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<JsonRecord>
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, string | number | boolean | undefined>,
): 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<string, string>
body?: Json
path: string
lane: 'pat' | 'serviceRole'
auth: SupabaseAuthInput
}): Promise<{ status: number; headers: Record<string, string>; 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<string, string> = {}
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<string, string | number | boolean | undefined>
body?: Json
dryRun?: boolean
}): Promise<{ status: number; headers: Record<string, string>; 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<string, string | number | boolean | undefined>
headers?: Record<string, string>
body?: Json
rawBody?: BodyInit
dryRun?: boolean
}): Promise<{ status: number; headers: Record<string, string>; 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<string, string> = {
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<T>(
result: { status: number; headers: Record<string, string>; 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
}