import { kody, packageStorage } from 'kody:runtime'
import type { ShopifyClientOptions, ShopifyPageInfo } from '../types.ts'
import {
clampInt,
DEFAULT_API_VERSION,
normalizeShop,
resolveApiVersion,
shopOrigin,
trimString,
} from './shop.ts'
export const ADMIN_ACCESS_TOKEN_SECRET = 'shopifyAdminAccessToken'
export const CLIENT_ID_SECRET = 'shopifyClientId'
export const CLIENT_SECRET_SECRET = 'shopifyClientSecret'
export const DEFAULT_SHOP_KEY = 'defaultShop'
export const DEFAULT_API_VERSION_KEY = 'defaultApiVersion'
const ADMIN_TOKEN_PLACEHOLDER = '{{secret:shopifyAdminAccessToken}}'
const CLIENT_ID_PLACEHOLDER = '{{secret:shopifyClientId}}'
const CLIENT_SECRET_PLACEHOLDER = '{{secret:shopifyClientSecret}}'
export class ShopifyApiError extends Error {
status: number
body: unknown
shop: string
path: string
constructor(
status: number,
body: unknown,
shop: string,
path: string,
message?: string,
) {
const detail = extractErrorDetail(body)
const suffix = detail ? `: ${detail}` : ''
const rateNote = status === 429 ? ' (rate limited — retry with backoff)' : ''
super(message || `Shopify API ${status} ${path}${suffix}${rateNote}`)
this.name = 'ShopifyApiError'
this.status = status
this.body = body
this.shop = shop
this.path = path
}
}
function extractErrorDetail(body: unknown): string {
if (!body) return ''
if (typeof body === 'string') return body.slice(0, 400)
if (typeof body !== 'object') return ''
const record = body as Record<string, unknown>
if (typeof record.errors === 'string') return record.errors
if (Array.isArray(record.errors)) {
return record.errors
.map((item) => {
if (typeof item === 'string') return item
if (item && typeof item === 'object' && 'message' in item) {
return String((item as { message: unknown }).message)
}
return JSON.stringify(item)
})
.join('; ')
.slice(0, 400)
}
if (typeof record.message === 'string') return record.message
return ''
}
function secretEntries(
result: unknown,
): Array<{ name?: string; scope?: string }> {
if (
result &&
typeof result === 'object' &&
Array.isArray((result as { secrets?: unknown }).secrets)
) {
return (result as { secrets: Array<{ name?: string; scope?: string }> })
.secrets
}
return Array.isArray(result) ? result : []
}
export async function listUserSecretNames(): Promise<Set<string>> {
const listed = await kody.secret_list({ scope: 'user' })
const names = new Set<string>()
for (const entry of secretEntries(listed)) {
if (entry?.name && (entry.scope === 'user' || !entry.scope)) {
names.add(entry.name)
}
}
return names
}
export type ShopifyAuthMode = 'admin-token' | 'client-credentials'
export async function resolveAuthMode(): Promise<ShopifyAuthMode> {
const names = await listUserSecretNames()
if (names.has(ADMIN_ACCESS_TOKEN_SECRET)) return 'admin-token'
if (names.has(CLIENT_ID_SECRET) && names.has(CLIENT_SECRET_SECRET)) {
return 'client-credentials'
}
throw new Error(
[
'Shopify credentials are missing.',
`Save either ${ADMIN_ACCESS_TOKEN_SECRET} (legacy custom-app Admin API token)`,
`or both ${CLIENT_ID_SECRET} and ${CLIENT_SECRET_SECRET} (Dev Dashboard client credentials).`,
'Setup: https://kody.codes/account/secrets/new?name=shopifyClientSecret&description=Shopify%20Dev%20Dashboard%20client%20secret&scope=user',
'Docs: https://shopify.dev/docs/apps/build/dev-dashboard/get-api-access-tokens',
].join(' '),
)
}
async function readStorageString(key: string): Promise<string | null> {
try {
const store = packageStorage()
const raw = await store.get(key)
if (typeof raw === 'string' && raw.trim()) return raw.trim()
return null
} catch {
return null
}
}
export async function resolveShop(shop?: unknown): Promise<string> {
if (trimString(shop)) return normalizeShop(shop)
const stored = await readStorageString(DEFAULT_SHOP_KEY)
if (stored) return normalizeShop(stored)
throw new Error(
'Shop is required. Pass shop: "your-store", or after forking call setDefaultShop from kody:@kody/shopify/config.',
)
}
export async function resolveVersion(apiVersion?: unknown): Promise<string> {
if (trimString(apiVersion)) return resolveApiVersion(apiVersion)
const stored = await readStorageString(DEFAULT_API_VERSION_KEY)
return resolveApiVersion(stored || DEFAULT_API_VERSION)
}
async function exchangeClientCredentials(shop: string): Promise<string> {
const url = `${shopOrigin(shop)}/admin/oauth/access_token`
const body = new URLSearchParams({
grant_type: 'client_credentials',
client_id: CLIENT_ID_PLACEHOLDER,
client_secret: CLIENT_SECRET_PLACEHOLDER,
})
const response = await fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'kody-shopify/1.0',
},
body,
})
const text = await response.text()
let parsed: unknown = text
try {
parsed = text ? JSON.parse(text) : null
} catch {
parsed = text
}
if (!response.ok) {
throw new ShopifyApiError(
response.status,
parsed,
shop,
'/admin/oauth/access_token',
'Shopify client-credentials token exchange failed',
)
}
const token =
parsed &&
typeof parsed === 'object' &&
typeof (parsed as { access_token?: unknown }).access_token === 'string'
? (parsed as { access_token: string }).access_token
: ''
if (!token) {
throw new Error(
'Shopify client-credentials response did not include access_token.',
)
}
return token
}
export async function resolveAccessToken(shop: string): Promise<string> {
const mode = await resolveAuthMode()
if (mode === 'admin-token') return ADMIN_TOKEN_PLACEHOLDER
return await exchangeClientCredentials(shop)
}
export type ShopifyRequestInput = ShopifyClientOptions & {
/** Admin path (`/products.json`) or absolute shop URL under /admin/. */
path: string
method?: string
query?: Record<string, string | number | boolean | undefined | null>
body?: unknown
headers?: Record<string, string>
maxAttempts?: number
}
export type ShopifyRequestResult = {
status: number
ok: boolean
body: unknown
headers: Record<string, string>
shop: string
url: string
}
function assertAdminPath(path: string): string {
const trimmed = trimString(path)
if (!trimmed) throw new Error('path is required.')
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
const url = new URL(trimmed)
if (!url.hostname.endsWith('.myshopify.com')) {
throw new Error('Absolute Shopify URLs must use a *.myshopify.com host.')
}
if (!url.pathname.startsWith('/admin/')) {
throw new Error('Absolute Shopify URLs must stay under /admin/.')
}
return trimmed
}
const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
if (withSlash.startsWith('/admin/')) return withSlash
return withSlash
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function parseRetryMs(response: Response, attempt: number): number {
const retryAfter = response.headers.get('retry-after')
if (retryAfter) {
if (Number.isFinite(Number(retryAfter))) return Number(retryAfter) * 1000
const until = Date.parse(retryAfter)
if (Number.isFinite(until)) return Math.max(0, until - Date.now())
}
return Math.min(8000, 400 * 2 ** (attempt - 1))
}
function headerRecord(headers: Headers): Record<string, string> {
const out: Record<string, string> = {}
headers.forEach((value, key) => {
out[key] = value
})
return out
}
function parseBody(text: string, contentType: string): unknown {
if (!text.trim()) return null
if (contentType.includes('application/json')) {
try {
return JSON.parse(text)
} catch {
return text
}
}
const trimmed = text.trim()
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
return JSON.parse(trimmed)
} catch {
return text
}
}
return text
}
/** Compact authenticated request to a shop Admin API (REST or absolute /admin URL). */
export async function shopifyRequestRaw(
input: ShopifyRequestInput,
): Promise<ShopifyRequestResult> {
const shop = await resolveShop(input.shop)
const version = await resolveVersion(input.apiVersion)
const token = await resolveAccessToken(shop)
const origin = shopOrigin(shop)
const rawPath = assertAdminPath(input.path)
const url = rawPath.startsWith('http')
? new URL(rawPath)
: new URL(
rawPath.startsWith('/admin/')
? rawPath
: `/admin/api/${version}${rawPath.startsWith('/') ? rawPath : `/${rawPath}`}`,
origin,
)
if (url.origin !== origin || !url.pathname.startsWith('/admin/')) {
throw new Error('Shopify requests must stay on this shop\'s /admin/ origin.')
}
if (input.query) {
for (const [key, value] of Object.entries(input.query)) {
if (value === undefined || value === null || value === '') continue
url.searchParams.set(key, String(value))
}
}
const maxAttempts = clampInt(input.maxAttempts, 1, 5, 3)
let last: ShopifyRequestResult | null = null
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const headers = new Headers({
Accept: 'application/json',
'X-Shopify-Access-Token': token,
'User-Agent': 'kody-shopify/1.0',
})
if (input.headers) {
for (const [key, value] of Object.entries(input.headers)) {
if (value) headers.set(key, value)
}
}
const init: RequestInit = {
method: trimString(input.method) || 'GET',
headers,
}
if (input.body !== undefined) {
if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
init.body =
typeof input.body === 'string'
? input.body
: JSON.stringify(input.body)
}
const response = await fetch(url.toString(), init)
const text = await response.text()
const body = parseBody(text, response.headers.get('content-type') || '')
last = {
status: response.status,
ok: response.ok,
body,
headers: headerRecord(response.headers),
shop,
url: url.toString(),
}
if (response.ok) return last
if (
!(response.status === 429 || response.status >= 500) ||
attempt >= maxAttempts
) {
throw new ShopifyApiError(response.status, body, shop, url.pathname)
}
await sleep(parseRetryMs(response, attempt))
}
throw new ShopifyApiError(last?.status || 0, last?.body, shop, url.pathname)
}
export async function shopifyRest<T = unknown>(
input: ShopifyRequestInput,
): Promise<T> {
const result = await shopifyRequestRaw(input)
return result.body as T
}
export type ShopifyGraphqlInput = ShopifyClientOptions & {
query: string
variables?: Record<string, unknown>
}
export type ShopifyGraphqlResult<T = unknown> = {
data: T
errors?: Array<{ message: string; extensions?: Record<string, unknown> }>
extensions?: {
cost?: {
requestedQueryCost?: number
actualQueryCost?: number
throttleStatus?: {
maximumAvailable?: number
currentlyAvailable?: number
restoreRate?: number
}
}
}
shop: string
}
export async function shopifyGraphqlRaw<T = unknown>(
input: ShopifyGraphqlInput,
): Promise<ShopifyGraphqlResult<T>> {
const query = trimString(input.query)
if (!query) throw new Error('GraphQL query is required.')
const shop = await resolveShop(input.shop)
const version = await resolveVersion(input.apiVersion)
const result = await shopifyRequestRaw({
shop,
apiVersion: version,
path: `/admin/api/${version}/graphql.json`,
method: 'POST',
body: {
query,
variables: input.variables || {},
},
})
const payload = (result.body || {}) as {
data?: T
errors?: Array<{ message: string; extensions?: Record<string, unknown> }>
extensions?: ShopifyGraphqlResult['extensions']
}
return {
data: (payload.data ?? null) as T,
errors: payload.errors,
extensions: payload.extensions,
shop,
}
}
export async function shopifyGraphql<T = unknown>(
input: ShopifyGraphqlInput,
): Promise<T> {
const result = await shopifyGraphqlRaw<T>(input)
if (result.errors?.length) {
const first = result.errors[0]
const code =
first.extensions && typeof first.extensions.code === 'string'
? ` [${first.extensions.code}]`
: ''
throw new ShopifyApiError(
200,
result.errors,
result.shop,
'/graphql.json',
`Shopify GraphQL error${code}: ${first.message}`,
)
}
return result.data
}
export function pageInfoFromConnection(value: unknown): ShopifyPageInfo {
const info =
value && typeof value === 'object'
? ((value as { pageInfo?: Record<string, unknown> }).pageInfo ?? {})
: {}
return {
hasNextPage: Boolean(info.hasNextPage),
hasPreviousPage: Boolean(info.hasPreviousPage),
startCursor: typeof info.startCursor === 'string' ? info.startCursor : null,
endCursor: typeof info.endCursor === 'string' ? info.endCursor : null,
}
}
export function nodesFromConnection<T>(value: unknown): T[] {
if (!value || typeof value !== 'object') return []
const record = value as { nodes?: unknown; edges?: unknown }
if (Array.isArray(record.nodes)) return record.nodes as T[]
if (Array.isArray(record.edges)) {
return record.edges
.map((edge) =>
edge && typeof edge === 'object'
? ((edge as { node?: T }).node as T)
: undefined,
)
.filter((node): node is T => node !== undefined)
}
return []
}
export function moneyFromSet(value: unknown): { amount: string; currencyCode: string } | null {
if (!value || typeof value !== 'object') return null
const shopMoney = (value as { shopMoney?: unknown }).shopMoney
if (!shopMoney || typeof shopMoney !== 'object') return null
const amount = trimString((shopMoney as { amount?: unknown }).amount)
const currencyCode = trimString(
(shopMoney as { currencyCode?: unknown }).currencyCode,
)
if (!amount || !currencyCode) return null
return { amount, currencyCode }
}
export function userErrorsFrom(value: unknown): Array<{
field: Array<string> | null
message: string
}> {
if (!value || typeof value !== 'object') return []
const errors = (value as { userErrors?: unknown }).userErrors
if (!Array.isArray(errors)) return []
return errors.map((error) => {
const record = error && typeof error === 'object' ? error : {}
const field = Array.isArray((record as { field?: unknown }).field)
? ((record as { field: Array<unknown> }).field.map(String) as Array<string>)
: null
return {
field,
message: trimString((record as { message?: unknown }).message) || 'Unknown user error',
}
})
}
export function assertNoUserErrors(
payload: unknown,
label: string,
): void {
const errors = userErrorsFrom(payload)
if (!errors.length) return
throw new Error(
`${label} failed: ${errors.map((error) => error.message).join('; ')}`,
)
}
export { clampInt, trimString }