import { accounts } from './accounts.ts' import { projectBaseUrl, secretPlaceholder, setupUrls, } from './setup.ts' import { SupabaseRequestError, isRecord, optionalString, requireString, type DryRunResult, type HttpMethod, type Json, type JsonRecord, type SupabaseAuthInput, } from './types.ts' const MANAGEMENT_BASE = 'https://api.supabase.com' const PROJECT_REF_PATTERN = /^[a-z0-9]{8,40}$/i export type RequestPreview = DryRunResult export function resolveProjectUrl(input: SupabaseAuthInput): { baseUrl: string host: string projectRef?: string } { const projectUrl = optionalString(input.projectUrl, 'projectUrl') if (projectUrl) { const url = new URL(projectUrl) if (url.protocol !== 'https:') { throw new Error('projectUrl must use https.') } return { baseUrl: url.origin, host: url.host, projectRef: optionalString(input.projectRef, 'projectRef'), } } const projectRef = requireString(input.projectRef, 'projectRef') if (!PROJECT_REF_PATTERN.test(projectRef)) { throw new Error( 'projectRef must be the Supabase project ref (8–40 letters or digits).', ) } return { baseUrl: projectBaseUrl(projectRef), host: `${projectRef}.supabase.co`, projectRef, } } export function joinUrl(baseUrl: string, path: string): string { const normalized = path.startsWith('/') ? path : `/${path}` return new URL(normalized, `${baseUrl.replace(/\/$/, '')}/`).toString() } export function withQuery( url: string, query?: Record, ): string { if (!query) return url const target = new URL(url) for (const [key, value] of Object.entries(query)) { if (value === undefined) continue target.searchParams.set(key, String(value)) } return target.toString() } function slimErrorBody(text: string): { message: string; code?: string } { try { const parsed: unknown = JSON.parse(text) if (isRecord(parsed)) { const message = optionalString(parsed.message, 'message') ?? optionalString(parsed.error, 'error') ?? optionalString(parsed.msg, 'msg') ?? text.slice(0, 400) const code = optionalString(parsed.code, 'code') ?? optionalString(parsed.error_code, 'error_code') return { message, code } } } catch { // Use the raw body when it is not JSON. } return { message: text.slice(0, 400) || 'Request failed' } } function authSetupHint(input: { status: number lane: 'pat' | 'serviceRole' auth: SupabaseAuthInput }): string | undefined { if (input.status !== 401 && input.status !== 403) return undefined const setup = setupUrls(input.auth) if (input.lane === 'pat') { return ` Save a personal access token at ${setup.patSetupUrl} and approve host api.supabase.com. Create the token at ${setup.patGenerateUrl}.` } const host = setup.projectHost ?? '{projectRef}.supabase.co' return ` Save the project service role / secret key at ${setup.serviceRoleSetupUrl} and approve host ${host}. Copy the key at ${setup.apiKeysUrl}.` } async function send(input: { url: string method: HttpMethod headers: Record body?: Json path: string lane: 'pat' | 'serviceRole' auth: SupabaseAuthInput }): Promise<{ status: number; headers: Record; data: unknown }> { const init: RequestInit = { method: input.method, headers: input.headers, } if (input.body !== undefined && input.method !== 'GET' && input.method !== 'HEAD') { init.body = JSON.stringify(input.body) } const response = await fetch(input.url, init) const text = await response.text() let data: unknown = null if (text) { try { data = JSON.parse(text) } catch { data = text } } if (!response.ok) { const slim = slimErrorBody(typeof data === 'string' ? data : text) throw new SupabaseRequestError({ message: `${input.method} ${input.path} failed (${response.status}): ${slim.message}.${authSetupHint({ status: response.status, lane: input.lane, auth: input.auth, }) ?? ''}`, status: response.status, method: input.method, path: input.path, code: slim.code, }) } const headers: Record = {} for (const [key, value] of response.headers.entries()) { if ( key === 'content-range' || key === 'content-type' || key === 'x-total-count' || key.startsWith('x-ratelimit-') ) { headers[key] = value } } return { status: response.status, headers, data } } export async function managementRequest(input: { auth?: SupabaseAuthInput method?: HttpMethod path: string query?: Record body?: Json dryRun?: boolean }): Promise<{ status: number; headers: Record; data: unknown } | RequestPreview> { const auth = input.auth ?? {} const account = await accounts(auth) const method = input.method ?? 'GET' const path = input.path.startsWith('/') ? input.path : `/${input.path}` const url = withQuery(joinUrl(MANAGEMENT_BASE, path), input.query) if (input.dryRun) { return { dryRun: true, method, path, url, body: input.body, } } const token = secretPlaceholder(account.patSecretName) return await send({ url, method, path, body: input.body, lane: 'pat', auth, headers: { Authorization: `Bearer ${token}`, Accept: 'application/json', ...(input.body !== undefined ? { 'Content-Type': 'application/json' } : {}), }, }) } export async function projectRequest(input: { auth: SupabaseAuthInput method?: HttpMethod path: string query?: Record headers?: Record body?: Json rawBody?: BodyInit dryRun?: boolean }): Promise<{ status: number; headers: Record; data: unknown } | RequestPreview> { const project = resolveProjectUrl(input.auth) const account = await accounts(input.auth) const method = input.method ?? 'GET' const path = input.path.startsWith('/') ? input.path : `/${input.path}` const url = withQuery(joinUrl(project.baseUrl, path), input.query) if (input.dryRun) { return { dryRun: true, method, path, url, headers: input.headers, body: input.body, } } const token = secretPlaceholder(account.serviceRoleSecretName) const headers: Record = { apikey: token, Authorization: `Bearer ${token}`, Accept: 'application/json', ...input.headers, } if (input.rawBody !== undefined) { const response = await fetch(url, { method, headers, body: input.rawBody, }) const text = await response.text() let data: unknown = null if (text) { try { data = JSON.parse(text) } catch { data = text } } if (!response.ok) { const slim = slimErrorBody(typeof data === 'string' ? data : text) throw new SupabaseRequestError({ message: `${method} ${path} failed (${response.status}): ${slim.message}.${authSetupHint({ status: response.status, lane: 'serviceRole', auth: input.auth, }) ?? ''}`, status: response.status, method, path, code: slim.code, }) } return { status: response.status, headers: {}, data } } if (input.body !== undefined && !headers['Content-Type'] && !headers['content-type']) { headers['Content-Type'] = 'application/json' } return await send({ url, method, path, body: input.body, lane: 'serviceRole', auth: input.auth, headers, }) } export function asLive( result: { status: number; headers: Record; data: unknown } | RequestPreview, ): T { if ('dryRun' in result && result.dryRun) { throw new Error('Expected a live response, received a dry-run preview.') } return result as T }