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