@specific.dev/spectest 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/index.ts +6 -0
- package/src/components/k3s.ts +4 -16
- package/src/components/supabase.ts +1109 -0
- package/src/daemon.ts +270 -59
- package/src/index.ts +510 -31
- package/src/inspect.ts +1 -1
- package/src/recorder.ts +7 -5
|
@@ -0,0 +1,1109 @@
|
|
|
1
|
+
// A ready-to-use, self-hosted **Supabase** stack, expressed as a set of
|
|
2
|
+
// spectest services (one container each) wired together on spectest-net.
|
|
3
|
+
//
|
|
4
|
+
// This is the standard Supabase self-hosted Docker topology
|
|
5
|
+
// (github.com/supabase/supabase, `docker/docker-compose.yml`) reproduced with
|
|
6
|
+
// the *exact upstream images and config* — the Kong API gateway, GoTrue auth,
|
|
7
|
+
// PostgREST, storage-api (+imgproxy), Realtime, and a `supabase/postgres`
|
|
8
|
+
// database with the standard role/JWT bootstrap — just orchestrated as
|
|
9
|
+
// spectest services instead of via docker-compose. That means it rides
|
|
10
|
+
// spectest's parallel image prep, ready-check DAG, DNS, host image cache, and
|
|
11
|
+
// snapshot/fork for free, with no daemon or control-plane changes.
|
|
12
|
+
//
|
|
13
|
+
// Unlike the single-`ServiceDefinition` components (`postgres()`, `s3()`),
|
|
14
|
+
// Supabase is inherently a constellation of containers, so `supabase()` returns
|
|
15
|
+
// a **service group** (one services-map entry that `defineEnvironment` expands)
|
|
16
|
+
// plus the derived connection surface:
|
|
17
|
+
//
|
|
18
|
+
// ```ts
|
|
19
|
+
// import { defineEnvironment } from "@specific.dev/spectest";
|
|
20
|
+
// import { supabase } from "@specific.dev/spectest/components";
|
|
21
|
+
//
|
|
22
|
+
// const sb = supabase({ dbPassword: "postgres" });
|
|
23
|
+
//
|
|
24
|
+
// export const env = defineEnvironment({
|
|
25
|
+
// name: "my-app",
|
|
26
|
+
// services: {
|
|
27
|
+
// supabase: sb.group, // expands to supabase, supabase-db, supabase-auth, ...
|
|
28
|
+
// app: {
|
|
29
|
+
// image: { type: "dockerfile", content: "..." },
|
|
30
|
+
// env: { ...sb.appEnv }, // SUPABASE_URL / _ANON_KEY / _SERVICE_ROLE_KEY / DATABASE_URL
|
|
31
|
+
// dependsOn: [sb.ready], // wait for the gateway (and the stack behind it)
|
|
32
|
+
// },
|
|
33
|
+
// },
|
|
34
|
+
// });
|
|
35
|
+
//
|
|
36
|
+
// // in a test:
|
|
37
|
+
// await ctx.svc.supabase.sql`select * from auth.users`; // instrumented DB client
|
|
38
|
+
// await ctx.fetch(`${sb.url}/rest/v1/todos`, { headers: { apikey: sb.anonKey } });
|
|
39
|
+
// ```
|
|
40
|
+
//
|
|
41
|
+
// The gateway is the group's primary part, so it takes the group key itself:
|
|
42
|
+
// reachable at `http://supabase:8000` on spectest-net, helpers (the group's
|
|
43
|
+
// consolidated handle) at `ctx.svc.supabase`, and `dependsOn: ["supabase"]`
|
|
44
|
+
// waits for the whole stack (group expansion makes the primary the sink).
|
|
45
|
+
// Every other piece lands at `<name>-<part>` (`supabase-db`, `supabase-auth`,
|
|
46
|
+
// …); internal references (env + the Kong declarative config) are built from
|
|
47
|
+
// the group naming context, so two Supabase stacks (or a user service
|
|
48
|
+
// innocently named `db`) never collide. The group must be mounted at the key
|
|
49
|
+
// matching the `name` option (default `"supabase"`) since the derived surface
|
|
50
|
+
// (`sb.url`, `sb.appEnv`) embeds it — a mismatch errors at load.
|
|
51
|
+
|
|
52
|
+
import { createHmac } from "node:crypto";
|
|
53
|
+
import { existsSync } from "node:fs";
|
|
54
|
+
import { readdir, stat } from "node:fs/promises";
|
|
55
|
+
import { join } from "node:path";
|
|
56
|
+
|
|
57
|
+
import {
|
|
58
|
+
serviceGroup,
|
|
59
|
+
type ServiceConfig,
|
|
60
|
+
type ServiceDefinition,
|
|
61
|
+
type ServiceGroup,
|
|
62
|
+
type ServiceSetupContext,
|
|
63
|
+
type ServicesMap,
|
|
64
|
+
} from "../index.js";
|
|
65
|
+
import { SQL, type SqlClient } from "../sql.js";
|
|
66
|
+
|
|
67
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
68
|
+
// Pinned upstream image tags (github.com/supabase/supabase docker-compose.yml,
|
|
69
|
+
// current master). Bump together; each is pulled through the host Hub mirror on
|
|
70
|
+
// first cold boot and captured into the warm-template snapshot thereafter.
|
|
71
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
72
|
+
const IMAGES = {
|
|
73
|
+
db: "supabase/postgres:17.6.1.136",
|
|
74
|
+
kong: "kong/kong:3.9.1",
|
|
75
|
+
auth: "supabase/gotrue:v2.189.0",
|
|
76
|
+
rest: "postgrest/postgrest:v14.12",
|
|
77
|
+
realtime: "supabase/realtime:v2.102.3",
|
|
78
|
+
storage: "supabase/storage-api:v1.60.4",
|
|
79
|
+
imgproxy: "darthsim/imgproxy:v3.30.1",
|
|
80
|
+
meta: "supabase/postgres-meta:v0.96.6",
|
|
81
|
+
studio: "supabase/studio:2026.07.07-sha-a6a04f2",
|
|
82
|
+
} as const;
|
|
83
|
+
|
|
84
|
+
export interface SupabaseOptions {
|
|
85
|
+
/**
|
|
86
|
+
* Key of the API-gateway service and the prefix for every other piece.
|
|
87
|
+
* Default `"supabase"`. The gateway is reachable at `http://<name>:8000`
|
|
88
|
+
* and its helpers at `ctx.svc.<name>`; the rest are `<name>-db`,
|
|
89
|
+
* `<name>-auth`, `<name>-rest`, `<name>-storage`, `<name>-realtime`, ….
|
|
90
|
+
*/
|
|
91
|
+
name?: string;
|
|
92
|
+
/**
|
|
93
|
+
* Password for the Postgres roles the services authenticate with (and the
|
|
94
|
+
* `postgres` superuser the app / SQL helper connect as). Default
|
|
95
|
+
* `"postgres"`. Purely local to the hermetic VM — not a secret to guard.
|
|
96
|
+
*/
|
|
97
|
+
dbPassword?: string;
|
|
98
|
+
/**
|
|
99
|
+
* HS256 secret that signs and verifies all JWTs (user access tokens plus the
|
|
100
|
+
* derived `anonKey` / `serviceRoleKey` API keys). Must be ≥32 chars. Default
|
|
101
|
+
* is Supabase's well-known demo secret. Changing it re-derives the keys
|
|
102
|
+
* deterministically, so the warm-template cache stays stable.
|
|
103
|
+
*/
|
|
104
|
+
jwtSecret?: string;
|
|
105
|
+
/** Studio dashboard Basic-Auth username (only relevant with `studio`). Default `"supabase"`. */
|
|
106
|
+
dashboardUsername?: string;
|
|
107
|
+
/** Studio dashboard Basic-Auth password (only relevant with `studio`). */
|
|
108
|
+
dashboardPassword?: string;
|
|
109
|
+
|
|
110
|
+
/** Include GoTrue auth (`<name>-auth`). Default `true`. */
|
|
111
|
+
auth?: boolean;
|
|
112
|
+
/** Include storage-api + imgproxy (`<name>-storage`, `<name>-imgproxy`). Default `true`. */
|
|
113
|
+
storage?: boolean;
|
|
114
|
+
/** Include Realtime (`<name>-realtime`). Default `true`. */
|
|
115
|
+
realtime?: boolean;
|
|
116
|
+
/**
|
|
117
|
+
* Include the Studio dashboard (`<name>-studio`) and its postgres-meta
|
|
118
|
+
* backend (`<name>-meta`). Off by default — Studio is a heavy Next.js image
|
|
119
|
+
* and is only useful for interactive `ctx.browser()` exploration, not
|
|
120
|
+
* automated backend assertions. Turning it on implies `meta`.
|
|
121
|
+
*/
|
|
122
|
+
studio?: boolean;
|
|
123
|
+
/** Include postgres-meta (`<name>-meta`). Defaults to whatever `studio` is. */
|
|
124
|
+
meta?: boolean;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Also serve the gateway over **HTTPS** at `https://<hostname>` via the
|
|
128
|
+
* daemon's CA-trusted TLS reverse proxy (in addition to the plain
|
|
129
|
+
* `http://<name>:8000`). Set this only when the app under test hardcodes a
|
|
130
|
+
* specific Supabase URL it can't be told to override — otherwise leave it and
|
|
131
|
+
* point the app at `sb.url`.
|
|
132
|
+
*/
|
|
133
|
+
hostname?: string;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Auto-apply SQL migrations found in the project before any dependent service
|
|
137
|
+
* starts. `true` (default) applies every `*.sql` under `supabase/migrations/`
|
|
138
|
+
* (sorted, the Supabase convention), then `supabase/seed.sql` if present — a
|
|
139
|
+
* no-op when the directory is absent. Pass a string to point at a different
|
|
140
|
+
* directory (relative to the project root or absolute), or `false` to skip.
|
|
141
|
+
* Because `supabase/**` is project content, editing a migration correctly
|
|
142
|
+
* forces a cold rebuild and re-apply (unlike `spectest/tests/**`).
|
|
143
|
+
*/
|
|
144
|
+
migrations?: boolean | string;
|
|
145
|
+
/**
|
|
146
|
+
* Seed SQL applied after migrations. `true` (default) applies
|
|
147
|
+
* `supabase/seed.sql` if it exists; a string overrides the path; `false`
|
|
148
|
+
* skips it.
|
|
149
|
+
*/
|
|
150
|
+
seed?: boolean | string;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Helpers the gateway service exposes on `ctx.svc.<name>`. */
|
|
154
|
+
export interface SupabaseHelpers {
|
|
155
|
+
/**
|
|
156
|
+
* An instrumented {@link SqlClient} connected to the database as the
|
|
157
|
+
* `postgres` superuser — every query lands on the test event log and its rows
|
|
158
|
+
* come back provenance-wrapped, so `expect(...)` on a read links to the
|
|
159
|
+
* query. Point of direct DB verification alongside the REST/auth surface.
|
|
160
|
+
*/
|
|
161
|
+
sql: SqlClient;
|
|
162
|
+
/** The gateway base URL the app uses as `SUPABASE_URL` (e.g. `http://supabase:8000`). */
|
|
163
|
+
url: string;
|
|
164
|
+
/** Long-lived `anon` API key (HS256 JWT). Pass as the `apikey` header. */
|
|
165
|
+
anonKey: string;
|
|
166
|
+
/** Long-lived `service_role` API key (HS256 JWT). Bypasses RLS — server-side only. */
|
|
167
|
+
serviceRoleKey: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** What `supabase()` returns: the service group plus the derived
|
|
171
|
+
* connection surface for wiring the app under test. */
|
|
172
|
+
export interface SupabaseStack {
|
|
173
|
+
/** The Supabase stack as a service group. Mount it at the key matching
|
|
174
|
+
* the `name` option (default `"supabase"`):
|
|
175
|
+
* `services: { supabase: sb.group, ... }`. */
|
|
176
|
+
group: ServiceGroup<ServicesMap, SupabaseHelpers>;
|
|
177
|
+
/** Gateway base URL — `http://<name>:8000`, or `https://<hostname>` when set. */
|
|
178
|
+
url: string;
|
|
179
|
+
/** `anon` API key (HS256 JWT). */
|
|
180
|
+
anonKey: string;
|
|
181
|
+
/** `service_role` API key (HS256 JWT). */
|
|
182
|
+
serviceRoleKey: string;
|
|
183
|
+
/** Direct Postgres URL for the app (`postgres` superuser): `postgresql://postgres:<pw>@<name>-db:5432/postgres`. */
|
|
184
|
+
dbUrl: string;
|
|
185
|
+
/** The gateway service key — use in a dependent service's `dependsOn` to wait
|
|
186
|
+
* for the whole stack (the gateway waits on the services behind it). */
|
|
187
|
+
ready: string;
|
|
188
|
+
/** Ready-to-spread env for the app under test: `SUPABASE_URL`,
|
|
189
|
+
* `SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `DATABASE_URL`. */
|
|
190
|
+
appEnv: Record<string, string>;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Supabase's well-known demo secret (≥32 chars). Local-only; override via opts.
|
|
194
|
+
const DEFAULT_JWT_SECRET =
|
|
195
|
+
"your-super-secret-jwt-token-with-at-least-32-characters-long";
|
|
196
|
+
|
|
197
|
+
// Fixed issued-at / expiry for the derived API keys. Constant (not `Date.now`)
|
|
198
|
+
// so the keys are a pure function of the secret and never perturb the
|
|
199
|
+
// warm-template hash. `exp` is 2027-01-01, matching Supabase's demo keys.
|
|
200
|
+
const KEY_IAT = 1641769200;
|
|
201
|
+
const KEY_EXP = 1799535600;
|
|
202
|
+
|
|
203
|
+
function base64url(input: Buffer | string): string {
|
|
204
|
+
return Buffer.from(input)
|
|
205
|
+
.toString("base64")
|
|
206
|
+
.replace(/\+/g, "-")
|
|
207
|
+
.replace(/\//g, "_")
|
|
208
|
+
.replace(/=+$/, "");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Sign a compact HS256 JWT. Any token with the right `role` claim signed by
|
|
212
|
+
* `JWT_SECRET` is a valid Supabase API key — we don't need to reproduce the
|
|
213
|
+
* exact demo string, just a validly-signed one. */
|
|
214
|
+
function signHs256(payload: Record<string, unknown>, secret: string): string {
|
|
215
|
+
const header = base64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
|
216
|
+
const body = base64url(JSON.stringify(payload));
|
|
217
|
+
const data = `${header}.${body}`;
|
|
218
|
+
const sig = base64url(createHmac("sha256", secret).update(data).digest());
|
|
219
|
+
return `${data}.${sig}`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function apiKey(role: "anon" | "service_role", secret: string): string {
|
|
223
|
+
return signHs256({ role, iss: "supabase-demo", iat: KEY_IAT, exp: KEY_EXP }, secret);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
227
|
+
// Postgres bootstrap SQL. The `supabase/postgres` image already creates every
|
|
228
|
+
// Supabase role/schema in its baked init-scripts; we only need to (a) set the
|
|
229
|
+
// network-facing roles' passwords to ours, (b) plant the JWT secret as a DB GUC
|
|
230
|
+
// (used by RLS `auth.jwt()`), and (c) create the `_realtime` schema.
|
|
231
|
+
//
|
|
232
|
+
// We apply this in the db service's `setup` hook (via `psql`) rather than as
|
|
233
|
+
// mounted init-scripts, deliberately: the image's `migrate.sh` runs mounted
|
|
234
|
+
// files under `ON_ERROR_STOP`, so a single unexpected role (e.g.
|
|
235
|
+
// `supabase_functions_admin`, which only exists when the DB-webhooks script is
|
|
236
|
+
// present) aborts the whole init and the container exits. `setup` runs after
|
|
237
|
+
// the DB is ready and before any dependent service starts, so ordering is still
|
|
238
|
+
// guaranteed, and we touch only roles the base image is known to create.
|
|
239
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
/** Quote a Postgres string literal (single-quote, double any embedded quote). */
|
|
242
|
+
function pgLiteral(s: string): string {
|
|
243
|
+
return `'${s.replace(/'/g, "''")}'`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function buildBootstrapSql(
|
|
247
|
+
dbPassword: string,
|
|
248
|
+
jwtSecret: string,
|
|
249
|
+
withRealtime: boolean,
|
|
250
|
+
): string {
|
|
251
|
+
const pw = pgLiteral(dbPassword);
|
|
252
|
+
const lines = [
|
|
253
|
+
// Network-facing roles the services authenticate as. supabase_admin and
|
|
254
|
+
// postgres already have their password set to POSTGRES_PASSWORD by the
|
|
255
|
+
// image's own bootstrap, so we leave them alone.
|
|
256
|
+
`ALTER USER authenticator WITH PASSWORD ${pw};`,
|
|
257
|
+
`ALTER USER supabase_auth_admin WITH PASSWORD ${pw};`,
|
|
258
|
+
`ALTER USER supabase_storage_admin WITH PASSWORD ${pw};`,
|
|
259
|
+
// JWT secret GUC for RLS (`current_setting('app.settings.jwt_secret')`).
|
|
260
|
+
`ALTER DATABASE postgres SET "app.settings.jwt_secret" TO ${pgLiteral(jwtSecret)};`,
|
|
261
|
+
`ALTER DATABASE postgres SET "app.settings.jwt_exp" TO '3600';`,
|
|
262
|
+
];
|
|
263
|
+
if (withRealtime) {
|
|
264
|
+
lines.push(
|
|
265
|
+
"CREATE SCHEMA IF NOT EXISTS _realtime;",
|
|
266
|
+
"ALTER SCHEMA _realtime OWNER TO supabase_admin;",
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
return lines.join("\n") + "\n";
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
273
|
+
// Kong declarative gateway config (docker/volumes/api/kong.yml). Embedded
|
|
274
|
+
// verbatim with `$VAR` placeholders; substituted below the same way
|
|
275
|
+
// kong-entrypoint.sh does — including the legacy (opaque-keys-disabled) Lua
|
|
276
|
+
// expressions that just pass the `apikey` header/query param straight through.
|
|
277
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
// The two Lua router expressions kong-entrypoint.sh emits when the opaque
|
|
280
|
+
// `sb_`-style keys are NOT configured (our case): pass the incoming apikey (a
|
|
281
|
+
// legacy HS256 JWT) through unchanged, preferring an existing non-`sb_`
|
|
282
|
+
// Authorization header.
|
|
283
|
+
const LUA_AUTH_EXPR =
|
|
284
|
+
"$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or headers.apikey)";
|
|
285
|
+
const LUA_RT_WS_EXPR = "$(query_params.apikey)";
|
|
286
|
+
|
|
287
|
+
const KONG_YML_RAW = String.raw`_format_version: '2.1'
|
|
288
|
+
_transform: true
|
|
289
|
+
|
|
290
|
+
consumers:
|
|
291
|
+
- username: DASHBOARD
|
|
292
|
+
- username: anon
|
|
293
|
+
keyauth_credentials:
|
|
294
|
+
- key: $SUPABASE_ANON_KEY
|
|
295
|
+
- key: $SUPABASE_PUBLISHABLE_KEY
|
|
296
|
+
- username: service_role
|
|
297
|
+
keyauth_credentials:
|
|
298
|
+
- key: $SUPABASE_SERVICE_KEY
|
|
299
|
+
- key: $SUPABASE_SECRET_KEY
|
|
300
|
+
|
|
301
|
+
acls:
|
|
302
|
+
- consumer: anon
|
|
303
|
+
group: anon
|
|
304
|
+
- consumer: service_role
|
|
305
|
+
group: admin
|
|
306
|
+
|
|
307
|
+
basicauth_credentials:
|
|
308
|
+
- consumer: DASHBOARD
|
|
309
|
+
username: '$DASHBOARD_USERNAME'
|
|
310
|
+
password: '$DASHBOARD_PASSWORD'
|
|
311
|
+
|
|
312
|
+
services:
|
|
313
|
+
- name: auth-v1-open
|
|
314
|
+
url: http://auth:9999/verify
|
|
315
|
+
routes:
|
|
316
|
+
- name: auth-v1-open
|
|
317
|
+
strip_path: true
|
|
318
|
+
paths:
|
|
319
|
+
- /auth/v1/verify
|
|
320
|
+
plugins:
|
|
321
|
+
- name: cors
|
|
322
|
+
- name: auth-v1-open-callback
|
|
323
|
+
url: http://auth:9999/callback
|
|
324
|
+
routes:
|
|
325
|
+
- name: auth-v1-open-callback
|
|
326
|
+
strip_path: true
|
|
327
|
+
paths:
|
|
328
|
+
- /auth/v1/callback
|
|
329
|
+
plugins:
|
|
330
|
+
- name: cors
|
|
331
|
+
- name: auth-v1-open-authorize
|
|
332
|
+
url: http://auth:9999/authorize
|
|
333
|
+
routes:
|
|
334
|
+
- name: auth-v1-open-authorize
|
|
335
|
+
strip_path: true
|
|
336
|
+
paths:
|
|
337
|
+
- /auth/v1/authorize
|
|
338
|
+
plugins:
|
|
339
|
+
- name: cors
|
|
340
|
+
- name: auth-v1-open-jwks
|
|
341
|
+
url: http://auth:9999/.well-known/jwks.json
|
|
342
|
+
routes:
|
|
343
|
+
- name: auth-v1-open-jwks
|
|
344
|
+
strip_path: true
|
|
345
|
+
paths:
|
|
346
|
+
- /auth/v1/.well-known/jwks.json
|
|
347
|
+
plugins:
|
|
348
|
+
- name: cors
|
|
349
|
+
- name: auth-v1-open-sso-acs
|
|
350
|
+
url: "http://auth:9999/sso/saml/acs"
|
|
351
|
+
routes:
|
|
352
|
+
- name: auth-v1-open-sso-acs
|
|
353
|
+
strip_path: true
|
|
354
|
+
paths:
|
|
355
|
+
- /auth/v1/sso/saml/acs
|
|
356
|
+
plugins:
|
|
357
|
+
- name: cors
|
|
358
|
+
- name: auth-v1-open-sso-metadata
|
|
359
|
+
url: "http://auth:9999/sso/saml/metadata"
|
|
360
|
+
routes:
|
|
361
|
+
- name: auth-v1-open-sso-metadata
|
|
362
|
+
strip_path: true
|
|
363
|
+
paths:
|
|
364
|
+
- /auth/v1/sso/saml/metadata
|
|
365
|
+
plugins:
|
|
366
|
+
- name: cors
|
|
367
|
+
- name: auth-v1
|
|
368
|
+
url: http://auth:9999/
|
|
369
|
+
routes:
|
|
370
|
+
- name: auth-v1-all
|
|
371
|
+
strip_path: true
|
|
372
|
+
paths:
|
|
373
|
+
- /auth/v1/
|
|
374
|
+
plugins:
|
|
375
|
+
- name: cors
|
|
376
|
+
- name: key-auth
|
|
377
|
+
config:
|
|
378
|
+
hide_credentials: false
|
|
379
|
+
- name: request-transformer
|
|
380
|
+
config:
|
|
381
|
+
add:
|
|
382
|
+
headers:
|
|
383
|
+
- "Authorization: $LUA_AUTH_EXPR"
|
|
384
|
+
replace:
|
|
385
|
+
headers:
|
|
386
|
+
- "Authorization: $LUA_AUTH_EXPR"
|
|
387
|
+
- name: acl
|
|
388
|
+
config:
|
|
389
|
+
hide_groups_header: true
|
|
390
|
+
allow:
|
|
391
|
+
- admin
|
|
392
|
+
- anon
|
|
393
|
+
- name: rest-v1-openapi
|
|
394
|
+
url: http://rest:3000/
|
|
395
|
+
routes:
|
|
396
|
+
- name: rest-v1-openapi-root
|
|
397
|
+
strip_path: true
|
|
398
|
+
expression: 'http.path == "/rest/v1/"'
|
|
399
|
+
plugins:
|
|
400
|
+
- name: cors
|
|
401
|
+
- name: key-auth
|
|
402
|
+
config:
|
|
403
|
+
hide_credentials: false
|
|
404
|
+
- name: request-transformer
|
|
405
|
+
config:
|
|
406
|
+
add:
|
|
407
|
+
headers:
|
|
408
|
+
- "Authorization: $LUA_AUTH_EXPR"
|
|
409
|
+
replace:
|
|
410
|
+
headers:
|
|
411
|
+
- "Authorization: $LUA_AUTH_EXPR"
|
|
412
|
+
- name: acl
|
|
413
|
+
config:
|
|
414
|
+
hide_groups_header: true
|
|
415
|
+
allow:
|
|
416
|
+
- admin
|
|
417
|
+
- name: rest-v1
|
|
418
|
+
url: http://rest:3000/
|
|
419
|
+
routes:
|
|
420
|
+
- name: rest-v1-all
|
|
421
|
+
strip_path: true
|
|
422
|
+
paths:
|
|
423
|
+
- /rest/v1/
|
|
424
|
+
plugins:
|
|
425
|
+
- name: cors
|
|
426
|
+
- name: key-auth
|
|
427
|
+
config:
|
|
428
|
+
hide_credentials: false
|
|
429
|
+
- name: request-transformer
|
|
430
|
+
config:
|
|
431
|
+
add:
|
|
432
|
+
headers:
|
|
433
|
+
- "Authorization: $LUA_AUTH_EXPR"
|
|
434
|
+
replace:
|
|
435
|
+
headers:
|
|
436
|
+
- "Authorization: $LUA_AUTH_EXPR"
|
|
437
|
+
- name: acl
|
|
438
|
+
config:
|
|
439
|
+
hide_groups_header: true
|
|
440
|
+
allow:
|
|
441
|
+
- admin
|
|
442
|
+
- anon
|
|
443
|
+
- name: graphql-v1
|
|
444
|
+
url: http://rest:3000/rpc/graphql
|
|
445
|
+
routes:
|
|
446
|
+
- name: graphql-v1-all
|
|
447
|
+
strip_path: true
|
|
448
|
+
paths:
|
|
449
|
+
- /graphql/v1
|
|
450
|
+
plugins:
|
|
451
|
+
- name: cors
|
|
452
|
+
- name: key-auth
|
|
453
|
+
config:
|
|
454
|
+
hide_credentials: false
|
|
455
|
+
- name: request-transformer
|
|
456
|
+
config:
|
|
457
|
+
add:
|
|
458
|
+
headers:
|
|
459
|
+
- "Content-Profile: graphql_public"
|
|
460
|
+
- "Authorization: $LUA_AUTH_EXPR"
|
|
461
|
+
replace:
|
|
462
|
+
headers:
|
|
463
|
+
- "Authorization: $LUA_AUTH_EXPR"
|
|
464
|
+
- name: acl
|
|
465
|
+
config:
|
|
466
|
+
hide_groups_header: true
|
|
467
|
+
allow:
|
|
468
|
+
- admin
|
|
469
|
+
- anon
|
|
470
|
+
- name: realtime-v1-ws
|
|
471
|
+
url: http://realtime-dev.supabase-realtime:4000/socket
|
|
472
|
+
protocol: ws
|
|
473
|
+
routes:
|
|
474
|
+
- name: realtime-v1-ws
|
|
475
|
+
strip_path: true
|
|
476
|
+
paths:
|
|
477
|
+
- /realtime/v1/
|
|
478
|
+
plugins:
|
|
479
|
+
- name: cors
|
|
480
|
+
- name: key-auth
|
|
481
|
+
config:
|
|
482
|
+
hide_credentials: false
|
|
483
|
+
- name: request-transformer
|
|
484
|
+
config:
|
|
485
|
+
add:
|
|
486
|
+
headers:
|
|
487
|
+
- "x-api-key:$LUA_RT_WS_EXPR"
|
|
488
|
+
replace:
|
|
489
|
+
querystring:
|
|
490
|
+
- "apikey:$LUA_RT_WS_EXPR"
|
|
491
|
+
- name: acl
|
|
492
|
+
config:
|
|
493
|
+
hide_groups_header: true
|
|
494
|
+
allow:
|
|
495
|
+
- admin
|
|
496
|
+
- anon
|
|
497
|
+
- name: realtime-v1-rest-openapi
|
|
498
|
+
url: http://realtime-dev.supabase-realtime:4000/api/openapi
|
|
499
|
+
protocol: http
|
|
500
|
+
routes:
|
|
501
|
+
- name: realtime-v1-rest-openapi
|
|
502
|
+
strip_path: true
|
|
503
|
+
paths:
|
|
504
|
+
- /realtime/v1/api/openapi
|
|
505
|
+
plugins:
|
|
506
|
+
- name: request-termination
|
|
507
|
+
config:
|
|
508
|
+
status_code: 403
|
|
509
|
+
message: "Access is forbidden."
|
|
510
|
+
- name: realtime-v1-rest-tenants
|
|
511
|
+
url: http://realtime-dev.supabase-realtime:4000/api/tenants
|
|
512
|
+
protocol: http
|
|
513
|
+
routes:
|
|
514
|
+
- name: realtime-v1-rest-tenants
|
|
515
|
+
strip_path: true
|
|
516
|
+
paths:
|
|
517
|
+
- /realtime/v1/api/tenants
|
|
518
|
+
plugins:
|
|
519
|
+
- name: request-termination
|
|
520
|
+
config:
|
|
521
|
+
status_code: 403
|
|
522
|
+
message: "Access is forbidden."
|
|
523
|
+
- name: realtime-v1-rest
|
|
524
|
+
url: http://realtime-dev.supabase-realtime:4000/api
|
|
525
|
+
protocol: http
|
|
526
|
+
routes:
|
|
527
|
+
- name: realtime-v1-rest
|
|
528
|
+
strip_path: true
|
|
529
|
+
paths:
|
|
530
|
+
- /realtime/v1/api
|
|
531
|
+
plugins:
|
|
532
|
+
- name: cors
|
|
533
|
+
- name: key-auth
|
|
534
|
+
config:
|
|
535
|
+
hide_credentials: false
|
|
536
|
+
- name: request-transformer
|
|
537
|
+
config:
|
|
538
|
+
add:
|
|
539
|
+
headers:
|
|
540
|
+
- "Authorization: $LUA_AUTH_EXPR"
|
|
541
|
+
replace:
|
|
542
|
+
headers:
|
|
543
|
+
- "Authorization: $LUA_AUTH_EXPR"
|
|
544
|
+
- name: acl
|
|
545
|
+
config:
|
|
546
|
+
hide_groups_header: true
|
|
547
|
+
allow:
|
|
548
|
+
- admin
|
|
549
|
+
- anon
|
|
550
|
+
- name: storage-v1
|
|
551
|
+
url: http://storage:5000/
|
|
552
|
+
routes:
|
|
553
|
+
- name: storage-v1-all
|
|
554
|
+
strip_path: true
|
|
555
|
+
paths:
|
|
556
|
+
- /storage/v1/
|
|
557
|
+
plugins:
|
|
558
|
+
- name: cors
|
|
559
|
+
- name: request-transformer
|
|
560
|
+
config:
|
|
561
|
+
add:
|
|
562
|
+
headers:
|
|
563
|
+
- "Authorization: $LUA_AUTH_EXPR"
|
|
564
|
+
replace:
|
|
565
|
+
headers:
|
|
566
|
+
- "Authorization: $LUA_AUTH_EXPR"
|
|
567
|
+
- name: post-function
|
|
568
|
+
config:
|
|
569
|
+
access:
|
|
570
|
+
- |
|
|
571
|
+
local auth = kong.request.get_header("authorization")
|
|
572
|
+
if auth == nil or auth == "" or auth:find("^%s*$") then
|
|
573
|
+
kong.service.request.clear_header("authorization")
|
|
574
|
+
end
|
|
575
|
+
- name: functions-v1
|
|
576
|
+
url: http://functions:9000/
|
|
577
|
+
read_timeout: 150000
|
|
578
|
+
routes:
|
|
579
|
+
- name: functions-v1-all
|
|
580
|
+
strip_path: true
|
|
581
|
+
paths:
|
|
582
|
+
- /functions/v1/
|
|
583
|
+
plugins:
|
|
584
|
+
- name: cors
|
|
585
|
+
- name: well-known-oauth
|
|
586
|
+
url: http://auth:9999/.well-known/oauth-authorization-server
|
|
587
|
+
routes:
|
|
588
|
+
- name: well-known-oauth
|
|
589
|
+
strip_path: true
|
|
590
|
+
paths:
|
|
591
|
+
- /.well-known/oauth-authorization-server
|
|
592
|
+
plugins:
|
|
593
|
+
- name: cors
|
|
594
|
+
- name: meta
|
|
595
|
+
url: http://meta:8080/
|
|
596
|
+
routes:
|
|
597
|
+
- name: meta-all
|
|
598
|
+
strip_path: true
|
|
599
|
+
paths:
|
|
600
|
+
- /pg/
|
|
601
|
+
plugins:
|
|
602
|
+
- name: key-auth
|
|
603
|
+
config:
|
|
604
|
+
hide_credentials: false
|
|
605
|
+
- name: acl
|
|
606
|
+
config:
|
|
607
|
+
hide_groups_header: true
|
|
608
|
+
allow:
|
|
609
|
+
- admin
|
|
610
|
+
- name: dashboard
|
|
611
|
+
url: http://studio:3000/
|
|
612
|
+
routes:
|
|
613
|
+
- name: dashboard-all
|
|
614
|
+
strip_path: true
|
|
615
|
+
paths:
|
|
616
|
+
- /
|
|
617
|
+
plugins:
|
|
618
|
+
- name: cors
|
|
619
|
+
- name: basic-auth
|
|
620
|
+
config:
|
|
621
|
+
hide_credentials: true
|
|
622
|
+
`;
|
|
623
|
+
|
|
624
|
+
/** Produce the final Kong declarative config: rewrite upstream bare hostnames
|
|
625
|
+
* to the group's final service DNS names, substitute the API keys / dashboard
|
|
626
|
+
* credentials / Lua expressions, and drop the empty opaque-key lines. */
|
|
627
|
+
function buildKongYaml(
|
|
628
|
+
key: (part: string) => string,
|
|
629
|
+
vals: {
|
|
630
|
+
anonKey: string;
|
|
631
|
+
serviceRoleKey: string;
|
|
632
|
+
dashboardUsername: string;
|
|
633
|
+
dashboardPassword: string;
|
|
634
|
+
},
|
|
635
|
+
): string {
|
|
636
|
+
let y = KONG_YML_RAW;
|
|
637
|
+
// Rewrite upstream bare service hosts → the group's final DNS names.
|
|
638
|
+
y = y
|
|
639
|
+
.replaceAll("http://auth:9999", `http://${key("auth")}:9999`)
|
|
640
|
+
.replaceAll("http://rest:3000", `http://${key("rest")}:3000`)
|
|
641
|
+
.replaceAll("http://storage:5000", `http://${key("storage")}:5000`)
|
|
642
|
+
.replaceAll("http://meta:8080", `http://${key("meta")}:8080`)
|
|
643
|
+
.replaceAll("http://studio:3000", `http://${key("studio")}:3000`)
|
|
644
|
+
.replaceAll("http://functions:9000", `http://${key("functions")}:9000`)
|
|
645
|
+
.replaceAll("realtime-dev.supabase-realtime", `realtime-dev.${key("realtime")}`);
|
|
646
|
+
// Lua expressions first (they contain no `$SUPABASE_*` tokens).
|
|
647
|
+
y = y.replaceAll("$LUA_AUTH_EXPR", LUA_AUTH_EXPR).replaceAll(
|
|
648
|
+
"$LUA_RT_WS_EXPR",
|
|
649
|
+
LUA_RT_WS_EXPR,
|
|
650
|
+
);
|
|
651
|
+
// API keys + dashboard credentials.
|
|
652
|
+
y = y
|
|
653
|
+
.replaceAll("$SUPABASE_ANON_KEY", vals.anonKey)
|
|
654
|
+
.replaceAll("$SUPABASE_SERVICE_KEY", vals.serviceRoleKey)
|
|
655
|
+
.replaceAll("$DASHBOARD_USERNAME", vals.dashboardUsername)
|
|
656
|
+
.replaceAll("$DASHBOARD_PASSWORD", vals.dashboardPassword);
|
|
657
|
+
// Drop the unconfigured opaque-key credential lines (kong-entrypoint.sh does
|
|
658
|
+
// the same with a `sed` after substitution).
|
|
659
|
+
y = y
|
|
660
|
+
.split("\n")
|
|
661
|
+
.filter(
|
|
662
|
+
(line) =>
|
|
663
|
+
!/^\s*- key:\s*\$SUPABASE_PUBLISHABLE_KEY\s*$/.test(line) &&
|
|
664
|
+
!/^\s*- key:\s*\$SUPABASE_SECRET_KEY\s*$/.test(line),
|
|
665
|
+
)
|
|
666
|
+
.join("\n");
|
|
667
|
+
return y;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
671
|
+
// Migration application (runs in the db service's `setup`, after the DB is
|
|
672
|
+
// ready and before any dependent service starts). Uses the setup context's
|
|
673
|
+
// `exec` (docker exec with piped stdin) and `projectRoot` — no hand-rolled
|
|
674
|
+
// child_process, no hard-coded workspace path.
|
|
675
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
676
|
+
|
|
677
|
+
/** Pipe one SQL file's contents into `psql` inside the db container. Uses
|
|
678
|
+
* `psql -f -` (stdin) so multi-statement files and non-transactional DDL
|
|
679
|
+
* (e.g. `CREATE INDEX CONCURRENTLY`) behave exactly as `supabase db reset`
|
|
680
|
+
* applies them — not wrapped in a single transaction. `ON_ERROR_STOP=1` fails
|
|
681
|
+
* fast on the first bad statement. */
|
|
682
|
+
async function applySqlFile(
|
|
683
|
+
ctx: ServiceSetupContext,
|
|
684
|
+
sql: string,
|
|
685
|
+
label: string,
|
|
686
|
+
user: string,
|
|
687
|
+
): Promise<void> {
|
|
688
|
+
const res = await ctx.exec(
|
|
689
|
+
ctx.name,
|
|
690
|
+
["psql", "-v", "ON_ERROR_STOP=1", "-U", user, "-d", "postgres", "-f", "-"],
|
|
691
|
+
{ stdin: sql, timeoutMs: 300_000 },
|
|
692
|
+
);
|
|
693
|
+
if (res.exitCode !== 0) {
|
|
694
|
+
throw new Error(
|
|
695
|
+
`supabase: applying ${label} failed (psql rc=${res.exitCode}):\n${
|
|
696
|
+
res.stderr.trim() || res.stdout.trim()
|
|
697
|
+
}`,
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** Resolve, sort, and apply the project's Supabase migrations + seed against
|
|
703
|
+
* the running db container as the `postgres` role (matching `supabase db
|
|
704
|
+
* reset` — tables end up owned by postgres). No-op when nothing is
|
|
705
|
+
* configured/present. */
|
|
706
|
+
async function applyMigrations(
|
|
707
|
+
ctx: ServiceSetupContext,
|
|
708
|
+
cfg: { migrations: boolean | string; seed: boolean | string },
|
|
709
|
+
): Promise<void> {
|
|
710
|
+
// Migrations directory.
|
|
711
|
+
if (cfg.migrations !== false) {
|
|
712
|
+
const dir =
|
|
713
|
+
typeof cfg.migrations === "string"
|
|
714
|
+
? cfg.migrations.startsWith("/")
|
|
715
|
+
? cfg.migrations
|
|
716
|
+
: join(ctx.projectRoot, cfg.migrations)
|
|
717
|
+
: join(ctx.projectRoot, "supabase", "migrations");
|
|
718
|
+
if (existsSync(dir) && (await stat(dir)).isDirectory()) {
|
|
719
|
+
const files = (await readdir(dir))
|
|
720
|
+
.filter((f) => f.endsWith(".sql"))
|
|
721
|
+
.sort();
|
|
722
|
+
for (const f of files) {
|
|
723
|
+
const sql = await ctx.readProjectFile(join(dir, f));
|
|
724
|
+
await applySqlFile(ctx, sql, `migration ${f}`, "postgres");
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
// Seed file.
|
|
729
|
+
if (cfg.seed !== false) {
|
|
730
|
+
const seedPath =
|
|
731
|
+
typeof cfg.seed === "string"
|
|
732
|
+
? cfg.seed.startsWith("/")
|
|
733
|
+
? cfg.seed
|
|
734
|
+
: join(ctx.projectRoot, cfg.seed)
|
|
735
|
+
: join(ctx.projectRoot, "supabase", "seed.sql");
|
|
736
|
+
if (existsSync(seedPath)) {
|
|
737
|
+
const sql = await ctx.readProjectFile(seedPath);
|
|
738
|
+
await applySqlFile(ctx, sql, "seed.sql", "postgres");
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* A ready-to-use self-hosted Supabase stack. Mount `.group` at the key
|
|
745
|
+
* matching the `name` option (default `"supabase"`) and wire the app with
|
|
746
|
+
* `.appEnv` / `.ready`:
|
|
747
|
+
*
|
|
748
|
+
* ```ts
|
|
749
|
+
* const sb = supabase({ dbPassword: "postgres" });
|
|
750
|
+
* services: { supabase: sb.group, app: { image, env: sb.appEnv, dependsOn: [sb.ready] } }
|
|
751
|
+
* ```
|
|
752
|
+
*
|
|
753
|
+
* See the file header for the full picture. Tests get an instrumented DB client
|
|
754
|
+
* at `ctx.svc.<name>.sql` plus `sb.url` / `sb.anonKey` / `sb.serviceRoleKey`
|
|
755
|
+
* for hitting the REST/auth/storage APIs through the gateway.
|
|
756
|
+
*/
|
|
757
|
+
export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
758
|
+
const name = opts.name ?? "supabase";
|
|
759
|
+
const dbPassword = opts.dbPassword ?? "postgres";
|
|
760
|
+
const jwtSecret = opts.jwtSecret ?? DEFAULT_JWT_SECRET;
|
|
761
|
+
if (jwtSecret.length < 32) {
|
|
762
|
+
throw new Error(
|
|
763
|
+
`supabase(): jwtSecret must be at least 32 characters (got ${jwtSecret.length})`,
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
const dashboardUsername = opts.dashboardUsername ?? "supabase";
|
|
767
|
+
const dashboardPassword =
|
|
768
|
+
opts.dashboardPassword ?? "this_password_is_insecure_and_should_be_updated";
|
|
769
|
+
|
|
770
|
+
const withAuth = opts.auth ?? true;
|
|
771
|
+
const withStorage = opts.storage ?? true;
|
|
772
|
+
const withRealtime = opts.realtime ?? true;
|
|
773
|
+
const withStudio = opts.studio ?? false;
|
|
774
|
+
const withMeta = opts.meta ?? withStudio;
|
|
775
|
+
|
|
776
|
+
const anonKey = apiKey("anon", jwtSecret);
|
|
777
|
+
const serviceRoleKey = apiKey("service_role", jwtSecret);
|
|
778
|
+
|
|
779
|
+
// Derived surface, embedded in `appEnv` / `url` before the group expands.
|
|
780
|
+
// `expectKey: name` on the group guarantees these names and the expanded
|
|
781
|
+
// service keys agree (`g.key("db")` at expansion === `${name}-db` here).
|
|
782
|
+
const dbKey = `${name}-db`;
|
|
783
|
+
const url = opts.hostname ? `https://${opts.hostname}` : `http://${name}:8000`;
|
|
784
|
+
// App-facing URL: the conventional `postgres` role (owns migrated tables).
|
|
785
|
+
const dbUrl = `postgresql://postgres:${dbPassword}@${dbKey}:5432/postgres`;
|
|
786
|
+
// The test-side `ctx.svc.<name>.sql` helper connects as the superuser
|
|
787
|
+
// `supabase_admin` so it can inspect everything — public tables plus the
|
|
788
|
+
// `auth`/`storage` schemas — regardless of ownership.
|
|
789
|
+
const adminDbUrl = `postgresql://supabase_admin:${dbPassword}@${dbKey}:5432/postgres`;
|
|
790
|
+
|
|
791
|
+
const group = serviceGroup({
|
|
792
|
+
// Kong: the group key itself is the gateway (`http://<name>:8000`), and
|
|
793
|
+
// as the primary it's the group's sink — `dependsOn: [sb.ready]` from
|
|
794
|
+
// outside waits for the whole stack.
|
|
795
|
+
primary: "gateway",
|
|
796
|
+
expectKey: name,
|
|
797
|
+
services: (g) => {
|
|
798
|
+
const db = g.key("db");
|
|
799
|
+
// Realtime derives its tenant id from the host subdomain, so it must
|
|
800
|
+
// be reachable at `realtime-dev.<realtime key>` — the host Kong
|
|
801
|
+
// routes to.
|
|
802
|
+
const realtimeHost = `realtime-dev.${g.key("realtime")}`;
|
|
803
|
+
const parts: Record<string, ServiceConfig> = {};
|
|
804
|
+
|
|
805
|
+
// ── db ──────────────────────────────────────────────────────────
|
|
806
|
+
const dbDef: ServiceDefinition = {
|
|
807
|
+
image: { type: "registry", reference: IMAGES.db },
|
|
808
|
+
// `args` (not `command`): keep the image's docker-entrypoint.sh so it
|
|
809
|
+
// runs initdb + its baked init-scripts before starting.
|
|
810
|
+
args: [
|
|
811
|
+
"postgres",
|
|
812
|
+
"-c",
|
|
813
|
+
"config_file=/etc/postgresql/postgresql.conf",
|
|
814
|
+
"-c",
|
|
815
|
+
"log_min_messages=fatal",
|
|
816
|
+
],
|
|
817
|
+
env: {
|
|
818
|
+
POSTGRES_HOST: "/var/run/postgresql",
|
|
819
|
+
PGPORT: "5432",
|
|
820
|
+
POSTGRES_PORT: "5432",
|
|
821
|
+
PGPASSWORD: dbPassword,
|
|
822
|
+
POSTGRES_PASSWORD: dbPassword,
|
|
823
|
+
PGDATABASE: "postgres",
|
|
824
|
+
POSTGRES_DB: "postgres",
|
|
825
|
+
JWT_SECRET: jwtSecret,
|
|
826
|
+
JWT_EXP: "3600",
|
|
827
|
+
},
|
|
828
|
+
ports: [5432],
|
|
829
|
+
// `pg_isready` succeeds only once the image finishes initdb + its
|
|
830
|
+
// baked init-scripts and starts serving for real.
|
|
831
|
+
readyCheck: { type: "exec", command: "pg_isready -U postgres -h 127.0.0.1 -p 5432", timeoutSecs: 180 },
|
|
832
|
+
setup: async (ctx) => {
|
|
833
|
+
// Bootstrap first (role passwords / JWT GUC / _realtime schema),
|
|
834
|
+
// then the project's own migrations + seed — both before any
|
|
835
|
+
// dependent service.
|
|
836
|
+
await applySqlFile(
|
|
837
|
+
ctx,
|
|
838
|
+
buildBootstrapSql(dbPassword, jwtSecret, withRealtime),
|
|
839
|
+
"supabase bootstrap",
|
|
840
|
+
// The bootstrap alters reserved roles (authenticator, …) and the
|
|
841
|
+
// database — superuser-only. `postgres` is deliberately NOT a
|
|
842
|
+
// superuser in modern supabase/postgres; `supabase_admin` is.
|
|
843
|
+
"supabase_admin",
|
|
844
|
+
);
|
|
845
|
+
await applyMigrations(ctx, {
|
|
846
|
+
migrations: opts.migrations ?? true,
|
|
847
|
+
seed: opts.seed ?? true,
|
|
848
|
+
});
|
|
849
|
+
},
|
|
850
|
+
};
|
|
851
|
+
parts.db = dbDef;
|
|
852
|
+
|
|
853
|
+
// ── rest (PostgREST) ────────────────────────────────────────────
|
|
854
|
+
parts.rest = {
|
|
855
|
+
image: { type: "registry", reference: IMAGES.rest },
|
|
856
|
+
env: {
|
|
857
|
+
PGRST_DB_URI: `postgres://authenticator:${dbPassword}@${db}:5432/postgres`,
|
|
858
|
+
PGRST_DB_SCHEMAS: "public,graphql_public",
|
|
859
|
+
PGRST_DB_ANON_ROLE: "anon",
|
|
860
|
+
PGRST_JWT_SECRET: jwtSecret,
|
|
861
|
+
PGRST_DB_USE_LEGACY_GUCS: "false",
|
|
862
|
+
PGRST_APP_SETTINGS_JWT_SECRET: jwtSecret,
|
|
863
|
+
PGRST_APP_SETTINGS_JWT_EXP: "3600",
|
|
864
|
+
PGRST_ADMIN_SERVER_PORT: "3001",
|
|
865
|
+
PGRST_DB_MAX_ROWS: "1000",
|
|
866
|
+
PGRST_DB_EXTRA_SEARCH_PATH: "public",
|
|
867
|
+
},
|
|
868
|
+
ports: [3000],
|
|
869
|
+
dependsOn: ["db"],
|
|
870
|
+
// PostgREST opens :3000 only after connecting to the DB and loading
|
|
871
|
+
// the schema cache, so a TCP connect is a good ready signal.
|
|
872
|
+
readyCheck: { type: "tcp", port: 3000, timeoutSecs: 120 },
|
|
873
|
+
};
|
|
874
|
+
|
|
875
|
+
// ── auth (GoTrue) ───────────────────────────────────────────────
|
|
876
|
+
if (withAuth) {
|
|
877
|
+
const apiExternalUrl = `${url}/auth/v1`;
|
|
878
|
+
parts.auth = {
|
|
879
|
+
image: { type: "registry", reference: IMAGES.auth },
|
|
880
|
+
env: {
|
|
881
|
+
GOTRUE_API_HOST: "0.0.0.0",
|
|
882
|
+
GOTRUE_API_PORT: "9999",
|
|
883
|
+
API_EXTERNAL_URL: apiExternalUrl,
|
|
884
|
+
GOTRUE_DB_DRIVER: "postgres",
|
|
885
|
+
GOTRUE_DB_DATABASE_URL: `postgres://supabase_auth_admin:${dbPassword}@${db}:5432/postgres`,
|
|
886
|
+
GOTRUE_SITE_URL: url,
|
|
887
|
+
GOTRUE_URI_ALLOW_LIST: "*",
|
|
888
|
+
GOTRUE_DISABLE_SIGNUP: "false",
|
|
889
|
+
GOTRUE_JWT_ADMIN_ROLES: "service_role",
|
|
890
|
+
GOTRUE_JWT_AUD: "authenticated",
|
|
891
|
+
GOTRUE_JWT_DEFAULT_GROUP_NAME: "authenticated",
|
|
892
|
+
GOTRUE_JWT_EXP: "3600",
|
|
893
|
+
GOTRUE_JWT_SECRET: jwtSecret,
|
|
894
|
+
GOTRUE_JWT_ISSUER: apiExternalUrl,
|
|
895
|
+
GOTRUE_EXTERNAL_EMAIL_ENABLED: "true",
|
|
896
|
+
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: "true",
|
|
897
|
+
// Autoconfirm so tests can sign up without a mail server
|
|
898
|
+
// round-trip.
|
|
899
|
+
GOTRUE_MAILER_AUTOCONFIRM: "true",
|
|
900
|
+
GOTRUE_EXTERNAL_PHONE_ENABLED: "false",
|
|
901
|
+
GOTRUE_SMS_AUTOCONFIRM: "true",
|
|
902
|
+
},
|
|
903
|
+
ports: [9999],
|
|
904
|
+
dependsOn: ["db"],
|
|
905
|
+
readyCheck: { type: "http", port: 9999, path: "/health", timeoutSecs: 90 },
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// ── storage (+ imgproxy) ────────────────────────────────────────
|
|
910
|
+
if (withStorage) {
|
|
911
|
+
// Named shared volume so imgproxy can read what storage-api wrote —
|
|
912
|
+
// one backing dir mounted by both containers, snapshot/fork- and
|
|
913
|
+
// teardown-managed like any other volume.
|
|
914
|
+
const storageData = { name: `${g.name}-storage-data`, target: "/var/lib/storage" };
|
|
915
|
+
parts.imgproxy = {
|
|
916
|
+
image: { type: "registry", reference: IMAGES.imgproxy },
|
|
917
|
+
env: {
|
|
918
|
+
IMGPROXY_BIND: ":5001",
|
|
919
|
+
IMGPROXY_LOCAL_FILESYSTEM_ROOT: "/",
|
|
920
|
+
IMGPROXY_USE_ETAG: "true",
|
|
921
|
+
IMGPROXY_AUTO_WEBP: "true",
|
|
922
|
+
IMGPROXY_MAX_SRC_RESOLUTION: "16.8",
|
|
923
|
+
},
|
|
924
|
+
volumes: [storageData],
|
|
925
|
+
ports: [5001],
|
|
926
|
+
readyCheck: { type: "tcp", port: 5001, timeoutSecs: 60 },
|
|
927
|
+
};
|
|
928
|
+
parts.storage = {
|
|
929
|
+
image: { type: "registry", reference: IMAGES.storage },
|
|
930
|
+
env: {
|
|
931
|
+
ANON_KEY: anonKey,
|
|
932
|
+
SERVICE_KEY: serviceRoleKey,
|
|
933
|
+
POSTGREST_URL: `http://${g.key("rest")}:3000`,
|
|
934
|
+
AUTH_JWT_SECRET: jwtSecret,
|
|
935
|
+
DATABASE_URL: `postgres://supabase_storage_admin:${dbPassword}@${db}:5432/postgres`,
|
|
936
|
+
STORAGE_PUBLIC_URL: url,
|
|
937
|
+
REQUEST_ALLOW_X_FORWARDED_PATH: "true",
|
|
938
|
+
FILE_SIZE_LIMIT: "52428800",
|
|
939
|
+
STORAGE_BACKEND: "file",
|
|
940
|
+
GLOBAL_S3_BUCKET: "stub",
|
|
941
|
+
FILE_STORAGE_BACKEND_PATH: "/var/lib/storage",
|
|
942
|
+
TENANT_ID: "stub",
|
|
943
|
+
REGION: "stub",
|
|
944
|
+
ENABLE_IMAGE_TRANSFORMATION: "true",
|
|
945
|
+
IMGPROXY_URL: `http://${g.key("imgproxy")}:5001`,
|
|
946
|
+
S3_PROTOCOL_ACCESS_KEY_ID: "625729a08b95bf1b7ff351a663f3a23c",
|
|
947
|
+
S3_PROTOCOL_ACCESS_KEY_SECRET:
|
|
948
|
+
"850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907",
|
|
949
|
+
},
|
|
950
|
+
volumes: [storageData],
|
|
951
|
+
ports: [5000],
|
|
952
|
+
dependsOn: ["db", "rest", "imgproxy"],
|
|
953
|
+
readyCheck: { type: "http", port: 5000, path: "/status", timeoutSecs: 90 },
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// ── realtime ────────────────────────────────────────────────────
|
|
958
|
+
if (withRealtime) {
|
|
959
|
+
parts.realtime = {
|
|
960
|
+
image: { type: "registry", reference: IMAGES.realtime },
|
|
961
|
+
// Extra peer alias so Kong (and the tenant-subdomain parser)
|
|
962
|
+
// reaches it at `realtime-dev.<realtime key>`.
|
|
963
|
+
hostnames: [realtimeHost],
|
|
964
|
+
env: {
|
|
965
|
+
PORT: "4000",
|
|
966
|
+
DB_HOST: db,
|
|
967
|
+
DB_PORT: "5432",
|
|
968
|
+
DB_USER: "supabase_admin",
|
|
969
|
+
DB_PASSWORD: dbPassword,
|
|
970
|
+
DB_NAME: "postgres",
|
|
971
|
+
DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime",
|
|
972
|
+
DB_ENC_KEY: "supabaserealtime",
|
|
973
|
+
API_JWT_SECRET: jwtSecret,
|
|
974
|
+
METRICS_JWT_SECRET: jwtSecret,
|
|
975
|
+
SECRET_KEY_BASE:
|
|
976
|
+
"UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq",
|
|
977
|
+
ERL_AFLAGS: "-proto_dist inet_tcp",
|
|
978
|
+
DNS_NODES: "''",
|
|
979
|
+
RLIMIT_NOFILE: "10000",
|
|
980
|
+
APP_NAME: "realtime",
|
|
981
|
+
SEED_SELF_HOST: "true",
|
|
982
|
+
RUN_JANITOR: "true",
|
|
983
|
+
DISABLE_HEALTHCHECK_LOGGING: "true",
|
|
984
|
+
},
|
|
985
|
+
ports: [4000],
|
|
986
|
+
dependsOn: ["db"],
|
|
987
|
+
// The health endpoint wants a valid JWT — `headers` keeps the
|
|
988
|
+
// probe image-agnostic (no curl-in-image dependency).
|
|
989
|
+
readyCheck: {
|
|
990
|
+
type: "http",
|
|
991
|
+
port: 4000,
|
|
992
|
+
path: "/api/tenants/realtime-dev/health",
|
|
993
|
+
headers: { Authorization: `Bearer ${anonKey}` },
|
|
994
|
+
timeoutSecs: 120,
|
|
995
|
+
},
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// ── meta (postgres-meta) ────────────────────────────────────────
|
|
1000
|
+
if (withMeta) {
|
|
1001
|
+
parts.meta = {
|
|
1002
|
+
image: { type: "registry", reference: IMAGES.meta },
|
|
1003
|
+
env: {
|
|
1004
|
+
PG_META_PORT: "8080",
|
|
1005
|
+
PG_META_DB_HOST: db,
|
|
1006
|
+
PG_META_DB_PORT: "5432",
|
|
1007
|
+
PG_META_DB_NAME: "postgres",
|
|
1008
|
+
PG_META_DB_USER: "postgres",
|
|
1009
|
+
PG_META_DB_PASSWORD: dbPassword,
|
|
1010
|
+
CRYPTO_KEY: "your-encryption-key-32-chars-min",
|
|
1011
|
+
},
|
|
1012
|
+
ports: [8080],
|
|
1013
|
+
dependsOn: ["db"],
|
|
1014
|
+
readyCheck: { type: "tcp", port: 8080, timeoutSecs: 90 },
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// ── studio ──────────────────────────────────────────────────────
|
|
1019
|
+
if (withStudio) {
|
|
1020
|
+
parts.studio = {
|
|
1021
|
+
image: { type: "registry", reference: IMAGES.studio },
|
|
1022
|
+
env: {
|
|
1023
|
+
HOSTNAME: "0.0.0.0",
|
|
1024
|
+
STUDIO_PG_META_URL: `http://${g.key("meta")}:8080`,
|
|
1025
|
+
POSTGRES_PORT: "5432",
|
|
1026
|
+
POSTGRES_HOST: db,
|
|
1027
|
+
POSTGRES_DB: "postgres",
|
|
1028
|
+
POSTGRES_PASSWORD: dbPassword,
|
|
1029
|
+
POSTGRES_USER_READ_WRITE: "postgres",
|
|
1030
|
+
PG_META_CRYPTO_KEY: "your-encryption-key-32-chars-min",
|
|
1031
|
+
PGRST_DB_SCHEMAS: "public,graphql_public",
|
|
1032
|
+
DEFAULT_ORGANIZATION_NAME: "Default Organization",
|
|
1033
|
+
DEFAULT_PROJECT_NAME: "Default Project",
|
|
1034
|
+
SUPABASE_URL: `http://${g.name}:8000`,
|
|
1035
|
+
SUPABASE_PUBLIC_URL: url,
|
|
1036
|
+
SUPABASE_ANON_KEY: anonKey,
|
|
1037
|
+
SUPABASE_SERVICE_KEY: serviceRoleKey,
|
|
1038
|
+
AUTH_JWT_SECRET: jwtSecret,
|
|
1039
|
+
},
|
|
1040
|
+
ports: [3000],
|
|
1041
|
+
dependsOn: ["meta"],
|
|
1042
|
+
readyCheck: {
|
|
1043
|
+
type: "http",
|
|
1044
|
+
port: 3000,
|
|
1045
|
+
path: "/api/platform/profile",
|
|
1046
|
+
timeoutSecs: 180,
|
|
1047
|
+
},
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// ── kong (gateway / primary) — no explicit dependsOn: the group
|
|
1052
|
+
// expansion makes the primary depend on every member ──────────────
|
|
1053
|
+
parts.gateway = {
|
|
1054
|
+
image: { type: "registry", reference: IMAGES.kong },
|
|
1055
|
+
env: {
|
|
1056
|
+
KONG_DATABASE: "off",
|
|
1057
|
+
KONG_DECLARATIVE_CONFIG: "/home/kong/kong.yml",
|
|
1058
|
+
KONG_ROUTER_FLAVOR: "expressions",
|
|
1059
|
+
KONG_DNS_ORDER: "LAST,A,CNAME",
|
|
1060
|
+
KONG_DNS_NOT_FOUND_TTL: "1",
|
|
1061
|
+
KONG_PLUGINS:
|
|
1062
|
+
"request-transformer,cors,key-auth,acl,basic-auth,request-termination,ip-restriction,post-function",
|
|
1063
|
+
KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: "160k",
|
|
1064
|
+
KONG_NGINX_PROXY_PROXY_BUFFERS: "64 160k",
|
|
1065
|
+
},
|
|
1066
|
+
files: [
|
|
1067
|
+
{
|
|
1068
|
+
path: "/home/kong/kong.yml",
|
|
1069
|
+
content: buildKongYaml(g.key, {
|
|
1070
|
+
anonKey,
|
|
1071
|
+
serviceRoleKey,
|
|
1072
|
+
dashboardUsername,
|
|
1073
|
+
dashboardPassword,
|
|
1074
|
+
}),
|
|
1075
|
+
},
|
|
1076
|
+
],
|
|
1077
|
+
ports: [8000, 8443],
|
|
1078
|
+
...(opts.hostname ? { tls: [{ hostname: opts.hostname, port: 8000 }] } : {}),
|
|
1079
|
+
readyCheck: { type: "exec", command: "kong health", timeoutSecs: 90 },
|
|
1080
|
+
};
|
|
1081
|
+
|
|
1082
|
+
return parts;
|
|
1083
|
+
},
|
|
1084
|
+
// The group's consolidated handle at `ctx.svc.<name>`.
|
|
1085
|
+
helpers: (): SupabaseHelpers => ({
|
|
1086
|
+
sql: new SQL(adminDbUrl, { label: name }),
|
|
1087
|
+
url,
|
|
1088
|
+
anonKey,
|
|
1089
|
+
serviceRoleKey,
|
|
1090
|
+
}),
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
const appEnv = {
|
|
1094
|
+
SUPABASE_URL: url,
|
|
1095
|
+
SUPABASE_ANON_KEY: anonKey,
|
|
1096
|
+
SUPABASE_SERVICE_ROLE_KEY: serviceRoleKey,
|
|
1097
|
+
DATABASE_URL: dbUrl,
|
|
1098
|
+
};
|
|
1099
|
+
|
|
1100
|
+
return {
|
|
1101
|
+
group,
|
|
1102
|
+
url,
|
|
1103
|
+
anonKey,
|
|
1104
|
+
serviceRoleKey,
|
|
1105
|
+
dbUrl,
|
|
1106
|
+
ready: name,
|
|
1107
|
+
appEnv,
|
|
1108
|
+
};
|
|
1109
|
+
}
|