diff --git a/.changeset/bright-auth-skeleton.md b/.changeset/bright-auth-skeleton.md new file mode 100644 index 00000000000..57af0687d26 --- /dev/null +++ b/.changeset/bright-auth-skeleton.md @@ -0,0 +1,5 @@ +--- +'@shopify/dev-platform-auth': minor +--- + +Add the `@shopify/dev-platform-auth` package skeleton. diff --git a/.changeset/config.json b/.changeset/config.json index d12fe1e0daa..607e96235eb 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -12,7 +12,8 @@ "@shopify/cli-kit", "@shopify/theme", "@shopify/plugin-cloudflare", - "@shopify/plugin-did-you-mean" + "@shopify/plugin-did-you-mean", + "@shopify/dev-platform-auth" ]], "access": "public", "baseBranch": "main", diff --git a/configurations/vite.config.ts b/configurations/vite.config.ts index 0a577cbfe58..a4e706c2104 100644 --- a/configurations/vite.config.ts +++ b/configurations/vite.config.ts @@ -85,5 +85,6 @@ export const aliases = (packagePath: string) => { {find: '@shopify/theme', replacement: path.join(packagePath, '../theme/src/index')}, {find: '@shopify/organizations', replacement: path.join(packagePath, '../organizations/src/index')}, {find: '@shopify/store', replacement: path.join(packagePath, '../store/src/index')}, + {find: '@shopify/dev-platform-auth', replacement: path.join(packagePath, '../dev-platform-auth/src/index')}, ] } diff --git a/package.json b/package.json index fb7d604dcb4..49548951acb 100644 --- a/package.json +++ b/package.json @@ -248,6 +248,17 @@ ] } }, + "packages/dev-platform-auth": { + "entry": [ + "**/index.ts!" + ], + "project": "**/*.ts!", + "vite": { + "config": [ + "vite.config.ts" + ] + } + }, "packages/store": { "entry": [ "**/{commands,hooks}/**/*.ts!", diff --git a/packages/cli-kit/package.json b/packages/cli-kit/package.json index b6ca7f3a4fa..8bcff7a8928 100644 --- a/packages/cli-kit/package.json +++ b/packages/cli-kit/package.json @@ -102,6 +102,7 @@ ] }, "dependencies": { + "@shopify/dev-platform-auth": "workspace:*", "@apidevtools/json-schema-ref-parser": "11.9.3", "@bugsnag/js": "8.9.0", "@graphql-typed-document-node/core": "3.2.0", diff --git a/packages/cli-kit/src/public/node/session.auth-clientcreds.test.ts b/packages/cli-kit/src/public/node/session.auth-clientcreds.test.ts new file mode 100644 index 00000000000..59af4d4fd16 --- /dev/null +++ b/packages/cli-kit/src/public/node/session.auth-clientcreds.test.ts @@ -0,0 +1,25 @@ +import {ensureAuthenticatedAdminAsApp} from './session.js' +import {shopifyFetch} from './http.js' +import {describe, expect, test, vi} from 'vitest' + +vi.mock('./http.js') + +describe('ensureAuthenticatedAdminAsApp client credentials errors', () => { + test('does not include upstream status text in the error', async () => { + vi.mocked(shopifyFetch).mockResolvedValueOnce({ + status: 500, + statusText: 'attacker-controlled upstream detail', + text: async () => JSON.stringify({error: 'invalid_client'}), + } as unknown as Awaited>) + + const error = await ensureAuthenticatedAdminAsApp('mystore.myshopify.com', 'client123', 'secret456').catch( + (caught) => caught, + ) + + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toBe( + 'Failed to get access token for app client123 on store mystore.myshopify.com: HTTP status 500', + ) + expect((error as Error).message).not.toContain('attacker-controlled upstream detail') + }) +}) diff --git a/packages/cli-kit/src/public/node/session.ts b/packages/cli-kit/src/public/node/session.ts index 15be5d39cbf..429db072e47 100644 --- a/packages/cli-kit/src/public/node/session.ts +++ b/packages/cli-kit/src/public/node/session.ts @@ -22,6 +22,7 @@ import { setLastSeenUserIdAfterAuth, } from '../../private/node/session.js' import {isThemeAccessSession} from '../../private/node/api/rest.js' +import {createClientCredentialsClient, type AuthFetch} from '@shopify/dev-platform-auth' /** * Session Object to access the Admin API, includes the token and the store FQDN. @@ -343,47 +344,37 @@ export async function ensureAuthenticatedAdminAsApp( clientId: string, clientSecret: string, ): Promise { - const bodyData = { - client_id: clientId, - client_secret: clientSecret, - grant_type: 'client_credentials', + const fetch: AuthFetch = async (url, init) => { + const response = await shopifyFetch(url, {...init}, 'slow-request') + return { + status: response.status, + text: () => response.text(), + } } - const tokenResponse = await shopifyFetch( - `https://${storeFqdn}/admin/oauth/access_token`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(bodyData), - }, - 'slow-request', - ) + const result = await createClientCredentialsClient({fetch}).requestToken({ + storeFqdn, + clientId, + clientSecret, + }) - const body = await tokenResponse.text() + if ('accessToken' in result) return {token: result.accessToken, storeFqdn} - if (tokenResponse.status === 400) { - if (body.includes('app_not_installed')) { + if ('serverCode' in result) { + if (result.serverCode === 'app_not_installed') { throw new AbortError( outputContent`App is not installed on ${outputToken.green( storeFqdn, )}. Try running ${outputToken.genericShellCommand(`shopify app dev`)} to connect your app to the shop.`, ) } - throw new AbortError( - `Failed to get access token for app ${clientId} on store ${storeFqdn}: ${tokenResponse.statusText}`, - ) + throw new AbortError(clientCredentialsFailureMessage(result.status, storeFqdn, clientId)) } - try { - const tokenJson = JSON.parse(body) as {access_token: string} - return {token: tokenJson.access_token, storeFqdn} - } catch (error) { - if (error instanceof SyntaxError) { - throw new AbortError( - `Received invalid response from admin authentication service (HTTP ${tokenResponse.status}).`, - 'The response could not be parsed as JSON. The service may be temporarily unavailable. Please try again.', - ) - } - throw error + if (result.kind === 'malformed_response' || result.kind === 'unexpected_status') { + throw new AbortError(clientCredentialsFailureMessage(result.status, storeFqdn, clientId)) } + throw new AbortError(`Failed to get access token for app ${clientId} on store ${storeFqdn}: request failed`) +} + +function clientCredentialsFailureMessage(status: number, storeFqdn: string, clientId: string): string { + return `Failed to get access token for app ${clientId} on store ${storeFqdn}: HTTP status ${status}` } diff --git a/packages/dev-platform-auth/README.md b/packages/dev-platform-auth/README.md new file mode 100644 index 00000000000..7f710cab23b --- /dev/null +++ b/packages/dev-platform-auth/README.md @@ -0,0 +1,7 @@ +# @shopify/dev-platform-auth + +`@shopify/dev-platform-auth` provides portable Shopify developer auth flows. + +This package currently provides the portable client-credentials contract and runtime, including transport types and testing helpers. Identity and Store PKCE flows are out of scope. + +The portability contract is ESM on Node.js >=20, with no Node built-ins in the `.` entry. Only `.` and `./testing` are exported. diff --git a/packages/dev-platform-auth/package.json b/packages/dev-platform-auth/package.json new file mode 100644 index 00000000000..fff33fd9139 --- /dev/null +++ b/packages/dev-platform-auth/package.json @@ -0,0 +1,56 @@ +{ + "name": "@shopify/dev-platform-auth", + "version": "4.6.0", + "packageManager": "pnpm@10.11.1", + "private": false, + "description": "Portable Shopify developer auth flows", + "homepage": "https://github.com/shopify/cli#readme", + "bugs": { + "url": "https://community.shopify.dev/c/shopify-cli-libraries/14" + }, + "repository": { + "type": "git", + "url": "https://github.com/Shopify/cli.git", + "directory": "packages/dev-platform-auth" + }, + "license": "MIT", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "node": "./dist/index.js" + }, + "./testing": { + "types": "./dist/testing/index.d.ts", + "import": "./dist/testing/index.js", + "node": "./dist/testing/index.js" + } + }, + "files": ["dist"], + "scripts": { + "build": "nx build", + "clean": "nx clean", + "lint": "nx lint", + "lint:fix": "nx lint:fix", + "type-check": "nx type-check", + "vitest": "vitest" + }, + "eslintConfig": { + "extends": ["../../.eslintrc.cjs"] + }, + "devDependencies": { + "@types/node": "18.19.130", + "@vitest/coverage-istanbul": "^3.2.7", + "esbuild": "0.28.1" + }, + "engines": { + "node": ">=20.10.0" + }, + "publishConfig": { + "@shopify:registry": "https://registry.npmjs.org", + "access": "public" + }, + "sideEffects": false, + "engine-strict": true +} diff --git a/packages/dev-platform-auth/project.json b/packages/dev-platform-auth/project.json new file mode 100644 index 00000000000..4c17ed8d9f4 --- /dev/null +++ b/packages/dev-platform-auth/project.json @@ -0,0 +1,46 @@ +{ + "name": "dev-platform-auth", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/dev-platform-auth/src", + "projectType": "library", + "tags": ["scope:foundation"], + "targets": { + "clean": { + "executor": "nx:run-commands", + "options": { + "command": "pnpm rimraf dist/", + "cwd": "packages/dev-platform-auth" + } + }, + "build": { + "executor": "nx:run-commands", + "outputs": ["{workspaceRoot}/packages/dev-platform-auth/dist"], + "inputs": ["{projectRoot}/src/**/*", "{projectRoot}/package.json", "{projectRoot}/tsconfig.build.json"], + "options": { + "command": "pnpm tsc -b ./tsconfig.build.json", + "cwd": "packages/dev-platform-auth" + } + }, + "lint": { + "executor": "nx:run-commands", + "options": { + "command": "pnpm eslint src", + "cwd": "packages/dev-platform-auth" + } + }, + "lint:fix": { + "executor": "nx:run-commands", + "options": { + "command": "pnpm eslint src --fix", + "cwd": "packages/dev-platform-auth" + } + }, + "type-check": { + "executor": "nx:run-commands", + "options": { + "command": "pnpm tsc --noEmit", + "cwd": "packages/dev-platform-auth" + } + } + } +} diff --git a/packages/dev-platform-auth/src/client-credentials.test.ts b/packages/dev-platform-auth/src/client-credentials.test.ts new file mode 100644 index 00000000000..a1d23e43196 --- /dev/null +++ b/packages/dev-platform-auth/src/client-credentials.test.ts @@ -0,0 +1,110 @@ +import {requestClientCredentialsToken} from './client-credentials.js' +import {describe, expect, test} from 'vitest' +import type {AuthFetch, ClientCredentialsTokenRequest} from './index.js' + +const request: ClientCredentialsTokenRequest = { + storeFqdn: 'example.myshopify.com', + clientId: 'client-id', + clientSecret: 'client-secret', +} + +const requestWithSignal = request as ClientCredentialsTokenRequest & {signal: unknown} + +test('does not expose cancellation on the client-credentials request', () => { + expect('signal' in requestWithSignal).toBe(false) +}) + +function fetchResponse(status: number, body: string): AuthFetch { + return async () => ({status, text: async () => body}) +} + +describe('requestClientCredentialsToken', () => { + test('returns the access token and store without fabricated expiry', async () => { + await expect( + requestClientCredentialsToken({fetch: fetchResponse(200, '{"access_token":"token"}')}, request), + ).resolves.toEqual({ + accessToken: 'token', + storeFqdn: request.storeFqdn, + }) + }) + + test('maps app_not_installed only from a 400 raw response', async () => { + await expect( + requestClientCredentialsToken({fetch: fetchResponse(400, 'app_not_installed')}, request), + ).resolves.toEqual({ + serverCode: 'app_not_installed', + status: 400, + }) + await expect( + requestClientCredentialsToken({fetch: fetchResponse(500, 'app_not_installed')}, request), + ).resolves.toMatchObject({ + kind: 'malformed_response', + status: 500, + }) + }) + + test.each([200, 400, 500])('classifies malformed JSON as malformed_response for HTTP %s', async (status) => { + await expect( + requestClientCredentialsToken({fetch: fetchResponse(status, 'not-json')}, request), + ).resolves.toMatchObject({ + kind: 'malformed_response', + status, + }) + }) + + test.each([ + [500, true, 'server_error'], + [200, false, 'token'], + ] as const)('uses numeric status rather than ok (%s/%s)', async (status, ok, expected) => { + const fetch: AuthFetch = async () => ({ok, status, text: async () => '{"access_token":"token"}'}) + const result = await requestClientCredentialsToken({fetch}, request) + if (expected === 'token') { + expect(result).toEqual({accessToken: 'token', storeFqdn: request.storeFqdn}) + } else { + expect(result).toEqual({serverCode: 'unknown_error', status}) + expect(result).not.toHaveProperty('accessToken') + } + }) + + test('rejects successful JSON without a non-empty access token', async () => { + await expect(requestClientCredentialsToken({fetch: fetchResponse(200, '{}')}, request)).resolves.toEqual({ + kind: 'malformed_response', + status: 200, + }) + await expect( + requestClientCredentialsToken({fetch: fetchResponse(200, '{"access_token":""}')}, request), + ).resolves.toEqual({ + kind: 'malformed_response', + status: 200, + }) + }) + + test('classifies an unexpected non-JSON error shape as unexpected_status', async () => { + await expect(requestClientCredentialsToken({fetch: fetchResponse(500, '"unexpected"')}, request)).resolves.toEqual({ + kind: 'unexpected_status', + status: 500, + }) + }) + + test.each([ + ['unknown code', '{"error":"not_a_known_code"}'], + ['missing code', '{}'], + ['empty code', '{"error":""}'], + ])('uses a safe server code for %s', async (_name, body) => { + await expect(requestClientCredentialsToken({fetch: fetchResponse(400, body)}, request)).resolves.toMatchObject({ + serverCode: body === '{"error":"not_a_known_code"}' ? 'not_a_known_code' : 'unknown_error', + status: 400, + }) + }) + + test('classifies a fetch failure as transport_failed without leaking its cause', async () => { + const fetch: AuthFetch = async () => { + throw new Error('upstream secret') + } + await expect(requestClientCredentialsToken({fetch}, request)).resolves.toMatchObject({kind: 'transport_failed'}) + }) + + test('classifies a missing fetch as transport_failed', async () => { + await expect(requestClientCredentialsToken({}, request)).resolves.toMatchObject({kind: 'transport_failed'}) + }) +}) diff --git a/packages/dev-platform-auth/src/client-credentials.ts b/packages/dev-platform-auth/src/client-credentials.ts new file mode 100644 index 00000000000..22483bc8605 --- /dev/null +++ b/packages/dev-platform-auth/src/client-credentials.ts @@ -0,0 +1,82 @@ +import type { + ClientCredentialsConfig, + ClientCredentialsToken, + ClientCredentialsClient, + ClientCredentialsError, + ClientCredentialsTokenRequest, +} from './index.js' + +export function createClientCredentialsClient(config: ClientCredentialsConfig): ClientCredentialsClient { + return { + requestToken: async (request) => requestClientCredentialsToken(config, request), + } +} + +export async function requestClientCredentialsToken( + config: ClientCredentialsConfig, + request: ClientCredentialsTokenRequest, +): Promise { + if (!config.fetch) { + return {kind: 'transport_failed', cause: new Error('Auth fetch is not configured')} + } + + let response + try { + response = await config.fetch(`https://${request.storeFqdn}/admin/oauth/access_token`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + client_id: request.clientId, + client_secret: request.clientSecret, + grant_type: 'client_credentials', + }), + }) + // eslint-disable-next-line no-catch-all/no-catch-all + } catch { + // The fetch port reports all request failures as typed transport errors. + return {kind: 'transport_failed', cause: new Error('Auth request failed')} + } + + let body: string + try { + body = await response.text() + // eslint-disable-next-line no-catch-all/no-catch-all + } catch { + return {kind: 'transport_failed', cause: new Error('Auth response could not be read')} + } + + let parsed: unknown + try { + parsed = JSON.parse(body) + // eslint-disable-next-line no-catch-all/no-catch-all + } catch { + const serverCode = response.status === 400 && body.includes('app_not_installed') ? 'app_not_installed' : undefined + if (serverCode) return {serverCode, status: response.status} + return {kind: 'malformed_response', status: response.status, cause: new Error('Auth response is not valid JSON')} + } + + const isSuccess = response.status >= 200 && response.status < 300 + if (!isSuccess) { + const serverCode = readServerCode(parsed) + return serverCode ? {serverCode, status: response.status} : {kind: 'unexpected_status', status: response.status} + } + + if (!isRecord(parsed) || typeof parsed.access_token !== 'string' || parsed.access_token.length === 0) { + return {kind: 'malformed_response', status: response.status} + } + + return { + accessToken: parsed.access_token, + storeFqdn: request.storeFqdn, + } +} + +function readServerCode(value: unknown): string | undefined { + if (!isRecord(value)) return undefined + if ('error' in value && typeof value.error === 'string' && value.error.length > 0) return value.error + return 'unknown_error' +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} diff --git a/packages/dev-platform-auth/src/index.ts b/packages/dev-platform-auth/src/index.ts new file mode 100644 index 00000000000..a82c551e380 --- /dev/null +++ b/packages/dev-platform-auth/src/index.ts @@ -0,0 +1,44 @@ +export const PACKAGE_NAME = '@shopify/dev-platform-auth' + +export interface ClientCredentialsToken { + accessToken: string + storeFqdn: string +} + +export interface ClientCredentialsConfig { + fetch?: AuthFetch +} + +export interface AuthFetchResponse { + status: number + text(): Promise +} + +export type AuthFetch = ( + url: string, + init: { + method: string + headers: Record + body?: string + }, +) => Promise + +export interface ClientCredentialsTokenRequest { + storeFqdn: string + clientId: string + clientSecret: string +} + +export type ClientCredentialsError = + | {kind: 'transport_failed'; cause: unknown} + | {kind: 'malformed_response'; status: number; cause?: unknown} + | {kind: 'unexpected_status'; status: number} + | {serverCode: string; status: number} + +export type ClientCredentialsResult = ClientCredentialsToken | ClientCredentialsError + +export interface ClientCredentialsClient { + requestToken(options: ClientCredentialsTokenRequest): Promise +} + +export {createClientCredentialsClient, requestClientCredentialsToken} from './client-credentials.js' diff --git a/packages/dev-platform-auth/src/portability.test.ts b/packages/dev-platform-auth/src/portability.test.ts new file mode 100644 index 00000000000..747fd3e2c6b --- /dev/null +++ b/packages/dev-platform-auth/src/portability.test.ts @@ -0,0 +1,38 @@ +/* eslint-disable no-restricted-imports, import-x/order */ +import {execFileSync} from 'node:child_process' +import {readFileSync} from 'node:fs' +import {resolve} from 'node:path' +import {build} from 'esbuild' +import {describe, expect, test} from 'vitest' + +const packageRoot = resolve(__dirname, '..') +const entry = resolve(packageRoot, 'dist/index.js') + +function buildPackage() { + execFileSync('pnpm', ['exec', 'nx', 'build', 'dev-platform-auth'], { + cwd: resolve(packageRoot, '../..'), + stdio: 'ignore', + }) +} + +describe('package portability', () => { + test('can be consumed by plain JavaScript', () => { + buildPackage() + execFileSync(process.execPath, [resolve(packageRoot, 'tests/consumer.mjs')], {stdio: 'pipe'}) + }) + + test('does not include Node built-ins or CommonJS in the portable entry', () => { + buildPackage() + const output = readFileSync(entry, 'utf8') + const nodeBuiltins = + /(?:node:)?(?:assert|buffer|child_process|cluster|crypto|dgram|dns|events|fs|http|https|module|net|os|path|perf_hooks|process|querystring|readline|stream|string_decoder|timers|tls|tty|url|util|v8|vm|worker_threads|zlib)/ + + expect(output).not.toMatch(nodeBuiltins) + expect(output).not.toContain('require(') + }) + + test('bundles for browsers without external dependencies', async () => { + buildPackage() + await expect(build({entryPoints: [entry], bundle: true, platform: 'browser', write: false})).resolves.toBeDefined() + }) +}) diff --git a/packages/dev-platform-auth/src/testing/README.md b/packages/dev-platform-auth/src/testing/README.md new file mode 100644 index 00000000000..42b1c39b628 --- /dev/null +++ b/packages/dev-platform-auth/src/testing/README.md @@ -0,0 +1,38 @@ +# Auth contract fixtures + +These fixtures are an independent transport contract for developer authentication. They have two layers: + +- **Layer 1 — pinned transport:** `request`, `responses`, and `transportCitation`. These pin the cited response structure, HTTP status, and content type. Response values are arbitrary, obviously fake fixture data unless the citation states otherwise; values such as `599` and `fixture-access-token` are not captured production facts. A citation is required for every fixture. The transport harness rejects calls after the declared response sequence, so polling count remains observable. Do not change Layer 1 cases to make a port pass. +- **Layer 2 — provisional outcomes:** `expected` and `provisionalOutcome`. These describe possible package conclusions and open questions. They are not a frozen error taxonomy. Layer 2 is intentionally open and changeable: propose a decision with characterization evidence and explicit review rather than silently changing it. + +Cancellation fields (`required`, `optional`, and `absent`) are Layer 2 design notes. No cli-kit auth function accepts a cancellation signal, so they do not define a current equivalence contract. Fixed expiry outcomes state the injected clock in their provisional note. Dynamic server fields such as `expires_in` are checked for presence and type, not pinned to an observed number. + +The fixtures use fake values only. `createFixtureFetch` has no HTTP or Node runtime dependency, so CLI and app SDK adapters can use the same data. + +## Resolving citations + +Citations name a file and line range but not a repository. Resolve them against these roots, or they will appear not to exist: + +| Citation looks like | Repository | Root to resolve against | +| --- | --- | --- | +| `packages/cli-kit/...` | `Shopify/cli` | repository root | +| `app/...`, `lib/rack/...`, `config/...`, `db/schema.rb` | `shopify/identity` | **`areas/platforms/identity/`**, not the repository root | +| `spec/...` | `shopify/identity` | `areas/platforms/identity/` | +| `*.test.ts` described as oracle | `Shopify/cli`, branch `donald/auth-characterization-tests` | repository root | + +Identity evidence was read at revision `b0a0a25efe32489cc4e24541c4a3bd653b262f97`; the characterization oracle at head `b95944ae09`. Line numbers drift, so confirm the quoted behavior rather than trusting the range. An Identity path checked from the repository root instead of `areas/platforms/identity/` will look fabricated when it is not. + +## Coverage gaps + +The 12 cases are not a complete oracle. The following Identity/cli-kit behavior is intentionally not covered: + +- Device authorization missing `verification_uri_complete` and its `BugError`. +- Malformed, empty, HTML, and other non-JSON responses. +- CI/noninteractive abort behavior. +- Unknown device errors mapping to `unknown_failure`. +- Device success expiry, scopes, and user ID from a valid JWT. +- The full refresh error family: `invalid_grant`, `invalid_request`, and `invalid_target`. +- All non-admin token-exchange audiences and their exact scopes. +- Client-credentials `app_not_installed` and non-JSON handling. + +Where server reality and cli-kit behavior differ, the fixture keeps the cli-kit equivalence behavior as the provisional expectation and records the divergence. In particular, Identity's `slow_down` response has no `interval`; cli-kit still applies a fixed +5-second client-policy increment. diff --git a/packages/dev-platform-auth/src/testing/fixtures.ts b/packages/dev-platform-auth/src/testing/fixtures.ts new file mode 100644 index 00000000000..59c72ca04ce --- /dev/null +++ b/packages/dev-platform-auth/src/testing/fixtures.ts @@ -0,0 +1,317 @@ +import type {AuthFetchResponse} from '../index.js' + +type AuthErrorCode = + | 'invalid_grant' + | 'invalid_request' + | 'invalid_target' + | 'access_denied' + | 'expired_token' + | 'invalid_response' + | 'app_not_installed' + | 'unknown' +export interface AuthSignal { + readonly aborted: boolean + addEventListener(type: 'abort', listener: () => void, options?: {once?: boolean}): void + removeEventListener(type: 'abort', listener: () => void): void +} + +export const fixtureOrigin = 'https://identity.example.test' +export const fakeClientId = 'fixture-client-id' + +export type FixtureOperation = + | 'device_authorization' + | 'device_code_poll' + | 'refresh_token' + | 'token_exchange' + | 'client_credentials' + +export type FixtureExpected = + | {kind: 'result'; value: Record} + | {kind: 'error'; code: AuthErrorCode | 'authorization_pending' | 'slow_down' | 'unknown_failure'; status?: number} + +export interface AuthFixtureRequest { + method: 'POST' + url: string + headers: Record + body: string +} + +export interface AuthFixtureResponse { + status: number + body: string +} + +export interface AuthFixture { + readonly name: string + readonly operation: FixtureOperation + readonly request: AuthFixtureRequest + readonly responses: ReadonlyArray + /** Layer 2 only: the package conclusion is not a frozen contract. */ + readonly expected?: FixtureExpected + /** Layer 2 only: cancellation is not accepted by cli-kit auth functions. */ + readonly signalExpectation: 'required' | 'optional' | 'absent' + /** Layer 1 citation for the pinned request and raw response. */ + readonly transportCitation: string + /** Layer 2 rationale/open question. */ + readonly provisionalOutcome?: string + readonly redactedValues?: ReadonlyArray + readonly privateInputs?: ReadonlyArray<'device_code' | 'refresh_token' | 'client_secret' | 'subject_token'> +} + +const json = (body: unknown, status = 200): AuthFixtureResponse => ({status, body: JSON.stringify(body)}) +const formHeaders = {'Content-Type': 'application/x-www-form-urlencoded'} +const fakeIdentityResponse = { + access_token: 'fixture-access-token', + refresh_token: 'fixture-refresh-token', + token_type: 'Bearer', + scope: 'read_products write_products', + expires_in: 7200, + id_token: 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJmaXh0dXJlLXVzZXItMTIzNCJ9.Zml4dHVyZS1zaWduYXR1cmUtbm90LXZlcmlmaWVk', +} + +const deviceAuthorizationResponse = { + device_code: 'fixture-device-code', + user_code: 'fixture-user-code', + verification_uri: 'https://identity.example.test/verify', + verification_uri_complete: 'https://identity.example.test/verify?code=fixture-user-code', + expires_in: 599, + interval: 5, +} + +export const authFixtures: ReadonlyArray = [ + { + name: 'device authorization start with scopes', + operation: 'device_authorization', + request: { + method: 'POST', + url: `${fixtureOrigin}/oauth/device_authorization`, + headers: {'Content-type': formHeaders['Content-Type']}, + body: 'client_id=fixture-client-id&scope=read_products+write_products', + }, + signalExpectation: 'optional', + responses: [json(deviceAuthorizationResponse)], + expected: { + kind: 'result', + value: { + userCode: 'fixture-user-code', + verificationUri: 'https://identity.example.test/verify', + verificationUriComplete: 'https://identity.example.test/verify?code=fixture-user-code', + interval: 5, + }, + }, + transportCitation: + 'Identity app/operations/oauth/build_device_authorization_request_info.rb:20-27; live production capture', + provisionalOutcome: + 'Whether adapters expose all response fields is undecided; expires_in is dynamic and this fixture value is an arbitrary fake; signal handling is also provisional.', + redactedValues: ['fixture-device-code', 'fixture-user-code'], + privateInputs: ['device_code'], + }, + { + name: 'device authorization start omits empty scope', + operation: 'device_authorization', + request: { + method: 'POST', + url: `${fixtureOrigin}/oauth/device_authorization`, + headers: {'Content-type': formHeaders['Content-Type']}, + body: 'client_id=fixture-client-id', + }, + signalExpectation: 'absent', + responses: [json(deviceAuthorizationResponse)], + expected: { + kind: 'result', + value: { + userCode: 'fixture-user-code', + verificationUri: 'https://identity.example.test/verify', + verificationUriComplete: 'https://identity.example.test/verify?code=fixture-user-code', + interval: 5, + }, + }, + transportCitation: + 'Identity app/operations/oauth/build_device_authorization_request_info.rb:20-27; live production capture', + provisionalOutcome: + 'Empty-scope request and cancellation behavior require an adapter decision; expires_in is dynamic and this fixture value is an arbitrary fake.', + }, + { + name: 'device authorization malformed success', + operation: 'device_authorization', + request: { + method: 'POST', + url: `${fixtureOrigin}/oauth/device_authorization`, + headers: {'Content-type': formHeaders['Content-Type']}, + body: 'client_id=fixture-client-id', + }, + signalExpectation: 'absent', + responses: [json({user_code: 'fixture-user-code'})], + transportCitation: + 'cli-kit packages/cli-kit/src/private/node/session/device-authorization.ts:57-60; characterization oracle packages/cli-kit/src/private/node/session/device-authorization.test.ts:224-230', + provisionalOutcome: + 'Open question: cli-kit throws BugError for missing device fields; do not map this to AuthProtocolError.', + }, + { + name: 'device poll pending then success', + operation: 'device_code_poll', + request: { + method: 'POST', + url: `${fixtureOrigin}/oauth/token`, + headers: formHeaders, + body: 'grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code&device_code=fixture-device-code&client_id=fixture-client-id', + }, + signalExpectation: 'required', + responses: [ + json({error: 'authorization_pending', error_description: 'The authorization request is still pending.'}, 400), + json(fakeIdentityResponse), + ], + expected: {kind: 'result', value: {status: 'complete'}}, + transportCitation: + 'Identity lib/rack/oauth2/server/token/extension/device_code.rb:35-64; Identity app/lib/token_server.rb:69-100; live production capture', + provisionalOutcome: 'Polling and token mapping are provisional; cli-kit polls pending then decodes id_token.', + redactedValues: ['fixture-device-code'], + privateInputs: ['device_code'], + }, + { + name: 'device poll slow down', + operation: 'device_code_poll', + request: { + method: 'POST', + url: `${fixtureOrigin}/oauth/token`, + headers: formHeaders, + body: 'grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code&device_code=fixture-device-code&client_id=fixture-client-id', + }, + signalExpectation: 'absent', + responses: [json({error: 'slow_down', error_description: 'Polling too frequently.'}, 400)], + expected: {kind: 'error', code: 'slow_down', status: 400}, + transportCitation: 'Identity lib/rack/oauth2/server/token/extension/device_code.rb:35-64; live production capture', + provisionalOutcome: + 'The response has no interval. cli-kit applies a fixed client-policy +5 seconds (packages/cli-kit/src/private/node/session/device-authorization.ts:129-134); this is not a server instruction. Signal behavior is provisional.', + }, + { + name: 'device poll access denied (defensive only)', + operation: 'device_code_poll', + request: { + method: 'POST', + url: `${fixtureOrigin}/oauth/token`, + headers: formHeaders, + body: 'grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code&device_code=fixture-device-code&client_id=fixture-client-id', + }, + signalExpectation: 'absent', + responses: [json({error: 'access_denied', error_description: 'Access denied.'}, 400)], + expected: {kind: 'error', code: 'access_denied', status: 400}, + transportCitation: + 'Identity lib/rack/oauth2/server/token/extension/device_code.rb:38-63 defines authorization_pending, slow_down, and expired_token only', + provisionalOutcome: + 'No known device-flow path emits access_denied; this fixture retains it as a defensive cli-kit mapping. A generic access_denied from another token-endpoint layer is not ruled out.', + }, + { + name: 'device poll expired token', + operation: 'device_code_poll', + request: { + method: 'POST', + url: `${fixtureOrigin}/oauth/token`, + headers: formHeaders, + body: 'grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code&device_code=fixture-device-code&client_id=fixture-client-id', + }, + signalExpectation: 'absent', + responses: [json({error: 'expired_token', error_description: 'The device code has expired.'}, 400)], + expected: {kind: 'error', code: 'expired_token', status: 400}, + transportCitation: 'Identity lib/rack/oauth2/server/token/extension/device_code.rb:35-64; live production capture', + provisionalOutcome: 'Error taxonomy and cancellation behavior remain provisional.', + }, + { + name: 'refresh preserves identity metadata', + operation: 'refresh_token', + request: { + method: 'POST', + url: `${fixtureOrigin}/oauth/token`, + headers: formHeaders, + body: 'grant_type=refresh_token&access_token=fixture-current-access&refresh_token=fixture-current-refresh&client_id=fixture-client-id', + }, + signalExpectation: 'optional', + responses: [json(fakeIdentityResponse)], + expected: {kind: 'result', value: {expiresAt: 1700003600000, userId: 'fixture-user-id', alias: 'fixture-alias'}}, + transportCitation: 'Identity app/lib/token_server.rb:69-100; live production capture', + provisionalOutcome: 'Fixed expiry uses an injected clock of 1700000000000 ms. Metadata mapping is provisional.', + redactedValues: ['fixture-current-access', 'fixture-current-refresh'], + privateInputs: ['refresh_token'], + }, + { + name: 'refresh response omits refresh token', + operation: 'refresh_token', + request: { + method: 'POST', + url: `${fixtureOrigin}/oauth/token`, + headers: formHeaders, + body: 'grant_type=refresh_token&access_token=fixture-current-access&refresh_token=fixture-current-refresh&client_id=fixture-client-id', + }, + signalExpectation: 'absent', + responses: [json({access_token: 'fixture-new-access', scope: 'read_products', expires_in: 300})], + transportCitation: 'Identity app/lib/token_grants/refresh.rb:111-138 (refresh-token selection conditional)', + provisionalOutcome: + 'Open question: cli-kit does not validate refresh_token presence, and Identity may omit it. Tolerant handling is not a frozen error mapping.', + redactedValues: ['fixture-current-access', 'fixture-current-refresh'], + privateInputs: ['refresh_token'], + }, + { + name: 'admin token exchange uses destination and store-qualified key', + operation: 'token_exchange', + request: { + method: 'POST', + url: `${fixtureOrigin}/oauth/token`, + headers: formHeaders, + body: 'grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&client_id=fixture-client-id&audience=admin&scope=read_products&subject_token=fixture-identity-access&destination=https%3A%2F%2Fshop.example.test%2Fadmin&store=shop.example.test', + }, + signalExpectation: 'absent', + responses: [json({access_token: 'fixture-app-access', scope: 'read_products', expires_in: 300})], + expected: {kind: 'result', value: {key: 'shop.example.test-admin'}}, + transportCitation: + 'cli-kit packages/cli-kit/src/private/node/session/exchange.ts:182-197 (admin destination and store-qualified key); Identity app/lib/token_grants/token_exchange.rb:59-71,82-88; Identity lib/rack/oauth2/server/token/extension/token_exchange.rb:23-43', + provisionalOutcome: + 'Admin result shape is provisional; invalid_target is token-exchange-only, not a generic refresh error.', + redactedValues: ['fixture-identity-access'], + privateInputs: ['subject_token'], + }, + { + name: 'client credentials request', + operation: 'client_credentials', + request: { + method: 'POST', + url: 'https://shop.example.test/admin/oauth/access_token', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + client_id: 'fixture-app-id', + client_secret: 'fixture-app-secret', + grant_type: 'client_credentials', + }), + }, + signalExpectation: 'optional', + responses: [json({access_token: 'fixture-client-access'})], + expected: {kind: 'result', value: {expiresAt: 1700000300000}}, + transportCitation: 'cli-kit packages/cli-kit/src/public/node/session.ts:339-381', + provisionalOutcome: 'Fixed expiry uses an injected clock of 1700000000000 ms. Error handling remains provisional.', + redactedValues: ['fixture-app-secret'], + privateInputs: ['client_secret'], + }, + { + name: 'malformed OAuth error', + operation: 'token_exchange', + request: { + method: 'POST', + url: `${fixtureOrigin}/oauth/token`, + headers: formHeaders, + body: 'grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&subject_token=fixture-identity-access', + }, + signalExpectation: 'absent', + responses: [json({error_description: 'fixture failure'}, 400)], + transportCitation: + 'cli-kit packages/cli-kit/src/private/node/session/exchange.ts:262-267; characterization oracle packages/cli-kit/src/private/node/session/exchange.test.ts:412-425,465-471', + provisionalOutcome: + 'Open question: cli-kit normalizes missing error to unknown_error and exchange throws AbortError; use malformed_response only for a body that does not parse.', + redactedValues: ['fixture-identity-access'], + privateInputs: ['subject_token'], + }, +] + +function isAuthSignal(value: unknown): value is AuthSignal { + return typeof value === 'object' && value !== null && 'aborted' in value && 'addEventListener' in value +} + +type FixtureResponse = AuthFetchResponse diff --git a/packages/dev-platform-auth/src/testing/harness.test.ts b/packages/dev-platform-auth/src/testing/harness.test.ts new file mode 100644 index 00000000000..871d395e196 --- /dev/null +++ b/packages/dev-platform-auth/src/testing/harness.test.ts @@ -0,0 +1,120 @@ +import {authFixtures} from './fixtures.js' +import {createFixtureFetch} from './harness.js' +import {describe, expect, test} from 'vitest' +import type {AuthFixture, AuthSignal} from './fixtures.js' + +const signal: AuthSignal = { + aborted: false, + addEventListener: () => undefined, + removeEventListener: () => undefined, +} + +async function requestFixture( + fixture: AuthFixture, + request: Omit & {method: string} = fixture.request, + withSignal = false, +) { + const fetch = createFixtureFetch(fixture) + return fetch(request.url, { + method: request.method, + headers: request.headers, + body: request.body, + ...(withSignal ? {signal} : {}), + }) +} + +describe('createFixtureFetch', () => { + test.each(authFixtures)('accepts the valid request for $name', async (fixture) => { + await expect( + requestFixture(fixture, fixture.request, fixture.signalExpectation === 'required'), + ).resolves.toMatchObject({ + status: fixture.responses[0]?.status, + }) + }) + + test('rejects a wrong method', async () => { + const fixture = authFixtures[0]! + const request = {...fixture.request, method: 'GET'} + await expect(requestFixture(fixture, request)).rejects.toThrow('method or URL') + }) + + test('rejects a wrong URL', async () => { + const fixture = authFixtures[0]! + const request: AuthFixture['request'] = {...fixture.request, url: `${fixture.request.url}/wrong`} + await expect(requestFixture(fixture, request)).rejects.toThrow('method or URL') + }) + + test.each([ + ['missing', {}], + ['extra', {'X-Fixture': 'unexpected'}], + ['misvalued', {'Content-type': 'text/plain'}], + ])('rejects a %s header', async (_description, headers) => { + const fixture = authFixtures[0]! + const request: AuthFixture['request'] = {...fixture.request, headers} + await expect(requestFixture(fixture, request)).rejects.toThrow('headers or body') + }) + + test('rejects a reordered body', async () => { + const fixture = authFixtures.find(({request}) => request.body.includes('&'))! + const [first, second] = fixture.request.body.split('&') + const reordered = `${second}&${first}` + const request: AuthFixture['request'] = {...fixture.request, body: reordered} + await expect(requestFixture(fixture, request)).rejects.toThrow('headers or body') + }) + + test('rejects an altered body value', async () => { + const fixture = authFixtures[0]! + const request: AuthFixture['request'] = { + ...fixture.request, + body: fixture.request.body.replace('fixture-client-id', 'other-client'), + } + await expect(requestFixture(fixture, request)).rejects.toThrow('headers or body') + }) + + test('requires a signal when the fixture requires one', async () => { + const fixture = authFixtures.find(({signalExpectation}) => signalExpectation === 'required')! + await expect(requestFixture(fixture)).rejects.toThrow('expected an abort signal') + }) + + test('rejects a signal when the fixture requires it to be absent', async () => { + const fixture = authFixtures.find(({signalExpectation}) => signalExpectation === 'absent')! + await expect(requestFixture(fixture, fixture.request, true)).rejects.toThrow('did not expect an abort signal') + }) + + test('permits either signal state for optional fixtures', async () => { + const fixture = authFixtures.find(({signalExpectation}) => signalExpectation === 'optional')! + await expect(requestFixture(fixture)).resolves.toBeDefined() + await expect(requestFixture(fixture, fixture.request, true)).resolves.toBeDefined() + }) + + test('returns responses in fixture order', async () => { + const fixture: AuthFixture = { + ...authFixtures[0]!, + name: 'ordered responses', + responses: [ + {status: 202, body: 'first'}, + {status: 200, body: 'second'}, + ], + } + const fetch = createFixtureFetch(fixture) + const init = {method: fixture.request.method, headers: fixture.request.headers, body: fixture.request.body} + const first = await fetch(fixture.request.url, init) + const second = await fetch(fixture.request.url, init) + expect(first.status).toBe(202) + expect(second.status).toBe(200) + }) + + test('rejects a fixture with no responses', async () => { + const fixture: AuthFixture = {...authFixtures[0]!, name: 'empty responses', responses: []} + const fetch = createFixtureFetch(fixture) + await expect(requestFixture(fixture)).rejects.toThrow('fixture has no response') + }) + + test('rejects a response call after the sequence is exhausted', async () => { + const fixture = authFixtures[0]! + const fetch = createFixtureFetch(fixture) + const init = {method: fixture.request.method, headers: fixture.request.headers, body: fixture.request.body} + await fetch(fixture.request.url, init) + await expect(fetch(fixture.request.url, init)).rejects.toThrow('response sequence exhausted') + }) +}) diff --git a/packages/dev-platform-auth/src/testing/harness.ts b/packages/dev-platform-auth/src/testing/harness.ts new file mode 100644 index 00000000000..ce31f010dad --- /dev/null +++ b/packages/dev-platform-auth/src/testing/harness.ts @@ -0,0 +1,46 @@ +import type {AuthFetch, AuthFetchResponse} from '../index.js' +import type {AuthFixture, AuthSignal} from './fixtures.js' + +/** Creates a transport double from a fixture without requiring a runtime or HTTP library. */ +export function createFixtureFetch(fixture: AuthFixture): AuthFetch { + let responseIndex = 0 + return async (url, init: Parameters[1] & {signal?: AuthSignal}): Promise => { + if (url !== fixture.request.url || init.method !== fixture.request.method) { + throw new Error(`${fixture.name}: request method or URL did not match the fixture`) + } + if (!sameRecord(init.headers, fixture.request.headers) || init.body !== fixture.request.body) { + throw new Error(`${fixture.name}: request headers or body did not match the fixture`) + } + const signalState = init.signal ? 'present' : 'absent' + if (fixture.signalExpectation === 'required' && signalState !== 'present') { + throw new Error(`${fixture.name}: expected an abort signal`) + } + if (fixture.signalExpectation === 'absent' && signalState !== 'absent') { + throw new Error(`${fixture.name}: did not expect an abort signal`) + } + if (fixture.responses.length === 0) throw new Error(`${fixture.name}: fixture has no response`) + if (responseIndex >= fixture.responses.length) { + throw new Error(`${fixture.name}: response sequence exhausted after ${fixture.responses.length} response(s)`) + } + const response = fixture.responses[responseIndex++] + if (!response) throw new Error(`${fixture.name}: fixture has no response`) + return { + status: response.status, + text: async () => response.body, + } + } +} + +/** Runs a fixture transport and returns the observed response; adapters map it to fixture.expected. */ +export async function runFixtureTransport( + fixture: AuthFixture, + request: (fetch: AuthFetch, signal?: AuthSignal) => Promise, +): Promise { + return request(createFixtureFetch(fixture)) +} + +function sameRecord(actual: Record, expected: Record): boolean { + const actualKeys = Object.keys(actual) + const expectedKeys = Object.keys(expected) + return actualKeys.length === expectedKeys.length && expectedKeys.every((key) => actual[key] === expected[key]) +} diff --git a/packages/dev-platform-auth/src/testing/index.ts b/packages/dev-platform-auth/src/testing/index.ts new file mode 100644 index 00000000000..06a81e1b1a2 --- /dev/null +++ b/packages/dev-platform-auth/src/testing/index.ts @@ -0,0 +1,11 @@ +export const TESTING_PACKAGE_NAME = '@shopify/dev-platform-auth/testing' + +export {authFixtures, fakeClientId, fixtureOrigin} from './fixtures.js' +export type { + AuthFixture, + AuthFixtureRequest, + AuthFixtureResponse, + FixtureExpected, + FixtureOperation, +} from './fixtures.js' +export {createFixtureFetch, runFixtureTransport} from './harness.js' diff --git a/packages/dev-platform-auth/tests/consumer.mjs b/packages/dev-platform-auth/tests/consumer.mjs new file mode 100644 index 00000000000..f55476d8927 --- /dev/null +++ b/packages/dev-platform-auth/tests/consumer.mjs @@ -0,0 +1,5 @@ +import {PACKAGE_NAME} from '../dist/index.js' + +if (PACKAGE_NAME !== '@shopify/dev-platform-auth') { + throw new Error(`Unexpected package name: ${PACKAGE_NAME}`) +} diff --git a/packages/dev-platform-auth/tsconfig.build.json b/packages/dev-platform-auth/tsconfig.build.json new file mode 100644 index 00000000000..d472b2c2aa0 --- /dev/null +++ b/packages/dev-platform-auth/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["**/*.test.ts"] +} diff --git a/packages/dev-platform-auth/tsconfig.json b/packages/dev-platform-auth/tsconfig.json new file mode 100644 index 00000000000..3d7207d293a --- /dev/null +++ b/packages/dev-platform-auth/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../configurations/tsconfig.json", + "include": ["./src/**/*.ts"], + "exclude": ["./dist"], + "compilerOptions": { + "lib": ["ES2022"], + "target": "ES2022", + "outDir": "dist", + "rootDir": "src", + "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo", + "types": ["node", "vitest/importMeta"] + }, + "references": [] +} diff --git a/packages/dev-platform-auth/vite.config.ts b/packages/dev-platform-auth/vite.config.ts new file mode 100644 index 00000000000..9536586ca45 --- /dev/null +++ b/packages/dev-platform-auth/vite.config.ts @@ -0,0 +1,3 @@ +import config from '../../configurations/vite.config' + +export default config(__dirname) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac9dc0316e9..0c49f86402b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -334,6 +334,9 @@ importers: '@opentelemetry/sdk-metrics': specifier: 1.30.1 version: 1.30.1(@opentelemetry/api@1.9.1) + '@shopify/dev-platform-auth': + specifier: workspace:* + version: link:../dev-platform-auth '@shopify/polaris': specifier: 12.27.0 version: 12.27.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -526,6 +529,18 @@ importers: specifier: ^2.1.1 version: 2.1.1(esbuild@0.28.1) + packages/dev-platform-auth: + devDependencies: + '@types/node': + specifier: 18.19.130 + version: 18.19.130 + '@vitest/coverage-istanbul': + specifier: ^3.2.7 + version: 3.2.7(vitest@4.1.10) + esbuild: + specifier: 0.28.1 + version: 0.28.1 + packages/e2e: devDependencies: '@iarna/toml': @@ -569,7 +584,7 @@ importers: version: 8.56.1(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) '@vitest/eslint-plugin': specifier: 1.1.44 - version: 1.1.44(@typescript-eslint/utils@8.56.1(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/coverage-istanbul@3.2.7(vitest@4.1.10))(jsdom@28.1.0)(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@6.4.3(@types/node@26.1.1)(jiti@2.6.1)(sass@1.100.0)(tsx@4.23.1)(yaml@2.9.0))) + version: 1.1.44(@typescript-eslint/utils@8.56.1(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/coverage-istanbul@3.2.7)(jsdom@28.1.0)(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@6.4.3(@types/node@26.1.1)(jiti@2.6.1)(sass@1.100.0)(tsx@4.23.1)(yaml@2.9.0))) eslint: specifier: ^9.0.0 version: 9.39.5(jiti@2.6.1) @@ -3759,6 +3774,7 @@ packages: '@shopify/polaris@12.27.0': resolution: {integrity: sha512-Y8yus6iEjcfW2ZtEJtlqxbWeDJqTX3S/MOLH4GWRvU5gFYJQhlaHaETs0+OimbhEpO95mXbY8qB+KnIJaVBHwA==, tarball: https://registry.npmjs.org/@shopify/polaris/-/polaris-12.27.0.tgz} engines: {node: ^16.17.0 || >=18.12.0} + deprecated: 'Polaris React is deprecated and no longer maintained. For building Shopify admin experiences, use Polaris web components: https://shopify.dev/docs/api/polaris — archived docs for this package: https://shopify.github.io/polaris-react-archive/' peerDependencies: react: ^18.0.0 react-dom: ^18.0.0 @@ -3989,6 +4005,9 @@ packages: '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==, tarball: https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz} + '@types/node@18.19.130': + resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==, tarball: https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz} + '@types/node@22.20.1': resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==, tarball: https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz} @@ -8495,6 +8514,9 @@ packages: uncrypto@0.1.3: resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==, tarball: https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz} + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==, tarball: https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==, tarball: https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz} @@ -12846,6 +12868,10 @@ snapshots: '@types/node@12.20.55': {} + '@types/node@18.19.130': + dependencies: + undici-types: 5.26.5 + '@types/node@22.20.1': dependencies: undici-types: 6.21.0 @@ -13082,17 +13108,17 @@ snapshots: magicast: 0.3.5 test-exclude: 7.0.2 tinyrainbow: 2.0.0 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/coverage-istanbul@3.2.7)(jsdom@28.1.0)(msw@2.15.0(@types/node@22.20.1)(typescript@5.9.3))(vite@6.4.3(@types/node@22.20.1)(jiti@2.6.1)(sass@1.100.0)(tsx@4.23.1)(yaml@2.9.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/coverage-istanbul@3.2.7)(jsdom@28.1.0)(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@6.4.3(@types/node@26.1.1)(jiti@2.6.1)(sass@1.100.0)(tsx@4.23.1)(yaml@2.9.0)) transitivePeerDependencies: - supports-color - '@vitest/eslint-plugin@1.1.44(@typescript-eslint/utils@8.56.1(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/coverage-istanbul@3.2.7(vitest@4.1.10))(jsdom@28.1.0)(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@6.4.3(@types/node@26.1.1)(jiti@2.6.1)(sass@1.100.0)(tsx@4.23.1)(yaml@2.9.0)))': + '@vitest/eslint-plugin@1.1.44(@typescript-eslint/utils@8.56.1(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/coverage-istanbul@3.2.7)(jsdom@28.1.0)(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@6.4.3(@types/node@26.1.1)(jiti@2.6.1)(sass@1.100.0)(tsx@4.23.1)(yaml@2.9.0)))': dependencies: '@typescript-eslint/utils': 8.56.1(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.5(jiti@2.6.1) optionalDependencies: typescript: 5.9.3 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/coverage-istanbul@3.2.7(vitest@4.1.10))(jsdom@28.1.0)(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@6.4.3(@types/node@26.1.1)(jiti@2.6.1)(sass@1.100.0)(tsx@4.23.1)(yaml@2.9.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/coverage-istanbul@3.2.7)(jsdom@28.1.0)(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@6.4.3(@types/node@26.1.1)(jiti@2.6.1)(sass@1.100.0)(tsx@4.23.1)(yaml@2.9.0)) '@vitest/eslint-plugin@1.1.44(@typescript-eslint/utils@8.56.1(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.10)': dependencies: @@ -13128,7 +13154,6 @@ snapshots: optionalDependencies: msw: 2.15.0(@types/node@26.1.1)(typescript@5.9.3) vite: 6.4.3(@types/node@26.1.1)(jiti@2.6.1)(sass@1.100.0)(tsx@4.23.1)(yaml@2.9.0) - optional: true '@vitest/pretty-format@4.1.10': dependencies: @@ -17934,6 +17959,8 @@ snapshots: uncrypto@0.1.3: {} + undici-types@5.26.5: {} + undici-types@6.21.0: {} undici-types@8.3.0: @@ -18102,7 +18129,7 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/coverage-istanbul@3.2.7(vitest@4.1.10))(jsdom@28.1.0)(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@6.4.3(@types/node@26.1.1)(jiti@2.6.1)(sass@1.100.0)(tsx@4.23.1)(yaml@2.9.0)): + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/coverage-istanbul@3.2.7)(jsdom@28.1.0)(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@6.4.3(@types/node@26.1.1)(jiti@2.6.1)(sass@1.100.0)(tsx@4.23.1)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 '@vitest/mocker': 4.1.10(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@6.4.3(@types/node@26.1.1)(jiti@2.6.1)(sass@1.100.0)(tsx@4.23.1)(yaml@2.9.0)) @@ -18131,7 +18158,6 @@ snapshots: jsdom: 28.1.0 transitivePeerDependencies: - msw - optional: true vscode-css-languageservice@6.3.2: dependencies: