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 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> { const listed = await kody.secretList({ scope: 'user' }) const names = new Set() 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 { 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 { 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 { 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 { 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 { 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 { 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 body?: unknown headers?: Record maxAttempts?: number } export type ShopifyRequestResult = { status: number ok: boolean body: unknown headers: Record 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 { 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 { const out: Record = {} 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 { 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( input: ShopifyRequestInput, ): Promise { const result = await shopifyRequestRaw(input) return result.body as T } export type ShopifyGraphqlInput = ShopifyClientOptions & { query: string variables?: Record } export type ShopifyGraphqlResult = { data: T errors?: Array<{ message: string; extensions?: Record }> extensions?: { cost?: { requestedQueryCost?: number actualQueryCost?: number throttleStatus?: { maximumAvailable?: number currentlyAvailable?: number restoreRate?: number } } } shop: string } export async function shopifyGraphqlRaw( input: ShopifyGraphqlInput, ): Promise> { 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 }> extensions?: ShopifyGraphqlResult['extensions'] } return { data: (payload.data ?? null) as T, errors: payload.errors, extensions: payload.extensions, shop, } } export async function shopifyGraphql( input: ShopifyGraphqlInput, ): Promise { const result = await shopifyGraphqlRaw(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 }).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(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 | 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 }).field.map(String) as Array) : 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 }