mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-17 08:03:06 +00:00
250 lines
9.3 KiB
TypeScript
250 lines
9.3 KiB
TypeScript
/**
|
|
* Supabase Management API calls, proxied through Windmill's backend.
|
|
*
|
|
* The Management API sends no access-control-allow-origin, so the browser cannot call it
|
|
* directly -- every request below goes through /api/oauth/*, which forwards the user's OAuth
|
|
* access token.
|
|
*/
|
|
|
|
import { DEFAULT_SSLMODE } from '$lib/utils/postgresConnectionString'
|
|
import { oauthStore } from '$lib/stores'
|
|
import { get } from 'svelte/store'
|
|
|
|
export type SupabaseOrg = { id: string; slug?: string; name: string }
|
|
|
|
export type SupabaseProject = {
|
|
/** `id` is Supabase's deprecated spelling of `ref`; both are sent today. */
|
|
id?: string
|
|
ref?: string
|
|
name: string
|
|
region: string
|
|
status?: string
|
|
organization_slug?: string
|
|
organization_id?: string
|
|
database?: { host: string }
|
|
}
|
|
|
|
/** One Supavisor endpoint of a project. A project has one per mode and replica. */
|
|
export type SupabasePooler = {
|
|
database_type: 'PRIMARY' | 'READ_REPLICA'
|
|
pool_mode: 'transaction' | 'session'
|
|
db_user: string
|
|
db_host: string
|
|
db_port: number
|
|
db_name: string
|
|
}
|
|
|
|
export type SupabaseConnectionMode = 'session' | 'direct'
|
|
|
|
/** Supabase deprecated `id` in favour of `ref`, and still sends both. */
|
|
export function projectRef(project: SupabaseProject): string {
|
|
return project.ref ?? project.id ?? ''
|
|
}
|
|
|
|
export function projectOrg(project: SupabaseProject): string | undefined {
|
|
return project.organization_slug ?? project.organization_id
|
|
}
|
|
|
|
/** Region codes accepted by region_selection, with the names Supabase shows for them. */
|
|
export const SUPABASE_REGIONS: { code: string; label: string }[] = [
|
|
{ code: 'us-east-1', label: 'East US (N. Virginia)' },
|
|
{ code: 'us-west-1', label: 'West US (N. California)' },
|
|
{ code: 'eu-central-1', label: 'Central EU (Frankfurt)' },
|
|
{ code: 'eu-west-1', label: 'West EU (Ireland)' },
|
|
{ code: 'eu-west-3', label: 'West EU (Paris)' },
|
|
{ code: 'ap-southeast-1', label: 'Southeast Asia (Singapore)' },
|
|
{ code: 'ap-northeast-1', label: 'Northeast Asia (Tokyo)' }
|
|
]
|
|
|
|
export const DEFAULT_SUPABASE_REGION = 'eu-central-1'
|
|
|
|
function headers(token: string): HeadersInit {
|
|
return { 'Content-Type': 'application/json', 'X-Supabase-Token': token }
|
|
}
|
|
|
|
async function unwrap(res: Response, what: string): Promise<any> {
|
|
if (!res.ok) {
|
|
// Supabase access tokens are short-lived while `oauthStore` lasts as long as the tab, so
|
|
// a stale one otherwise leaves every caller "authorized" and unable to reach the button
|
|
// that would fix it. Forgetting it here is what puts Connect back on screen.
|
|
if (res.status === 401) oauthStore.set(undefined)
|
|
const body = await res.text()
|
|
throw new Error(`${what}: ${supabaseErrorMessage(body) || res.statusText}`)
|
|
}
|
|
return res.json()
|
|
}
|
|
|
|
/**
|
|
* Supabase answers with `{ message }` or `{ error }` and occasionally plain text.
|
|
* Surfacing the raw body puts a JSON blob in front of the user, so unwrap it to
|
|
* the sentence inside.
|
|
*/
|
|
export function supabaseErrorMessage(body: string): string {
|
|
try {
|
|
const parsed = JSON.parse(body)
|
|
return parsed?.message ?? parsed?.error ?? parsed?.msg ?? body
|
|
} catch {
|
|
return body
|
|
}
|
|
}
|
|
|
|
export async function listSupabaseOrgs(token: string): Promise<SupabaseOrg[]> {
|
|
const res = await fetch('/api/oauth/list_supabase_orgs', { headers: headers(token) })
|
|
return unwrap(res, 'Could not list your Supabase organizations')
|
|
}
|
|
|
|
export async function listSupabaseProjects(token: string): Promise<SupabaseProject[]> {
|
|
const res = await fetch('/api/oauth/list_supabase', { headers: headers(token) })
|
|
return unwrap(res, 'Could not list your Supabase projects')
|
|
}
|
|
|
|
/** Plan of one organization, which the list endpoint does not carry. */
|
|
export async function getSupabaseOrgPlan(token: string, slug: string): Promise<string | undefined> {
|
|
try {
|
|
const res = await fetch(`/api/oauth/get_supabase_org/${slug}`, {
|
|
headers: headers(token)
|
|
})
|
|
if (!res.ok) return undefined
|
|
return (await res.json())?.plan
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
/** organization_slug is what create takes; older payloads only carry an id. */
|
|
export function orgSlug(org: SupabaseOrg): string {
|
|
return org.slug ?? org.id
|
|
}
|
|
|
|
/**
|
|
* Supabase never lets a database password be read back, so the only way to know it is to be
|
|
* the one who set it: db_pass is an input to project creation.
|
|
*/
|
|
export function generateDbPassword(): string {
|
|
const charset = 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
|
const values = new Uint32Array(32)
|
|
crypto.getRandomValues(values)
|
|
return Array.from(values, (v) => charset[v % charset.length]).join('')
|
|
}
|
|
|
|
export async function createSupabaseProject(
|
|
token: string,
|
|
args: { name: string; organizationSlug: string; region: string; dbPass: string }
|
|
): Promise<SupabaseProject> {
|
|
const res = await fetch('/api/oauth/create_supabase_project', {
|
|
method: 'POST',
|
|
headers: headers(token),
|
|
body: JSON.stringify({
|
|
name: args.name,
|
|
organization_slug: args.organizationSlug,
|
|
db_pass: args.dbPass,
|
|
// region_selection is { type: 'specific' | 'smartGroup', code }. Neither the published
|
|
// docs nor the OpenAPI spec describe it correctly (they give `kind`/`region` and
|
|
// `primary`) -- this shape comes from the API's own validation errors, so do not
|
|
// "correct" it against the documentation.
|
|
region_selection: { type: 'specific', code: args.region }
|
|
})
|
|
})
|
|
return unwrap(res, 'Supabase refused to create the project')
|
|
}
|
|
|
|
/**
|
|
* Creation returns immediately with the project still coming up, so the pooler is not
|
|
* reachable yet. Poll until Supabase reports it healthy before trying to connect.
|
|
*/
|
|
export async function waitUntilSupabaseHealthy(
|
|
token: string,
|
|
projectId: string,
|
|
onStatus?: (status: string | undefined) => void,
|
|
attempts = 60
|
|
): Promise<SupabaseProject> {
|
|
for (let i = 0; i < attempts; i++) {
|
|
await new Promise((r) => setTimeout(r, 5000))
|
|
let list: SupabaseProject[]
|
|
try {
|
|
list = await listSupabaseProjects(token)
|
|
} catch (err) {
|
|
// A transient failure is worth another poll; an expired token is not -- retrying it
|
|
// burns five minutes and then reports a timeout, which names the wrong problem.
|
|
if (!get(oauthStore)?.access_token) throw err
|
|
continue
|
|
}
|
|
const project = list?.find?.((p) => projectRef(p) === projectId)
|
|
if (project?.status === 'ACTIVE_HEALTHY') return project
|
|
onStatus?.(project?.status)
|
|
}
|
|
throw new Error('Timed out waiting for the project to become reachable')
|
|
}
|
|
|
|
/**
|
|
* The session-mode Supavisor endpoint of the project's primary database.
|
|
*
|
|
* Which pooler a project sits behind is assigned by Supabase, not derived from its
|
|
* region: constructing `aws-0-<region>.pooler.supabase.com` is wrong for every project
|
|
* that landed on another one, and the resulting resource never connects.
|
|
*/
|
|
export async function getSupabasePooler(token: string, projectId: string): Promise<SupabasePooler> {
|
|
const res = await fetch(`/api/oauth/get_supabase_pooler/${projectId}`, {
|
|
headers: headers(token)
|
|
})
|
|
const configs: SupabasePooler[] = await unwrap(res, 'Could not read the connection details')
|
|
const primary = configs.filter((c) => c.database_type === 'PRIMARY')
|
|
const pooler = primary.find((c) => c.pool_mode === 'session') ?? primary[0] ?? configs[0]
|
|
if (!pooler) throw new Error('Supabase returned no connection details for this project')
|
|
return pooler
|
|
}
|
|
|
|
export type SupabaseConnection = {
|
|
mode: SupabaseConnectionMode
|
|
pooler?: SupabasePooler
|
|
/** Why session pooling was asked for and not used. Absent when nothing was given up. */
|
|
unavailable?: string
|
|
}
|
|
|
|
/**
|
|
* The endpoint a project should be reached through, degrading rather than failing. Reading the
|
|
* pooler config needs the `database_pooling_config_read` scope, which an instance's OAuth app
|
|
* may not have. A direct connection still works where the workers have IPv6, so fall back to
|
|
* it and say so.
|
|
*/
|
|
export async function resolveSupabaseConnection(
|
|
token: string,
|
|
project: SupabaseProject,
|
|
mode: SupabaseConnectionMode
|
|
): Promise<SupabaseConnection> {
|
|
if (mode !== 'session') return { mode }
|
|
try {
|
|
return { mode, pooler: await getSupabasePooler(token, projectRef(project)) }
|
|
} catch (err) {
|
|
return { mode: 'direct', unavailable: err instanceof Error ? err.message : String(err) }
|
|
}
|
|
}
|
|
|
|
/** The resource value for a project, given the endpoint it should connect through. */
|
|
export function supabaseResourceValue(
|
|
project: SupabaseProject,
|
|
passwordVarPath: string,
|
|
connection: { mode: SupabaseConnectionMode; pooler?: SupabasePooler }
|
|
) {
|
|
const direct = connection.mode === 'direct' || !connection.pooler
|
|
return {
|
|
host: direct
|
|
? (project.database?.host ?? `db.${projectRef(project)}.supabase.co`)
|
|
: connection.pooler!.db_host,
|
|
user: direct ? 'postgres' : connection.pooler!.db_user,
|
|
port: direct ? 5432 : connection.pooler!.db_port,
|
|
dbname: direct ? 'postgres' : connection.pooler!.db_name,
|
|
// Supabase terminates TLS on every endpoint it hands out, and this connection carries a
|
|
// generated password, so there is no reason to leave a plaintext fallback open.
|
|
sslmode: DEFAULT_SSLMODE,
|
|
password: `$var:${passwordVarPath}`,
|
|
// Resource forms fill in every unset property from the schema as soon as they render,
|
|
// so a postgresql resource saved without these comes up already modified -- and saves a
|
|
// draft -- the first time anyone opens it. Write them here so opening one is a no-op.
|
|
// (accept_invalid_certs renders conditionally and is not seeded, so it stays out.)
|
|
region: '',
|
|
root_certificate_pem: '',
|
|
use_iam_auth: false
|
|
}
|
|
}
|