@specific.dev/spectest 0.38.0 → 0.41.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 (103) hide show
  1. package/dist/components/k3s.js +1 -24
  2. package/dist/components/supabase.d.ts +87 -27
  3. package/dist/components/supabase.js +352 -69
  4. package/dist/daemon.d.ts +38 -0
  5. package/dist/daemon.js +405 -946
  6. package/dist/harness/build-context.d.ts +82 -0
  7. package/dist/harness/build-context.js +113 -0
  8. package/dist/harness/buildkit-progress.d.ts +37 -0
  9. package/dist/harness/buildkit-progress.js +66 -0
  10. package/dist/harness/container-run.d.ts +89 -0
  11. package/dist/harness/container-run.js +118 -0
  12. package/dist/harness/file-mounts.d.ts +91 -0
  13. package/dist/harness/file-mounts.js +119 -0
  14. package/dist/harness/hostmatch.d.ts +65 -0
  15. package/dist/harness/hostmatch.js +108 -0
  16. package/dist/harness/http-proxy.d.ts +62 -0
  17. package/dist/harness/http-proxy.js +104 -0
  18. package/dist/harness/ingress-table.d.ts +148 -0
  19. package/dist/harness/ingress-table.js +129 -0
  20. package/dist/harness/log-delta.d.ts +54 -0
  21. package/dist/harness/log-delta.js +83 -0
  22. package/dist/harness/main.d.ts +47 -0
  23. package/dist/harness/main.js +164 -0
  24. package/dist/harness/methods.d.ts +54 -0
  25. package/dist/harness/methods.js +65 -0
  26. package/dist/harness/names-registry.d.ts +63 -0
  27. package/dist/harness/names-registry.js +90 -0
  28. package/dist/harness/protocol.d.ts +88 -0
  29. package/dist/harness/protocol.js +96 -0
  30. package/dist/harness/ready-poll.d.ts +47 -0
  31. package/dist/harness/ready-poll.js +67 -0
  32. package/dist/harness/service-graph.d.ts +29 -0
  33. package/dist/harness/service-graph.js +92 -0
  34. package/dist/harness/volume-paths.d.ts +70 -0
  35. package/dist/harness/volume-paths.js +81 -0
  36. package/dist/index.d.ts +3 -3
  37. package/dist/ingress.d.ts +1 -1
  38. package/dist/inspect.d.ts +23 -0
  39. package/dist/inspect.js +65 -0
  40. package/dist/resolver.js +5 -8
  41. package/dist/vendor/rrweb-plugin-console-record.umd.js +521 -0
  42. package/dist/vendor/rrweb-record.min.js +5061 -0
  43. package/package.json +7 -1
  44. package/src/aws-sigv4.ts +218 -0
  45. package/src/browser.ts +2040 -0
  46. package/src/components/aws.ts +554 -0
  47. package/src/components/email.ts +398 -0
  48. package/src/components/expo.ts +167 -0
  49. package/src/components/index.ts +81 -0
  50. package/src/components/k3s.ts +2061 -0
  51. package/src/components/postgres.ts +132 -0
  52. package/src/components/replayFake.ts +1015 -0
  53. package/src/components/s3.ts +132 -0
  54. package/src/components/supabase.ts +1699 -0
  55. package/src/daemon.ts +5489 -0
  56. package/src/harness/build-context.test.ts +0 -0
  57. package/src/harness/build-context.ts +146 -0
  58. package/src/harness/buildkit-progress.test.ts +98 -0
  59. package/src/harness/buildkit-progress.ts +74 -0
  60. package/src/harness/container-run.test.ts +209 -0
  61. package/src/harness/container-run.ts +158 -0
  62. package/src/harness/file-mounts.test.ts +185 -0
  63. package/src/harness/file-mounts.ts +145 -0
  64. package/src/harness/hostmatch.test.ts +148 -0
  65. package/src/harness/hostmatch.ts +109 -0
  66. package/src/harness/http-proxy.test.ts +156 -0
  67. package/src/harness/http-proxy.ts +119 -0
  68. package/src/harness/ingress-rebind.test.ts +125 -0
  69. package/src/harness/ingress-table.test.ts +172 -0
  70. package/src/harness/ingress-table.ts +186 -0
  71. package/src/harness/log-delta.test.ts +125 -0
  72. package/src/harness/log-delta.ts +100 -0
  73. package/src/harness/main.test.ts +211 -0
  74. package/src/harness/main.ts +196 -0
  75. package/src/harness/methods.test.ts +63 -0
  76. package/src/harness/methods.ts +92 -0
  77. package/src/harness/names-registry.test.ts +137 -0
  78. package/src/harness/names-registry.ts +108 -0
  79. package/src/harness/protocol.test.ts +148 -0
  80. package/src/harness/protocol.ts +163 -0
  81. package/src/harness/ready-poll.test.ts +172 -0
  82. package/src/harness/ready-poll.ts +93 -0
  83. package/src/harness/service-graph.test.ts +97 -0
  84. package/src/harness/service-graph.ts +97 -0
  85. package/src/harness/volume-paths.test.ts +102 -0
  86. package/src/harness/volume-paths.ts +112 -0
  87. package/src/ids.ts +89 -0
  88. package/src/index.ts +2725 -0
  89. package/src/ingress.ts +305 -0
  90. package/src/inspect.ts +739 -0
  91. package/src/locator.ts +716 -0
  92. package/src/mobile.ts +133 -0
  93. package/src/record-secrets.ts +41 -0
  94. package/src/recorder.ts +846 -0
  95. package/src/redis.ts +202 -0
  96. package/src/replay-bundle.ts +108 -0
  97. package/src/resolver.ts +348 -0
  98. package/src/s3.ts +333 -0
  99. package/src/sql.ts +243 -0
  100. package/src/terminal.ts +740 -0
  101. package/src/url-match.ts +67 -0
  102. package/src/vendor/rrweb-plugin-console-record.umd.js +521 -0
  103. package/src/vendor/rrweb-record.min.js +5061 -0
@@ -0,0 +1,1699 @@
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 { dirname, 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
+ functions: "supabase/edge-runtime:v1.74.0",
84
+ } as const;
85
+
86
+ /**
87
+ * The project root inside the VM. A service's `volumes[].source` is a static
88
+ * string in the wire config, so the edge runtime's bind mount cannot go
89
+ * through `ctx.projectRoot` (which only exists once a hook runs) — this is
90
+ * the same value, read the same way the harness reads it.
91
+ */
92
+ function projectRootPath(): string {
93
+ return process.env.SPECTEST_WORKSPACE ?? "/workspace";
94
+ }
95
+
96
+ export interface SupabaseOptions {
97
+ /**
98
+ * Key of the API-gateway service and the prefix for every other piece.
99
+ * Default `"supabase"`. The gateway is reachable at `http://<name>:8000`
100
+ * and its helpers at `ctx.svc.<name>`; the rest are `<name>-db`,
101
+ * `<name>-auth`, `<name>-rest`, `<name>-storage`, `<name>-realtime`, ….
102
+ */
103
+ name?: string;
104
+ /**
105
+ * Password for the Postgres roles the services authenticate with (and the
106
+ * `postgres` superuser the app / SQL helper connect as). Default
107
+ * `"postgres"`. Purely local to the hermetic VM — not a secret to guard.
108
+ */
109
+ dbPassword?: string;
110
+ /**
111
+ * HS256 secret that signs and verifies all JWTs (user access tokens plus the
112
+ * derived `anonKey` / `serviceRoleKey` API keys). Must be ≥32 chars. Default
113
+ * is Supabase's well-known demo secret. Changing it re-derives the keys
114
+ * deterministically, so the warm-template cache stays stable.
115
+ */
116
+ jwtSecret?: string;
117
+ /** Studio dashboard Basic-Auth username (only relevant with `studio`). Default `"supabase"`. */
118
+ dashboardUsername?: string;
119
+ /** Studio dashboard Basic-Auth password (only relevant with `studio`). */
120
+ dashboardPassword?: string;
121
+
122
+ /** Include GoTrue auth (`<name>-auth`). Default `true`. */
123
+ auth?: boolean;
124
+ /** Include storage-api + imgproxy (`<name>-storage`, `<name>-imgproxy`). Default `true`. */
125
+ storage?: boolean;
126
+ /** Include Realtime (`<name>-realtime`). Default `true`. */
127
+ realtime?: boolean;
128
+ /**
129
+ * Include the Studio dashboard (`<name>-studio`) and its postgres-meta
130
+ * backend (`<name>-meta`). Off by default — Studio is a heavy Next.js image
131
+ * and is only useful for interactive `ctx.browser()` exploration, not
132
+ * automated backend assertions. Turning it on implies `meta`.
133
+ */
134
+ studio?: boolean;
135
+ /** Include postgres-meta (`<name>-meta`). Defaults to whatever `studio` is. */
136
+ meta?: boolean;
137
+
138
+ /**
139
+ * Also serve the gateway over **HTTPS** at `https://<hostname>` via the
140
+ * daemon's CA-trusted TLS reverse proxy (in addition to the plain
141
+ * `http://<name>:8000`). Set this only when the app under test hardcodes a
142
+ * specific Supabase URL it can't be told to override — otherwise leave it and
143
+ * point the app at `sb.url`.
144
+ */
145
+ hostname?: string;
146
+
147
+ /**
148
+ * Capture GoTrue's outgoing mail (signup confirmations, magic links, OTPs,
149
+ * password recovery, invites) with the standard `email()` component. **On
150
+ * by default**: the stack includes the capture server (`<name>-mail`) with
151
+ * GoTrue's SMTP wired at it, and tests read the mailbox through the typed
152
+ * helpers at `ctx.svc.<name>.mail` (`lastEmail` / `emails` / `clear`), each
153
+ * captured message rendering mail-client-style on the dashboard timeline.
154
+ *
155
+ * Signups complete in **one step** by default (`autoconfirm: true`), so
156
+ * the mailbox is purely additive and a suite that just needs accounts
157
+ * writes nothing extra. Pass `{ autoconfirm: false }` to run the real
158
+ * confirmation flow instead — the signup then issues no session until a
159
+ * test follows the emailed link out of the mailbox. Pass
160
+ * `{ service: "<key>" }` to point GoTrue at
161
+ * an `email()` service the environment already declares — keep a single
162
+ * mailbox per environment rather than one per component: if the app under
163
+ * test also sends mail, share one `email()` between it and Supabase
164
+ * instead of standing up a second capture server. `false` disables mail
165
+ * capture entirely.
166
+ */
167
+ mail?: boolean | SupabaseMailOptions;
168
+
169
+ /**
170
+ * Extra API keys the gateway accepts alongside the derived JWT keys — for
171
+ * clients that ship a hardcoded key (e.g. an `sb_publishable_…` /
172
+ * `sb_secret_…` pair baked into an app build). Each `anon` key is admitted
173
+ * with anon privileges and each `serviceRole` key with service-role
174
+ * privileges; Kong swaps the matching *derived JWT* in before proxying, so
175
+ * GoTrue/PostgREST/Realtime still receive a validly-signed token.
176
+ */
177
+ extraApiKeys?: { anon?: string[]; serviceRole?: string[] };
178
+
179
+ /**
180
+ * Where the project's SQL migrations live, e.g. `"supabase/migrations"`
181
+ * (relative to the project root, or absolute). **Nothing is applied unless
182
+ * you set this** — there is no assumed path, so a project whose schema
183
+ * arrives some other way (an app that migrates itself at boot, a dump
184
+ * restored in `setup`) never gets a second, surprising source of DDL.
185
+ *
186
+ * Every `*.sql` directly in the directory is applied in sorted filename
187
+ * order, then — the standard Supabase layout — the **`seed.sql` beside
188
+ * that directory**, if there is one:
189
+ *
190
+ * ```
191
+ * supabase/
192
+ * migrations/0001_init.sql ← applied, sorted
193
+ * migrations/0002_todos.sql
194
+ * seed.sql ← applied last
195
+ * ```
196
+ *
197
+ * It all runs **once the whole stack is up** — the same point `supabase db
198
+ * reset` applies migrations — so your SQL may use schema the services
199
+ * create when they boot (`storage.buckets`, the modern `auth.*` tables).
200
+ * PostgREST's schema cache is reloaded afterwards.
201
+ *
202
+ * A path that doesn't resolve is an error at env start; a silent no-op
203
+ * there surfaces much later as a confusing `relation … does not exist`.
204
+ * Because `supabase/**` is project content, editing a migration correctly
205
+ * forces a cold rebuild and re-apply (unlike `spectest/tests/**`).
206
+ */
207
+ migrations?: string;
208
+
209
+ /**
210
+ * Where the project's **edge functions** live, e.g.
211
+ * `"supabase/functions"` — one subdirectory per function, each with an
212
+ * entrypoint the runtime can serve (`supabase/functions/hello/index.ts`).
213
+ *
214
+ * **Nothing runs unless you set this.** Point at the directory and every
215
+ * function in it is served on the real Supabase edge runtime at
216
+ * `<url>/functions/v1/<name>` through the gateway — the same path
217
+ * `supabase.functions.invoke(...)` derives, so the app under test needs no
218
+ * override. Leave it unset and the runtime is left out of the stack
219
+ * entirely, along with the gateway's `/functions/v1` route.
220
+ *
221
+ * Function code is read straight from the repo — nothing is copied or
222
+ * bundled — so editing a function is an ordinary project edit.
223
+ *
224
+ * Pass an object instead of a path to turn JWT verification off, or to set
225
+ * the function secrets your code reads with `Deno.env.get(...)`.
226
+ */
227
+ functions?: string | SupabaseFunctionsOptions;
228
+ }
229
+
230
+ /** Tuning for `SupabaseOptions.functions`. */
231
+ export interface SupabaseFunctionsOptions {
232
+ /**
233
+ * Directory holding one subdirectory per function, relative to the project
234
+ * root (or absolute). Required — there is no assumed location.
235
+ */
236
+ dir: string;
237
+ /**
238
+ * Reject a call that carries no valid JWT, exactly as hosted Supabase
239
+ * does. Default `true` — so a test that forgets the `Authorization`
240
+ * header fails here instead of in production. Set `false` for the
241
+ * `--no-verify-jwt` behaviour (webhooks, public endpoints).
242
+ */
243
+ verifyJwt?: boolean;
244
+ /**
245
+ * Extra environment variables the functions read (`Deno.env.get("…")`) —
246
+ * the local stand-in for function secrets. `SUPABASE_URL`,
247
+ * `SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `SUPABASE_DB_URL` and
248
+ * `JWT_SECRET` are already set for you.
249
+ */
250
+ env?: Record<string, string>;
251
+ }
252
+
253
+ /** Tuning for `SupabaseOptions.mail`. */
254
+ export interface SupabaseMailOptions {
255
+ /**
256
+ * Reuse an `email()` service the environment already declares (its
257
+ * services-map key) instead of adding one to the group — the way to keep a
258
+ * single global mailbox when the app under test sends mail too. Assumes
259
+ * the service's default ports (SMTP 1025, API 8025).
260
+ */
261
+ service?: string;
262
+ /**
263
+ * Whether GoTrue confirms a signup without the emailed verification.
264
+ * **`true` by default**: signups complete in one step and send no
265
+ * confirmation mail, which keeps account creation a single call in the
266
+ * suites where it is setup rather than the thing under test.
267
+ *
268
+ * Set `false` to run the flow a hosted project runs: the signup issues no
269
+ * session, and the test follows the emailed link out of the mailbox
270
+ * exactly as a user would (see `examples/supabase-app`). Recovery,
271
+ * magic-link, OTP and invite mail is sent either way. Forced `true` when
272
+ * the stack has no mailbox (`mail: false`), since the confirmation would
273
+ * go nowhere.
274
+ */
275
+ autoconfirm?: boolean;
276
+ /** Sender address GoTrue mails from. Default `admin@example.com`. */
277
+ adminEmail?: string;
278
+ /** Sender display name. Default `Supabase`. */
279
+ senderName?: string;
280
+ }
281
+
282
+ /** Helpers the gateway service exposes on `ctx.svc.<name>`. */
283
+ export interface SupabaseHelpers {
284
+ /**
285
+ * An instrumented {@link SqlClient} connected to the database as the
286
+ * `postgres` superuser — every query lands on the test event log and its rows
287
+ * come back provenance-wrapped, so `expect(...)` on a read links to the
288
+ * query. Point of direct DB verification alongside the REST/auth surface.
289
+ */
290
+ sql: SqlClient;
291
+ /** The gateway base URL the app uses as `SUPABASE_URL` (e.g. `http://supabase:8000`). */
292
+ url: string;
293
+ /** Long-lived `anon` API key (HS256 JWT). Pass as the `apikey` header. */
294
+ anonKey: string;
295
+ /** Long-lived `service_role` API key (HS256 JWT). Bypasses RLS — server-side only. */
296
+ serviceRoleKey: string;
297
+ /**
298
+ * The captured-mail mailbox (`lastEmail` / `emails` / `clear`) — present
299
+ * unless the stack was built with `mail: false`. Wait for delivery with
300
+ * the standard `ctx.poll`:
301
+ *
302
+ * ```ts
303
+ * const mail = await ctx.poll("confirmation email", () =>
304
+ * ctx.svc.supabase.mail!.lastEmail());
305
+ * expect(mail.to).toContain("alice@example.com");
306
+ * ```
307
+ */
308
+ mail?: EmailHelpers;
309
+ }
310
+
311
+ /** What `supabase()` returns: the service group plus the derived
312
+ * connection surface for wiring the app under test. */
313
+ export interface SupabaseStack {
314
+ /** The Supabase stack as a service group. Mount it at the key matching
315
+ * the `name` option (default `"supabase"`):
316
+ * `services: { supabase: sb.group, ... }`. */
317
+ group: ServiceGroup<ServicesMap, SupabaseHelpers>;
318
+ /** Gateway base URL — `http://<name>:8000`, or `https://<hostname>` when set. */
319
+ url: string;
320
+ /** `anon` API key (HS256 JWT). */
321
+ anonKey: string;
322
+ /** `service_role` API key (HS256 JWT). */
323
+ serviceRoleKey: string;
324
+ /** Direct Postgres URL for the app (`postgres` superuser): `postgresql://postgres:<pw>@<name>-db:5432/postgres`. */
325
+ dbUrl: string;
326
+ /** The gateway service key — use in a dependent service's `dependsOn` to wait
327
+ * for the whole stack (the gateway waits on the services behind it). */
328
+ ready: string;
329
+ /**
330
+ * Where the stack's SMTP capture server listens (absent with
331
+ * `mail: false`) — point the app under test's own mail transport here to
332
+ * share the one mailbox: `env: { SMTP_HOST: sb.smtp.host, SMTP_PORT: String(sb.smtp.port) }`.
333
+ */
334
+ smtp?: { host: string; port: number };
335
+ /** Ready-to-spread env for the app under test: `SUPABASE_URL`,
336
+ * `SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `DATABASE_URL`. */
337
+ appEnv: Record<string, string>;
338
+ }
339
+
340
+ // Supabase's well-known demo secret (≥32 chars). Local-only; override via opts.
341
+ const DEFAULT_JWT_SECRET =
342
+ "your-super-secret-jwt-token-with-at-least-32-characters-long";
343
+
344
+ // Fixed issued-at / expiry for the derived API keys. Constant (not `Date.now`)
345
+ // so the keys are a pure function of the secret and never perturb the
346
+ // warm-template hash. `exp` is 2027-01-01, matching Supabase's demo keys.
347
+ const KEY_IAT = 1641769200;
348
+ const KEY_EXP = 1799535600;
349
+
350
+ function base64url(input: Buffer | string): string {
351
+ return Buffer.from(input)
352
+ .toString("base64")
353
+ .replace(/\+/g, "-")
354
+ .replace(/\//g, "_")
355
+ .replace(/=+$/, "");
356
+ }
357
+
358
+ /** Sign a compact HS256 JWT. Any token with the right `role` claim signed by
359
+ * `JWT_SECRET` is a valid Supabase API key — we don't need to reproduce the
360
+ * exact demo string, just a validly-signed one. */
361
+ function signHs256(payload: Record<string, unknown>, secret: string): string {
362
+ const header = base64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
363
+ const body = base64url(JSON.stringify(payload));
364
+ const data = `${header}.${body}`;
365
+ const sig = base64url(createHmac("sha256", secret).update(data).digest());
366
+ return `${data}.${sig}`;
367
+ }
368
+
369
+ function apiKey(role: "anon" | "service_role", secret: string): string {
370
+ return signHs256({ role, iss: "supabase-demo", iat: KEY_IAT, exp: KEY_EXP }, secret);
371
+ }
372
+
373
+ // ──────────────────────────────────────────────────────────────────────────
374
+ // Postgres bootstrap SQL. The `supabase/postgres` image already creates every
375
+ // Supabase role/schema in its baked init-scripts; we only need to (a) set the
376
+ // network-facing roles' passwords to ours, (b) plant the JWT secret as a DB GUC
377
+ // (used by RLS `auth.jwt()`), and (c) create the `_realtime` schema.
378
+ //
379
+ // We apply this in the db service's `setup` hook (via `psql`) rather than as
380
+ // mounted init-scripts, deliberately: the image's `migrate.sh` runs mounted
381
+ // files under `ON_ERROR_STOP`, so a single unexpected role (e.g.
382
+ // `supabase_functions_admin`, which only exists when the DB-webhooks script is
383
+ // present) aborts the whole init and the container exits. `setup` runs after
384
+ // the DB is ready and before any dependent service starts, so ordering is still
385
+ // guaranteed, and we touch only roles the base image is known to create.
386
+ // ──────────────────────────────────────────────────────────────────────────
387
+
388
+ /** Quote a Postgres string literal (single-quote, double any embedded quote). */
389
+ function pgLiteral(s: string): string {
390
+ return `'${s.replace(/'/g, "''")}'`;
391
+ }
392
+
393
+ function buildBootstrapSql(
394
+ dbPassword: string,
395
+ jwtSecret: string,
396
+ withRealtime: boolean,
397
+ ): string {
398
+ const pw = pgLiteral(dbPassword);
399
+ const lines = [
400
+ // Network-facing roles the services authenticate as. supabase_admin and
401
+ // postgres already have their password set to POSTGRES_PASSWORD by the
402
+ // image's own bootstrap, so we leave them alone.
403
+ `ALTER USER authenticator WITH PASSWORD ${pw};`,
404
+ `ALTER USER supabase_auth_admin WITH PASSWORD ${pw};`,
405
+ `ALTER USER supabase_storage_admin WITH PASSWORD ${pw};`,
406
+ // JWT secret GUC for RLS (`current_setting('app.settings.jwt_secret')`).
407
+ `ALTER DATABASE postgres SET "app.settings.jwt_secret" TO ${pgLiteral(jwtSecret)};`,
408
+ `ALTER DATABASE postgres SET "app.settings.jwt_exp" TO '3600';`,
409
+ ];
410
+ if (withRealtime) {
411
+ lines.push(
412
+ "CREATE SCHEMA IF NOT EXISTS _realtime;",
413
+ "ALTER SCHEMA _realtime OWNER TO supabase_admin;",
414
+ );
415
+ }
416
+ return lines.join("\n") + "\n";
417
+ }
418
+
419
+ // ──────────────────────────────────────────────────────────────────────────
420
+ // Kong declarative gateway config (docker/volumes/api/kong.yml). Embedded
421
+ // verbatim with `$VAR` placeholders; substituted below the same way
422
+ // kong-entrypoint.sh does — including the legacy (opaque-keys-disabled) Lua
423
+ // expressions that just pass the `apikey` header/query param straight through.
424
+ // ──────────────────────────────────────────────────────────────────────────
425
+
426
+ // The two Lua router expressions kong-entrypoint.sh emits when the opaque
427
+ // `sb_`-style keys are NOT configured: pass the incoming apikey (a legacy
428
+ // HS256 JWT) through unchanged, preferring an existing non-`sb_`
429
+ // Authorization header. `extraApiKeys` extends both: an extra key (arriving
430
+ // as `apikey` header, `Authorization: Bearer <key>`, or the websocket
431
+ // `?apikey=` query param) is swapped for the corresponding *derived JWT*
432
+ // before proxying, so the upstream services still see a validly-signed token.
433
+ //
434
+ // SPECTEST DEVIATION — the `apikey` **query parameter** is a first-class
435
+ // credential here, not just a websocket one. Kong's key-auth already accepts
436
+ // it (`key_in_query` defaults on), but upstream saw only the header, so a
437
+ // query-param request reached PostgREST as the anon role. A browser is the
438
+ // reason this matters: `browser.goto(url)` sends no headers, so a top-level
439
+ // navigation to a gated URL can carry a key ONLY in the query string (and a
440
+ // 401 there surfaces as Chromium's opaque `ERR_INVALID_AUTH_CREDENTIALS`,
441
+ // not a status code).
442
+ //
443
+ // The rest/graphql routes additionally `remove.querystring: [apikey]` in
444
+ // their request-transformer, because PostgREST reads every unknown query
445
+ // parameter as a column filter and answers `400 PGRST100` on the leftover
446
+ // key. `hide_credentials: true` on key-auth would strip it (and the header)
447
+ // far earlier — before this request-transformer runs — which would break
448
+ // both the header swap above and the realtime websocket route, whose own
449
+ // expression reads `query_params.apikey`. So the strip belongs here, on the
450
+ // one upstream that cannot tolerate the parameter.
451
+ //
452
+ // MEASURED CONSEQUENCE — request-transformer removes the query parameter
453
+ // BEFORE it renders the Authorization template, so on `/rest/v1` the JWT
454
+ // never reaches PostgREST and the request runs as **anon**, whichever key
455
+ // was sent. Verified against an RLS-protected table: the service-role key
456
+ // as a header returned the row, the same key in the query returned `[]`.
457
+ // Anon is the right privilege for the browser navigation this exists for;
458
+ // anything needing service-role must send the header.
459
+ function buildLuaExprs(
460
+ extra: Array<{ key: string; jwt: string }>,
461
+ ): { auth: string; realtimeWs: string } {
462
+ // Keep a client's `Authorization: Bearer <extraKey>` from winning the
463
+ // header-passthrough clause with the raw (non-JWT) key.
464
+ const guards = extra
465
+ .map((m) => ` and headers.authorization ~= 'Bearer ${m.key}'`)
466
+ .join("");
467
+ const mappings = extra
468
+ .map(
469
+ (m) =>
470
+ ` or ((headers.apikey == '${m.key}' or headers.authorization == 'Bearer ${m.key}' or query_params.apikey == '${m.key}') and 'Bearer ${m.jwt}')`,
471
+ )
472
+ .join("");
473
+ const auth = `$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_'${guards} and headers.authorization)${mappings} or headers.apikey or query_params.apikey)`;
474
+ const wsMappings = extra
475
+ .map((m) => `(query_params.apikey == '${m.key}' and '${m.jwt}') or `)
476
+ .join("");
477
+ return { auth, realtimeWs: `$(${wsMappings}query_params.apikey)` };
478
+ }
479
+
480
+ const KONG_YML_RAW = String.raw`_format_version: '2.1'
481
+ _transform: true
482
+
483
+ consumers:
484
+ - username: DASHBOARD
485
+ - username: anon
486
+ keyauth_credentials:
487
+ - key: $SUPABASE_ANON_KEY
488
+ - key: $SUPABASE_PUBLISHABLE_KEY
489
+ - username: service_role
490
+ keyauth_credentials:
491
+ - key: $SUPABASE_SERVICE_KEY
492
+ - key: $SUPABASE_SECRET_KEY
493
+
494
+ acls:
495
+ - consumer: anon
496
+ group: anon
497
+ - consumer: service_role
498
+ group: admin
499
+
500
+ basicauth_credentials:
501
+ - consumer: DASHBOARD
502
+ username: '$DASHBOARD_USERNAME'
503
+ password: '$DASHBOARD_PASSWORD'
504
+
505
+ services:
506
+ - name: auth-v1-open
507
+ url: http://auth:9999/verify
508
+ routes:
509
+ - name: auth-v1-open
510
+ strip_path: true
511
+ paths:
512
+ - /auth/v1/verify
513
+ plugins:
514
+ - name: cors
515
+ - name: auth-v1-open-callback
516
+ url: http://auth:9999/callback
517
+ routes:
518
+ - name: auth-v1-open-callback
519
+ strip_path: true
520
+ paths:
521
+ - /auth/v1/callback
522
+ plugins:
523
+ - name: cors
524
+ - name: auth-v1-open-authorize
525
+ url: http://auth:9999/authorize
526
+ routes:
527
+ - name: auth-v1-open-authorize
528
+ strip_path: true
529
+ paths:
530
+ - /auth/v1/authorize
531
+ plugins:
532
+ - name: cors
533
+ - name: auth-v1-open-jwks
534
+ url: http://auth:9999/.well-known/jwks.json
535
+ routes:
536
+ - name: auth-v1-open-jwks
537
+ strip_path: true
538
+ paths:
539
+ - /auth/v1/.well-known/jwks.json
540
+ plugins:
541
+ - name: cors
542
+ - name: auth-v1-open-sso-acs
543
+ url: "http://auth:9999/sso/saml/acs"
544
+ routes:
545
+ - name: auth-v1-open-sso-acs
546
+ strip_path: true
547
+ paths:
548
+ - /auth/v1/sso/saml/acs
549
+ plugins:
550
+ - name: cors
551
+ - name: auth-v1-open-sso-metadata
552
+ url: "http://auth:9999/sso/saml/metadata"
553
+ routes:
554
+ - name: auth-v1-open-sso-metadata
555
+ strip_path: true
556
+ paths:
557
+ - /auth/v1/sso/saml/metadata
558
+ plugins:
559
+ - name: cors
560
+ - name: auth-v1
561
+ url: http://auth:9999/
562
+ routes:
563
+ - name: auth-v1-all
564
+ strip_path: true
565
+ paths:
566
+ - /auth/v1/
567
+ plugins:
568
+ - name: cors
569
+ - name: key-auth
570
+ config:
571
+ hide_credentials: false
572
+ - name: request-transformer
573
+ config:
574
+ add:
575
+ headers:
576
+ - "Authorization: $LUA_AUTH_EXPR"
577
+ replace:
578
+ headers:
579
+ - "Authorization: $LUA_AUTH_EXPR"
580
+ - name: acl
581
+ config:
582
+ hide_groups_header: true
583
+ allow:
584
+ - admin
585
+ - anon
586
+ - name: rest-v1-openapi
587
+ url: http://rest:3000/
588
+ routes:
589
+ - name: rest-v1-openapi-root
590
+ strip_path: true
591
+ expression: 'http.path == "/rest/v1/"'
592
+ plugins:
593
+ - name: cors
594
+ - name: key-auth
595
+ config:
596
+ hide_credentials: false
597
+ - name: request-transformer
598
+ config:
599
+ add:
600
+ headers:
601
+ - "Authorization: $LUA_AUTH_EXPR"
602
+ replace:
603
+ headers:
604
+ - "Authorization: $LUA_AUTH_EXPR"
605
+ remove:
606
+ querystring:
607
+ - apikey
608
+ - name: acl
609
+ config:
610
+ hide_groups_header: true
611
+ allow:
612
+ - admin
613
+ - name: rest-v1
614
+ url: http://rest:3000/
615
+ routes:
616
+ - name: rest-v1-all
617
+ strip_path: true
618
+ paths:
619
+ - /rest/v1/
620
+ plugins:
621
+ - name: cors
622
+ - name: key-auth
623
+ config:
624
+ hide_credentials: false
625
+ - name: request-transformer
626
+ config:
627
+ add:
628
+ headers:
629
+ - "Authorization: $LUA_AUTH_EXPR"
630
+ replace:
631
+ headers:
632
+ - "Authorization: $LUA_AUTH_EXPR"
633
+ remove:
634
+ querystring:
635
+ - apikey
636
+ - name: acl
637
+ config:
638
+ hide_groups_header: true
639
+ allow:
640
+ - admin
641
+ - anon
642
+ - name: graphql-v1
643
+ url: http://rest:3000/rpc/graphql
644
+ routes:
645
+ - name: graphql-v1-all
646
+ strip_path: true
647
+ paths:
648
+ - /graphql/v1
649
+ plugins:
650
+ - name: cors
651
+ - name: key-auth
652
+ config:
653
+ hide_credentials: false
654
+ - name: request-transformer
655
+ config:
656
+ add:
657
+ headers:
658
+ - "Content-Profile: graphql_public"
659
+ - "Authorization: $LUA_AUTH_EXPR"
660
+ replace:
661
+ headers:
662
+ - "Authorization: $LUA_AUTH_EXPR"
663
+ remove:
664
+ querystring:
665
+ - apikey
666
+ - name: acl
667
+ config:
668
+ hide_groups_header: true
669
+ allow:
670
+ - admin
671
+ - anon
672
+ - name: realtime-v1-ws
673
+ url: http://realtime-dev.supabase-realtime:4000/socket
674
+ protocol: ws
675
+ routes:
676
+ - name: realtime-v1-ws
677
+ strip_path: true
678
+ paths:
679
+ - /realtime/v1/
680
+ plugins:
681
+ - name: cors
682
+ - name: key-auth
683
+ config:
684
+ hide_credentials: false
685
+ - name: request-transformer
686
+ config:
687
+ add:
688
+ headers:
689
+ - "x-api-key:$LUA_RT_WS_EXPR"
690
+ replace:
691
+ querystring:
692
+ - "apikey:$LUA_RT_WS_EXPR"
693
+ - name: acl
694
+ config:
695
+ hide_groups_header: true
696
+ allow:
697
+ - admin
698
+ - anon
699
+ - name: realtime-v1-rest-openapi
700
+ url: http://realtime-dev.supabase-realtime:4000/api/openapi
701
+ protocol: http
702
+ routes:
703
+ - name: realtime-v1-rest-openapi
704
+ strip_path: true
705
+ paths:
706
+ - /realtime/v1/api/openapi
707
+ plugins:
708
+ - name: request-termination
709
+ config:
710
+ status_code: 403
711
+ message: "Access is forbidden."
712
+ - name: realtime-v1-rest-tenants
713
+ url: http://realtime-dev.supabase-realtime:4000/api/tenants
714
+ protocol: http
715
+ routes:
716
+ - name: realtime-v1-rest-tenants
717
+ strip_path: true
718
+ paths:
719
+ - /realtime/v1/api/tenants
720
+ plugins:
721
+ - name: request-termination
722
+ config:
723
+ status_code: 403
724
+ message: "Access is forbidden."
725
+ - name: realtime-v1-rest
726
+ url: http://realtime-dev.supabase-realtime:4000/api
727
+ protocol: http
728
+ routes:
729
+ - name: realtime-v1-rest
730
+ strip_path: true
731
+ paths:
732
+ - /realtime/v1/api
733
+ plugins:
734
+ - name: cors
735
+ - name: key-auth
736
+ config:
737
+ hide_credentials: false
738
+ - name: request-transformer
739
+ config:
740
+ add:
741
+ headers:
742
+ - "Authorization: $LUA_AUTH_EXPR"
743
+ replace:
744
+ headers:
745
+ - "Authorization: $LUA_AUTH_EXPR"
746
+ - name: acl
747
+ config:
748
+ hide_groups_header: true
749
+ allow:
750
+ - admin
751
+ - anon
752
+ - name: storage-v1
753
+ url: http://storage:5000/
754
+ routes:
755
+ - name: storage-v1-all
756
+ strip_path: true
757
+ paths:
758
+ - /storage/v1/
759
+ plugins:
760
+ - name: cors
761
+ - name: request-transformer
762
+ config:
763
+ add:
764
+ headers:
765
+ - "Authorization: $LUA_AUTH_EXPR"
766
+ replace:
767
+ headers:
768
+ - "Authorization: $LUA_AUTH_EXPR"
769
+ - name: post-function
770
+ config:
771
+ access:
772
+ - |
773
+ local auth = kong.request.get_header("authorization")
774
+ if auth == nil or auth == "" or auth:find("^%s*$") then
775
+ kong.service.request.clear_header("authorization")
776
+ end
777
+ # >>> functions (dropped when the edge runtime is off)
778
+ - name: functions-v1
779
+ url: http://functions:9000/
780
+ read_timeout: 150000
781
+ routes:
782
+ - name: functions-v1-all
783
+ strip_path: true
784
+ paths:
785
+ - /functions/v1/
786
+ plugins:
787
+ - name: cors
788
+ # <<< functions
789
+ - name: well-known-oauth
790
+ url: http://auth:9999/.well-known/oauth-authorization-server
791
+ routes:
792
+ - name: well-known-oauth
793
+ strip_path: true
794
+ paths:
795
+ - /.well-known/oauth-authorization-server
796
+ plugins:
797
+ - name: cors
798
+ - name: meta
799
+ url: http://meta:8080/
800
+ routes:
801
+ - name: meta-all
802
+ strip_path: true
803
+ paths:
804
+ - /pg/
805
+ plugins:
806
+ - name: key-auth
807
+ config:
808
+ hide_credentials: false
809
+ - name: acl
810
+ config:
811
+ hide_groups_header: true
812
+ allow:
813
+ - admin
814
+ - name: dashboard
815
+ url: http://studio:3000/
816
+ routes:
817
+ - name: dashboard-all
818
+ strip_path: true
819
+ paths:
820
+ - /
821
+ plugins:
822
+ - name: cors
823
+ - name: basic-auth
824
+ config:
825
+ hide_credentials: true
826
+ `;
827
+
828
+ // ──────────────────────────────────────────────────────────────────────────
829
+ // Edge functions — the runtime's "main service": the request router that
830
+ // spawns one user worker per function directory.
831
+ //
832
+ // Upstream ships an equivalent file in the repo the user is expected to copy
833
+ // (`docker/volumes/functions/main/index.ts`). We inject our own instead, for
834
+ // two reasons: the project's `supabase/functions/` holds the user's
835
+ // functions and should not need a `main/` of ours in it; and upstream's
836
+ // router imports `jsr:@panva/jose` at boot, which is a network fetch inside
837
+ // an otherwise hermetic VM. HS256 is the only algorithm this stack signs
838
+ // with, and Web Crypto verifies it in a few lines with no dependency.
839
+ // ──────────────────────────────────────────────────────────────────────────
840
+
841
+ const FUNCTIONS_MAIN_TS = String.raw`// spectest-generated edge-functions router. Do not edit.
842
+ const FUNCTIONS_DIR = "/home/deno/functions";
843
+ const JWT_SECRET = Deno.env.get("JWT_SECRET") ?? "";
844
+ const VERIFY_JWT = Deno.env.get("VERIFY_JWT") === "true";
845
+
846
+ function json(body: unknown, status: number): Response {
847
+ return new Response(JSON.stringify(body), {
848
+ status,
849
+ headers: { "content-type": "application/json" },
850
+ });
851
+ }
852
+
853
+ function base64UrlToBytes(s: string): Uint8Array {
854
+ const padded = s.replace(/-/g, "+").replace(/_/g, "/");
855
+ const bin = atob(padded + "=".repeat((4 - (padded.length % 4)) % 4));
856
+ const out = new Uint8Array(bin.length);
857
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
858
+ return out;
859
+ }
860
+
861
+ // HS256 only — every token this stack mints (the anon/service-role API keys
862
+ // and every GoTrue access token) is signed with the shared JWT secret.
863
+ async function validJwt(token: string): Promise<boolean> {
864
+ const parts = token.split(".");
865
+ if (parts.length !== 3 || !JWT_SECRET) return false;
866
+ try {
867
+ const key = await crypto.subtle.importKey(
868
+ "raw",
869
+ new TextEncoder().encode(JWT_SECRET),
870
+ { name: "HMAC", hash: "SHA-256" },
871
+ false,
872
+ ["verify"],
873
+ );
874
+ const signed = new TextEncoder().encode(parts[0] + "." + parts[1]);
875
+ if (!(await crypto.subtle.verify("HMAC", key, base64UrlToBytes(parts[2]), signed))) {
876
+ return false;
877
+ }
878
+ const claims = JSON.parse(new TextDecoder().decode(base64UrlToBytes(parts[1])));
879
+ if (typeof claims.exp === "number" && claims.exp * 1000 < Date.now()) return false;
880
+ return true;
881
+ } catch {
882
+ return false;
883
+ }
884
+ }
885
+
886
+ Deno.serve(async (req: Request) => {
887
+ const { pathname } = new URL(req.url);
888
+
889
+ // Ready probe — answered before any auth check, so bring-up never needs a
890
+ // token, and never treated as a function name.
891
+ if (pathname === "/_spectest/health") return new Response("ok");
892
+
893
+ const name = pathname.split("/")[1] ?? "";
894
+ if (name === "") return json({ msg: "missing function name in request" }, 400);
895
+
896
+ if (VERIFY_JWT && req.method !== "OPTIONS") {
897
+ const header = req.headers.get("authorization") ?? "";
898
+ const [scheme, token] = header.split(" ");
899
+ if (scheme !== "Bearer" || !token || !(await validJwt(token))) {
900
+ return json({ msg: "Invalid JWT" }, 401);
901
+ }
902
+ }
903
+
904
+ const servicePath = FUNCTIONS_DIR + "/" + name;
905
+ try {
906
+ const worker = await EdgeRuntime.userWorkers.create({
907
+ servicePath,
908
+ memoryLimitMb: 256,
909
+ workerTimeoutMs: 5 * 60 * 1000,
910
+ noModuleCache: false,
911
+ importMapPath: null,
912
+ envVars: Object.entries(Deno.env.toObject()),
913
+ });
914
+ return await worker.fetch(req);
915
+ } catch (e) {
916
+ // A missing directory lands here too, so say which function was asked
917
+ // for — the runtime's own message names only the path.
918
+ return json({ msg: String(e), function: name, servicePath }, 500);
919
+ }
920
+ });
921
+ `;
922
+
923
+ /** Keep or drop a `# >>> <tag>` … `# <<< <tag>` block, and always remove the
924
+ * marker lines themselves. */
925
+ function stripMarkedBlock(yaml: string, tag: string, keep: boolean): string {
926
+ const out: string[] = [];
927
+ let inside = false;
928
+ for (const line of yaml.split("\n")) {
929
+ if (line.trim().startsWith(`# >>> ${tag}`)) {
930
+ inside = true;
931
+ continue;
932
+ }
933
+ if (line.trim().startsWith(`# <<< ${tag}`)) {
934
+ inside = false;
935
+ continue;
936
+ }
937
+ if (inside && !keep) continue;
938
+ out.push(line);
939
+ }
940
+ return out.join("\n");
941
+ }
942
+
943
+ /** Produce the final Kong declarative config: rewrite upstream bare hostnames
944
+ * to the group's final service DNS names, substitute the API keys / dashboard
945
+ * credentials / Lua expressions, and drop the empty opaque-key lines. */
946
+ function buildKongYaml(
947
+ key: (part: string) => string,
948
+ vals: {
949
+ anonKey: string;
950
+ serviceRoleKey: string;
951
+ dashboardUsername: string;
952
+ dashboardPassword: string;
953
+ extraAnonKeys: string[];
954
+ extraServiceRoleKeys: string[];
955
+ /** Is the edge runtime part of this stack? When it isn't, the
956
+ * `/functions/v1` route is dropped rather than left pointing at a
957
+ * service that doesn't exist: a call then falls through to the
958
+ * gateway's catch-all and is rejected there (measured: 401), instead
959
+ * of proxying to an unresolvable host. */
960
+ functions: boolean;
961
+ },
962
+ ): string {
963
+ let y = KONG_YML_RAW;
964
+ y = stripMarkedBlock(y, "functions", vals.functions);
965
+ // Extra API keys land where kong-entrypoint.sh's opaque keys would: as
966
+ // additional keyauth credentials on the matching consumer. The template's
967
+ // placeholder lines are either expanded here or dropped by the filter below.
968
+ if (vals.extraAnonKeys.length > 0) {
969
+ y = y.replace(
970
+ " - key: $SUPABASE_PUBLISHABLE_KEY",
971
+ vals.extraAnonKeys.map((k) => ` - key: ${k}`).join("\n"),
972
+ );
973
+ }
974
+ if (vals.extraServiceRoleKeys.length > 0) {
975
+ y = y.replace(
976
+ " - key: $SUPABASE_SECRET_KEY",
977
+ vals.extraServiceRoleKeys.map((k) => ` - key: ${k}`).join("\n"),
978
+ );
979
+ }
980
+ // Rewrite upstream bare service hosts → the group's final DNS names.
981
+ y = y
982
+ .replaceAll("http://auth:9999", `http://${key("auth")}:9999`)
983
+ .replaceAll("http://rest:3000", `http://${key("rest")}:3000`)
984
+ .replaceAll("http://storage:5000", `http://${key("storage")}:5000`)
985
+ .replaceAll("http://meta:8080", `http://${key("meta")}:8080`)
986
+ .replaceAll("http://studio:3000", `http://${key("studio")}:3000`)
987
+ .replaceAll("http://functions:9000", `http://${key("functions")}:9000`)
988
+ .replaceAll("realtime-dev.supabase-realtime", `realtime-dev.${key("realtime")}`);
989
+ // Lua expressions first (they contain no `$SUPABASE_*` tokens).
990
+ const lua = buildLuaExprs([
991
+ ...vals.extraAnonKeys.map((k) => ({ key: k, jwt: vals.anonKey })),
992
+ ...vals.extraServiceRoleKeys.map((k) => ({ key: k, jwt: vals.serviceRoleKey })),
993
+ ]);
994
+ y = y.replaceAll("$LUA_AUTH_EXPR", lua.auth).replaceAll(
995
+ "$LUA_RT_WS_EXPR",
996
+ lua.realtimeWs,
997
+ );
998
+ // API keys + dashboard credentials.
999
+ y = y
1000
+ .replaceAll("$SUPABASE_ANON_KEY", vals.anonKey)
1001
+ .replaceAll("$SUPABASE_SERVICE_KEY", vals.serviceRoleKey)
1002
+ .replaceAll("$DASHBOARD_USERNAME", vals.dashboardUsername)
1003
+ .replaceAll("$DASHBOARD_PASSWORD", vals.dashboardPassword);
1004
+ // Drop the unconfigured opaque-key credential lines (kong-entrypoint.sh does
1005
+ // the same with a `sed` after substitution).
1006
+ y = y
1007
+ .split("\n")
1008
+ .filter(
1009
+ (line) =>
1010
+ !/^\s*- key:\s*\$SUPABASE_PUBLISHABLE_KEY\s*$/.test(line) &&
1011
+ !/^\s*- key:\s*\$SUPABASE_SECRET_KEY\s*$/.test(line),
1012
+ )
1013
+ .join("\n");
1014
+ return y;
1015
+ }
1016
+
1017
+ // ──────────────────────────────────────────────────────────────────────────
1018
+ // Migration application. Runs in the **group's** `setup`, which chains after
1019
+ // the primary (the gateway), and the gateway is the group's dependency sink
1020
+ // — so by then every service is up and has finished its own bootstrap. That
1021
+ // ordering is the whole point:
1022
+ //
1023
+ // Several services own schema of their own and create it when THEY boot,
1024
+ // not when the database does. storage-api creates `storage.buckets` /
1025
+ // `storage.objects`; GoTrue creates the modern `auth.*` tables
1026
+ // (`identities`, `sessions`, `mfa_*`, `one_time_tokens`); realtime creates
1027
+ // its own. The stock `supabase/postgres` image ships only the legacy auth
1028
+ // stubs (`auth.users` and friends) and nothing at all under `storage`.
1029
+ //
1030
+ // Applied any earlier, an ordinary migration — `insert into
1031
+ // storage.buckets`, a policy on `auth.identities` — fails with `relation
1032
+ // … does not exist`, and the error points at the user's SQL rather than at
1033
+ // the ordering. This is also the point at which `supabase db reset`
1034
+ // applies migrations: against a stack that is already running.
1035
+ //
1036
+ // The database's OWN bootstrap (roles, JWT GUC, `_realtime` schema) stays in
1037
+ // the db service's `setup`, because the other services need it to start at
1038
+ // all.
1039
+ //
1040
+ // Uses the setup context's `exec` (docker exec with piped stdin) and
1041
+ // `projectRoot` — no hand-rolled child_process, no hard-coded workspace path.
1042
+ // ──────────────────────────────────────────────────────────────────────────
1043
+
1044
+ /** Either setup hook's context: the db part's (no helpers) or the group's
1045
+ * (the consolidated handle). Nothing below reads `helpers`. */
1046
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1047
+ type SetupCtx = ServiceSetupContext<any>;
1048
+
1049
+ /** Pipe one SQL file's contents into `psql` inside the db container. Uses
1050
+ * `psql -f -` (stdin) so multi-statement files and non-transactional DDL
1051
+ * (e.g. `CREATE INDEX CONCURRENTLY`) behave exactly as `supabase db reset`
1052
+ * applies them — not wrapped in a single transaction. `ON_ERROR_STOP=1` fails
1053
+ * fast on the first bad statement. */
1054
+ async function applySqlFile(
1055
+ ctx: SetupCtx,
1056
+ sql: string,
1057
+ label: string,
1058
+ user: string,
1059
+ /** The db service to run `psql` in. Defaults to the hook's own service,
1060
+ * which is the db only for the bootstrap; the project's migrations run
1061
+ * from the group hook, where `ctx.name` is the gateway. */
1062
+ service: string = ctx.name,
1063
+ ): Promise<void> {
1064
+ const res = await ctx.exec(
1065
+ service,
1066
+ ["psql", "-v", "ON_ERROR_STOP=1", "-U", user, "-d", "postgres", "-f", "-"],
1067
+ { stdin: sql, timeoutMs: 300_000 },
1068
+ );
1069
+ if (res.exitCode !== 0) {
1070
+ throw new Error(
1071
+ `supabase: applying ${label} failed (psql rc=${res.exitCode}):\n${
1072
+ res.stderr.trim() || res.stdout.trim()
1073
+ }`,
1074
+ );
1075
+ }
1076
+ }
1077
+
1078
+ /** Resolve, sort, and apply the project's Supabase migrations — and the
1079
+ * `seed.sql` the standard layout puts beside them — against the running db
1080
+ * container as the `postgres` role (matching `supabase db reset`: tables end
1081
+ * up owned by postgres).
1082
+ *
1083
+ * Only ever called with a path the user gave: there is no assumed location,
1084
+ * so this never applies SQL nobody asked for. */
1085
+ async function applyMigrations(
1086
+ ctx: SetupCtx,
1087
+ migrations: string,
1088
+ /** Where `psql` runs — the db part, named from the group hook. */
1089
+ db: string,
1090
+ ): Promise<void> {
1091
+ const dir = migrations.startsWith("/")
1092
+ ? migrations
1093
+ : join(ctx.projectRoot, migrations);
1094
+ // A path that doesn't resolve is a hard error: a silent no-op here
1095
+ // surfaces much later as a baffling `relation "public.…" does not exist`.
1096
+ if (!existsSync(dir) || !(await stat(dir)).isDirectory()) {
1097
+ throw new Error(
1098
+ `supabase: migrations path ${JSON.stringify(migrations)} does not ` +
1099
+ `resolve to a directory (looked at ${dir}). The path is resolved ` +
1100
+ `inside the uploaded project — check it isn't ignored (e.g. via ` +
1101
+ `.spectestignore).`,
1102
+ );
1103
+ }
1104
+
1105
+ const files = (await readdir(dir)).filter((f) => f.endsWith(".sql")).sort();
1106
+ for (const f of files) {
1107
+ const sql = await ctx.readProjectFile(join(dir, f));
1108
+ await applySqlFile(ctx, sql, `migration ${f}`, "postgres", db);
1109
+ }
1110
+
1111
+ // `seed.sql` sits next to the migrations directory in the layout the
1112
+ // Supabase CLI creates, so it needs no option of its own — pointing at
1113
+ // the migrations is enough to say where the project's SQL lives. Absent
1114
+ // is fine: plenty of projects have migrations and no seed.
1115
+ const seedPath = join(dirname(dir), "seed.sql");
1116
+ if (existsSync(seedPath)) {
1117
+ const sql = await ctx.readProjectFile(seedPath);
1118
+ await applySqlFile(ctx, sql, "seed.sql", "postgres", db);
1119
+ }
1120
+ }
1121
+
1122
+ /**
1123
+ * Make PostgREST serve the schema the migrations just created, and do not
1124
+ * return until it does so **stably**.
1125
+ *
1126
+ * PostgREST reads the schema once, at boot, and now boots before the
1127
+ * project's migrations run. Without this, the first REST call for a new
1128
+ * table answers `PGRST205 Could not find the table` — the failure the user
1129
+ * would otherwise have to diagnose and work around themselves.
1130
+ *
1131
+ * Two things make this more than one request:
1132
+ *
1133
+ * - **A rebuild is not atomic from the outside.** While PostgREST reloads,
1134
+ * a request can still miss the table. So a single successful check is not
1135
+ * proof; the cache has to answer correctly several times in a row.
1136
+ * - **Reloads are already in flight.** The `supabase/postgres` image ships
1137
+ * `pgrst_ddl_watch`, an event trigger that notifies on every DDL, so a
1138
+ * migration run queues several reloads of its own. Ours goes *first* and
1139
+ * the settle loop then waits them all out — checking before sending the
1140
+ * notify was a real bug: the poll passed, our own notify landed after it,
1141
+ * and a test raced the rebuild it caused.
1142
+ *
1143
+ * The expectation comes from the database itself: every base table in the
1144
+ * exposed `public` schema should be listed. A stack with no public tables
1145
+ * has nothing to wait for.
1146
+ */
1147
+ async function reloadPostgrestSchema(
1148
+ ctx: SetupCtx,
1149
+ db: string,
1150
+ rest: string,
1151
+ ): Promise<void> {
1152
+ // Ours first, so every reload — ours and the DDL trigger's — is already
1153
+ // queued before we start watching for the result.
1154
+ await applySqlFile(ctx, "notify pgrst, 'reload schema';\n", "schema reload", "postgres", db);
1155
+
1156
+ const listed = await ctx.exec(
1157
+ db,
1158
+ [
1159
+ "psql", "-U", "postgres", "-d", "postgres", "-t", "-A", "-c",
1160
+ "select table_name from information_schema.tables " +
1161
+ "where table_schema = 'public' and table_type = 'BASE TABLE' order by 1",
1162
+ ],
1163
+ { timeoutMs: 60_000 },
1164
+ );
1165
+ const tables = String(listed.stdout)
1166
+ .split("\n")
1167
+ .map((t) => t.trim())
1168
+ .filter(Boolean);
1169
+ if (tables.length === 0) return;
1170
+
1171
+ /** One check: are all the tables served right now? */
1172
+ const served = async (): Promise<string[]> => {
1173
+ const res = await fetch(`http://${rest}:3000/`);
1174
+ if (!res.ok) return tables;
1175
+ const spec = (await res.json()) as { paths?: Record<string, unknown> };
1176
+ const paths = Object.keys(spec.paths ?? {});
1177
+ return tables.filter((t) => !paths.includes(`/${t}`));
1178
+ };
1179
+
1180
+ const SETTLE = 3;
1181
+ const deadline = Date.now() + 30_000;
1182
+ let streak = 0;
1183
+ let missing: string[] = tables;
1184
+ while (Date.now() < deadline) {
1185
+ try {
1186
+ missing = await served();
1187
+ streak = missing.length === 0 ? streak + 1 : 0;
1188
+ if (streak >= SETTLE) return;
1189
+ } catch {
1190
+ // Mid-reload PostgREST can refuse the connection outright.
1191
+ streak = 0;
1192
+ }
1193
+ await new Promise((r) => setTimeout(r, 150));
1194
+ }
1195
+ throw new Error(
1196
+ `supabase: PostgREST did not settle on the migrated schema within 30s ` +
1197
+ `(still missing: ${missing.join(", ") || "nothing, but not stably"}). ` +
1198
+ `The migrations applied — this is the REST schema cache, not your SQL.`,
1199
+ );
1200
+ }
1201
+
1202
+ /**
1203
+ * A ready-to-use self-hosted Supabase stack. Mount `.group` at the key
1204
+ * matching the `name` option (default `"supabase"`) and wire the app with
1205
+ * `.appEnv` / `.ready`:
1206
+ *
1207
+ * ```ts
1208
+ * const sb = supabase({ dbPassword: "postgres" });
1209
+ * services: { supabase: sb.group, app: { image, env: sb.appEnv, dependsOn: [sb.ready] } }
1210
+ * ```
1211
+ *
1212
+ * See the file header for the full picture. Tests get an instrumented DB client
1213
+ * at `ctx.svc.<name>.sql` plus `sb.url` / `sb.anonKey` / `sb.serviceRoleKey`
1214
+ * for hitting the REST/auth/storage APIs through the gateway.
1215
+ */
1216
+ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
1217
+ const name = opts.name ?? "supabase";
1218
+ const dbPassword = opts.dbPassword ?? "postgres";
1219
+ const jwtSecret = opts.jwtSecret ?? DEFAULT_JWT_SECRET;
1220
+ if (jwtSecret.length < 32) {
1221
+ throw new Error(
1222
+ `supabase(): jwtSecret must be at least 32 characters (got ${jwtSecret.length})`,
1223
+ );
1224
+ }
1225
+ const dashboardUsername = opts.dashboardUsername ?? "supabase";
1226
+ const dashboardPassword =
1227
+ opts.dashboardPassword ?? "this_password_is_insecure_and_should_be_updated";
1228
+
1229
+ const withAuth = opts.auth ?? true;
1230
+ const withStorage = opts.storage ?? true;
1231
+ const withRealtime = opts.realtime ?? true;
1232
+ const withStudio = opts.studio ?? false;
1233
+ const withMeta = opts.meta ?? withStudio;
1234
+
1235
+ // Mail capture (see SupabaseOptions.mail) — on unless explicitly disabled.
1236
+ // `mailKey` is the SMTP host GoTrue mails through — the in-group part
1237
+ // (`<name>-mail`) or a user-declared `email()` service shared with the app
1238
+ // under test.
1239
+ // Edge functions — like `migrations`, a path the caller gives or nothing
1240
+ // at all. A directory that doesn't resolve is an error rather than an
1241
+ // empty runtime whose every call 500s much later.
1242
+ const fnOpts: SupabaseFunctionsOptions | null =
1243
+ opts.functions === undefined
1244
+ ? null
1245
+ : typeof opts.functions === "string"
1246
+ ? { dir: opts.functions }
1247
+ : opts.functions;
1248
+ const withFunctions = fnOpts !== null;
1249
+ const fnDir =
1250
+ fnOpts === null
1251
+ ? ""
1252
+ : fnOpts.dir.startsWith("/")
1253
+ ? fnOpts.dir
1254
+ : join(projectRootPath(), fnOpts.dir);
1255
+ if (withFunctions && !existsSync(fnDir)) {
1256
+ throw new Error(
1257
+ `supabase(): functions path ${JSON.stringify(fnOpts!.dir)} does not ` +
1258
+ `resolve to a directory (looked at ${fnDir}). It should hold one ` +
1259
+ `subdirectory per function. The path is resolved inside the uploaded ` +
1260
+ `project — check it isn't ignored (e.g. via .spectestignore).`,
1261
+ );
1262
+ }
1263
+
1264
+ const withMail = opts.mail !== false;
1265
+ const mailOpts: SupabaseMailOptions =
1266
+ typeof opts.mail === "object" ? opts.mail : {};
1267
+ const mailExternal = mailOpts.service !== undefined;
1268
+ const mailKey = mailOpts.service ?? `${name}-mail`;
1269
+ const mailAutoconfirm = mailOpts.autoconfirm ?? true;
1270
+
1271
+ const extraAnonKeys = opts.extraApiKeys?.anon ?? [];
1272
+ const extraServiceRoleKeys = opts.extraApiKeys?.serviceRole ?? [];
1273
+ for (const k of [...extraAnonKeys, ...extraServiceRoleKeys]) {
1274
+ // The keys are spliced into Kong's YAML and its quoted Lua expressions —
1275
+ // restrict to the token charset real API keys use so neither can break.
1276
+ if (!/^[A-Za-z0-9._-]+$/.test(k)) {
1277
+ throw new Error(
1278
+ `supabase(): extraApiKeys entries must match [A-Za-z0-9._-]+ (got ${JSON.stringify(k)})`,
1279
+ );
1280
+ }
1281
+ }
1282
+
1283
+ const anonKey = apiKey("anon", jwtSecret);
1284
+ const serviceRoleKey = apiKey("service_role", jwtSecret);
1285
+
1286
+ // Derived surface, embedded in `appEnv` / `url` before the group expands.
1287
+ // `expectKey: name` on the group guarantees these names and the expanded
1288
+ // service keys agree (`g.key("db")` at expansion === `${name}-db` here).
1289
+ const dbKey = `${name}-db`;
1290
+ const url = opts.hostname ? `https://${opts.hostname}` : `http://${name}:8000`;
1291
+ // App-facing URL: the conventional `postgres` role (owns migrated tables).
1292
+ const dbUrl = `postgresql://postgres:${dbPassword}@${dbKey}:5432/postgres`;
1293
+ // The test-side `ctx.svc.<name>.sql` helper connects as the superuser
1294
+ // `supabase_admin` so it can inspect everything — public tables plus the
1295
+ // `auth`/`storage` schemas — regardless of ownership.
1296
+ const adminDbUrl = `postgresql://supabase_admin:${dbPassword}@${dbKey}:5432/postgres`;
1297
+
1298
+ const group = serviceGroup({
1299
+ // Kong: the group key itself is the gateway (`http://<name>:8000`), and
1300
+ // as the primary it's the group's sink — `dependsOn: [sb.ready]` from
1301
+ // outside waits for the whole stack.
1302
+ primary: "gateway",
1303
+ expectKey: name,
1304
+ services: (g) => {
1305
+ const db = g.key("db");
1306
+ // Realtime derives its tenant id from the host subdomain, so it must
1307
+ // be reachable at `realtime-dev.<realtime key>` — the host Kong
1308
+ // routes to.
1309
+ const realtimeHost = `realtime-dev.${g.key("realtime")}`;
1310
+ const parts: Record<string, ServiceConfig> = {};
1311
+
1312
+ // ── db ──────────────────────────────────────────────────────────
1313
+ const dbDef: ServiceDefinition = {
1314
+ image: { type: "registry", reference: IMAGES.db },
1315
+ // `args` (not `command`): keep the image's docker-entrypoint.sh so it
1316
+ // runs initdb + its baked init-scripts before starting.
1317
+ args: [
1318
+ "postgres",
1319
+ "-c",
1320
+ "config_file=/etc/postgresql/postgresql.conf",
1321
+ "-c",
1322
+ "log_min_messages=fatal",
1323
+ ],
1324
+ env: {
1325
+ POSTGRES_HOST: "/var/run/postgresql",
1326
+ PGPORT: "5432",
1327
+ POSTGRES_PORT: "5432",
1328
+ PGPASSWORD: dbPassword,
1329
+ POSTGRES_PASSWORD: dbPassword,
1330
+ PGDATABASE: "postgres",
1331
+ POSTGRES_DB: "postgres",
1332
+ JWT_SECRET: jwtSecret,
1333
+ JWT_EXP: "3600",
1334
+ },
1335
+ ports: [5432],
1336
+ // `pg_isready` succeeds only once the image finishes initdb + its
1337
+ // baked init-scripts and starts serving for real.
1338
+ readyCheck: { type: "exec", command: "pg_isready -U postgres -h 127.0.0.1 -p 5432", timeoutSecs: 180 },
1339
+ setup: async (ctx) => {
1340
+ // Only the database's own bootstrap (role passwords / JWT GUC /
1341
+ // `_realtime` schema) runs here: the other services need it before
1342
+ // they can start. The project's migrations run later, from the
1343
+ // group hook, once the whole stack is up — see the migration
1344
+ // section above for why.
1345
+ await applySqlFile(
1346
+ ctx,
1347
+ buildBootstrapSql(dbPassword, jwtSecret, withRealtime),
1348
+ "supabase bootstrap",
1349
+ // The bootstrap alters reserved roles (authenticator, …) and the
1350
+ // database — superuser-only. `postgres` is deliberately NOT a
1351
+ // superuser in modern supabase/postgres; `supabase_admin` is.
1352
+ "supabase_admin",
1353
+ );
1354
+ },
1355
+ };
1356
+ parts.db = dbDef;
1357
+
1358
+ // ── rest (PostgREST) ────────────────────────────────────────────
1359
+ parts.rest = {
1360
+ image: { type: "registry", reference: IMAGES.rest },
1361
+ env: {
1362
+ PGRST_DB_URI: `postgres://authenticator:${dbPassword}@${db}:5432/postgres`,
1363
+ PGRST_DB_SCHEMAS: "public,graphql_public",
1364
+ PGRST_DB_ANON_ROLE: "anon",
1365
+ PGRST_JWT_SECRET: jwtSecret,
1366
+ PGRST_DB_USE_LEGACY_GUCS: "false",
1367
+ PGRST_APP_SETTINGS_JWT_SECRET: jwtSecret,
1368
+ PGRST_APP_SETTINGS_JWT_EXP: "3600",
1369
+ PGRST_ADMIN_SERVER_PORT: "3001",
1370
+ PGRST_DB_MAX_ROWS: "1000",
1371
+ PGRST_DB_EXTRA_SEARCH_PATH: "public",
1372
+ },
1373
+ ports: [3000],
1374
+ dependsOn: ["db"],
1375
+ // PostgREST opens :3000 only after connecting to the DB and loading
1376
+ // the schema cache, so a TCP connect is a good ready signal.
1377
+ readyCheck: { type: "tcp", port: 3000, timeoutSecs: 120 },
1378
+ };
1379
+
1380
+ // ── mail (SMTP capture, the standard email() component) ─────────
1381
+ if (withMail && !mailExternal) {
1382
+ parts.mail = email();
1383
+ }
1384
+
1385
+ // ── auth (GoTrue) ───────────────────────────────────────────────
1386
+ if (withAuth) {
1387
+ const apiExternalUrl = `${url}/auth/v1`;
1388
+ parts.auth = {
1389
+ image: { type: "registry", reference: IMAGES.auth },
1390
+ env: {
1391
+ GOTRUE_API_HOST: "0.0.0.0",
1392
+ GOTRUE_API_PORT: "9999",
1393
+ API_EXTERNAL_URL: apiExternalUrl,
1394
+ GOTRUE_DB_DRIVER: "postgres",
1395
+ GOTRUE_DB_DATABASE_URL: `postgres://supabase_auth_admin:${dbPassword}@${db}:5432/postgres`,
1396
+ GOTRUE_SITE_URL: url,
1397
+ GOTRUE_URI_ALLOW_LIST: "*",
1398
+ GOTRUE_DISABLE_SIGNUP: "false",
1399
+ GOTRUE_JWT_ADMIN_ROLES: "service_role",
1400
+ GOTRUE_JWT_AUD: "authenticated",
1401
+ GOTRUE_JWT_DEFAULT_GROUP_NAME: "authenticated",
1402
+ GOTRUE_JWT_EXP: "3600",
1403
+ GOTRUE_JWT_SECRET: jwtSecret,
1404
+ GOTRUE_JWT_ISSUER: apiExternalUrl,
1405
+ GOTRUE_EXTERNAL_EMAIL_ENABLED: "true",
1406
+ GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: "true",
1407
+ // Autoconfirm is ON by default: a signup completes in one step
1408
+ // and sends no confirmation mail, so the always-on mailbox is
1409
+ // purely additive and a suite that only needs accounts to exist
1410
+ // writes nothing. `autoconfirm: false` opts into the real
1411
+ // confirmation flow, which the mailbox is there to drive.
1412
+ //
1413
+ // Always ON when there is no mailbox (`mail: false`), whatever
1414
+ // was asked for: the confirmation would be sent nowhere and no
1415
+ // account could ever be used.
1416
+ GOTRUE_MAILER_AUTOCONFIRM:
1417
+ !withMail || mailAutoconfirm ? "true" : "false",
1418
+ GOTRUE_EXTERNAL_PHONE_ENABLED: "false",
1419
+ GOTRUE_SMS_AUTOCONFIRM: "true",
1420
+ ...(withMail
1421
+ ? {
1422
+ GOTRUE_SMTP_HOST: mailKey,
1423
+ GOTRUE_SMTP_PORT: "1025",
1424
+ // Deliberately NO GOTRUE_SMTP_USER/PASS: the capture
1425
+ // server advertises AUTH, and with credentials set
1426
+ // GoTrue's Go smtp.PlainAuth refuses to send them over a
1427
+ // plaintext connection ("unencrypted connection" → every
1428
+ // mail 500s). Unauthenticated submission is accepted.
1429
+ GOTRUE_SMTP_ADMIN_EMAIL:
1430
+ mailOpts.adminEmail ?? "admin@example.com",
1431
+ GOTRUE_SMTP_SENDER_NAME: mailOpts.senderName ?? "Supabase",
1432
+ // GoTrue rate-limits repeat mail to the same address
1433
+ // (default 1/min) — far too slow for tests that
1434
+ // request an OTP, assert, and request again.
1435
+ GOTRUE_SMTP_MAX_FREQUENCY: "1s",
1436
+ GOTRUE_MAILER_URLPATHS_INVITE: "/auth/v1/verify",
1437
+ GOTRUE_MAILER_URLPATHS_CONFIRMATION: "/auth/v1/verify",
1438
+ GOTRUE_MAILER_URLPATHS_RECOVERY: "/auth/v1/verify",
1439
+ GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: "/auth/v1/verify",
1440
+ }
1441
+ : {}),
1442
+ },
1443
+ ports: [9999],
1444
+ dependsOn: ["db", ...(withMail && !mailExternal ? ["mail"] : [])],
1445
+ readyCheck: { type: "http", port: 9999, path: "/health", timeoutSecs: 90 },
1446
+ };
1447
+ }
1448
+
1449
+ // ── storage (+ imgproxy) ────────────────────────────────────────
1450
+ if (withStorage) {
1451
+ // Named shared volume so imgproxy can read what storage-api wrote —
1452
+ // one backing dir mounted by both containers, snapshot/fork- and
1453
+ // teardown-managed like any other volume.
1454
+ const storageData = { name: `${g.name}-storage-data`, target: "/var/lib/storage" };
1455
+ parts.imgproxy = {
1456
+ image: { type: "registry", reference: IMAGES.imgproxy },
1457
+ env: {
1458
+ IMGPROXY_BIND: ":5001",
1459
+ IMGPROXY_LOCAL_FILESYSTEM_ROOT: "/",
1460
+ IMGPROXY_USE_ETAG: "true",
1461
+ IMGPROXY_AUTO_WEBP: "true",
1462
+ IMGPROXY_MAX_SRC_RESOLUTION: "16.8",
1463
+ },
1464
+ volumes: [storageData],
1465
+ ports: [5001],
1466
+ readyCheck: { type: "tcp", port: 5001, timeoutSecs: 60 },
1467
+ };
1468
+ parts.storage = {
1469
+ image: { type: "registry", reference: IMAGES.storage },
1470
+ env: {
1471
+ ANON_KEY: anonKey,
1472
+ SERVICE_KEY: serviceRoleKey,
1473
+ POSTGREST_URL: `http://${g.key("rest")}:3000`,
1474
+ AUTH_JWT_SECRET: jwtSecret,
1475
+ DATABASE_URL: `postgres://supabase_storage_admin:${dbPassword}@${db}:5432/postgres`,
1476
+ STORAGE_PUBLIC_URL: url,
1477
+ REQUEST_ALLOW_X_FORWARDED_PATH: "true",
1478
+ FILE_SIZE_LIMIT: "52428800",
1479
+ STORAGE_BACKEND: "file",
1480
+ GLOBAL_S3_BUCKET: "stub",
1481
+ FILE_STORAGE_BACKEND_PATH: "/var/lib/storage",
1482
+ TENANT_ID: "stub",
1483
+ REGION: "stub",
1484
+ ENABLE_IMAGE_TRANSFORMATION: "true",
1485
+ IMGPROXY_URL: `http://${g.key("imgproxy")}:5001`,
1486
+ S3_PROTOCOL_ACCESS_KEY_ID: "625729a08b95bf1b7ff351a663f3a23c",
1487
+ S3_PROTOCOL_ACCESS_KEY_SECRET:
1488
+ "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907",
1489
+ },
1490
+ volumes: [storageData],
1491
+ ports: [5000],
1492
+ dependsOn: ["db", "rest", "imgproxy"],
1493
+ readyCheck: { type: "http", port: 5000, path: "/status", timeoutSecs: 90 },
1494
+ };
1495
+ }
1496
+
1497
+ // ── realtime ────────────────────────────────────────────────────
1498
+ if (withRealtime) {
1499
+ parts.realtime = {
1500
+ image: { type: "registry", reference: IMAGES.realtime },
1501
+ // Extra peer alias so Kong (and the tenant-subdomain parser)
1502
+ // reaches it at `realtime-dev.<realtime key>`.
1503
+ hostnames: [realtimeHost],
1504
+ env: {
1505
+ PORT: "4000",
1506
+ DB_HOST: db,
1507
+ DB_PORT: "5432",
1508
+ DB_USER: "supabase_admin",
1509
+ DB_PASSWORD: dbPassword,
1510
+ DB_NAME: "postgres",
1511
+ DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime",
1512
+ DB_ENC_KEY: "supabaserealtime",
1513
+ API_JWT_SECRET: jwtSecret,
1514
+ METRICS_JWT_SECRET: jwtSecret,
1515
+ SECRET_KEY_BASE:
1516
+ "UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq",
1517
+ ERL_AFLAGS: "-proto_dist inet_tcp",
1518
+ DNS_NODES: "''",
1519
+ RLIMIT_NOFILE: "10000",
1520
+ APP_NAME: "realtime",
1521
+ SEED_SELF_HOST: "true",
1522
+ RUN_JANITOR: "true",
1523
+ DISABLE_HEALTHCHECK_LOGGING: "true",
1524
+ },
1525
+ ports: [4000],
1526
+ dependsOn: ["db"],
1527
+ // The health endpoint wants a valid JWT — `headers` keeps the
1528
+ // probe image-agnostic (no curl-in-image dependency).
1529
+ readyCheck: {
1530
+ type: "http",
1531
+ port: 4000,
1532
+ path: "/api/tenants/realtime-dev/health",
1533
+ headers: { Authorization: `Bearer ${anonKey}` },
1534
+ timeoutSecs: 120,
1535
+ },
1536
+ };
1537
+ }
1538
+
1539
+ // ── functions (edge runtime) ────────────────────────────────────
1540
+ if (withFunctions) {
1541
+ parts.functions = {
1542
+ image: { type: "registry", reference: IMAGES.functions },
1543
+ // The repo's own function directory, mounted where the runtime
1544
+ // looks. Read-only: the runtime only ever reads the sources, and
1545
+ // a function that writes into its own directory would be writing
1546
+ // into the user's checkout.
1547
+ volumes: [{ source: fnDir, target: "/home/deno/functions", readOnly: true }],
1548
+ // Our router (see FUNCTIONS_MAIN_TS) sits outside that mount, so
1549
+ // the project never has to carry a `main/` function of ours.
1550
+ files: [{ path: "/home/deno/main/index.ts", content: FUNCTIONS_MAIN_TS }],
1551
+ args: ["start", "--main-service", "/home/deno/main", "--port", "9000"],
1552
+ env: {
1553
+ JWT_SECRET: jwtSecret,
1554
+ VERIFY_JWT: (fnOpts!.verifyJwt ?? true) ? "true" : "false",
1555
+ // A function calls back into its own project through the
1556
+ // gateway, exactly as it does when hosted.
1557
+ SUPABASE_URL: `http://${g.name}:8000`,
1558
+ SUPABASE_PUBLIC_URL: url,
1559
+ SUPABASE_ANON_KEY: anonKey,
1560
+ SUPABASE_SERVICE_ROLE_KEY: serviceRoleKey,
1561
+ SUPABASE_DB_URL: `postgresql://postgres:${dbPassword}@${db}:5432/postgres`,
1562
+ ...(fnOpts!.env ?? {}),
1563
+ },
1564
+ ports: [9000],
1565
+ dependsOn: ["db"],
1566
+ readyCheck: {
1567
+ type: "http",
1568
+ port: 9000,
1569
+ path: "/_spectest/health",
1570
+ timeoutSecs: 120,
1571
+ },
1572
+ };
1573
+ }
1574
+
1575
+ // ── meta (postgres-meta) ────────────────────────────────────────
1576
+ if (withMeta) {
1577
+ parts.meta = {
1578
+ image: { type: "registry", reference: IMAGES.meta },
1579
+ env: {
1580
+ PG_META_PORT: "8080",
1581
+ PG_META_DB_HOST: db,
1582
+ PG_META_DB_PORT: "5432",
1583
+ PG_META_DB_NAME: "postgres",
1584
+ PG_META_DB_USER: "postgres",
1585
+ PG_META_DB_PASSWORD: dbPassword,
1586
+ CRYPTO_KEY: "your-encryption-key-32-chars-min",
1587
+ },
1588
+ ports: [8080],
1589
+ dependsOn: ["db"],
1590
+ readyCheck: { type: "tcp", port: 8080, timeoutSecs: 90 },
1591
+ };
1592
+ }
1593
+
1594
+ // ── studio ──────────────────────────────────────────────────────
1595
+ if (withStudio) {
1596
+ parts.studio = {
1597
+ image: { type: "registry", reference: IMAGES.studio },
1598
+ env: {
1599
+ HOSTNAME: "0.0.0.0",
1600
+ STUDIO_PG_META_URL: `http://${g.key("meta")}:8080`,
1601
+ POSTGRES_PORT: "5432",
1602
+ POSTGRES_HOST: db,
1603
+ POSTGRES_DB: "postgres",
1604
+ POSTGRES_PASSWORD: dbPassword,
1605
+ POSTGRES_USER_READ_WRITE: "postgres",
1606
+ PG_META_CRYPTO_KEY: "your-encryption-key-32-chars-min",
1607
+ PGRST_DB_SCHEMAS: "public,graphql_public",
1608
+ DEFAULT_ORGANIZATION_NAME: "Default Organization",
1609
+ DEFAULT_PROJECT_NAME: "Default Project",
1610
+ SUPABASE_URL: `http://${g.name}:8000`,
1611
+ SUPABASE_PUBLIC_URL: url,
1612
+ SUPABASE_ANON_KEY: anonKey,
1613
+ SUPABASE_SERVICE_KEY: serviceRoleKey,
1614
+ AUTH_JWT_SECRET: jwtSecret,
1615
+ },
1616
+ ports: [3000],
1617
+ dependsOn: ["meta"],
1618
+ readyCheck: {
1619
+ type: "http",
1620
+ port: 3000,
1621
+ path: "/api/platform/profile",
1622
+ timeoutSecs: 180,
1623
+ },
1624
+ };
1625
+ }
1626
+
1627
+ // ── kong (gateway / primary) — no explicit dependsOn: the group
1628
+ // expansion makes the primary depend on every member ──────────────
1629
+ parts.gateway = {
1630
+ image: { type: "registry", reference: IMAGES.kong },
1631
+ env: {
1632
+ KONG_DATABASE: "off",
1633
+ KONG_DECLARATIVE_CONFIG: "/home/kong/kong.yml",
1634
+ KONG_ROUTER_FLAVOR: "expressions",
1635
+ KONG_DNS_ORDER: "LAST,A,CNAME",
1636
+ KONG_DNS_NOT_FOUND_TTL: "1",
1637
+ KONG_PLUGINS:
1638
+ "request-transformer,cors,key-auth,acl,basic-auth,request-termination,ip-restriction,post-function",
1639
+ KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: "160k",
1640
+ KONG_NGINX_PROXY_PROXY_BUFFERS: "64 160k",
1641
+ },
1642
+ files: [
1643
+ {
1644
+ path: "/home/kong/kong.yml",
1645
+ content: buildKongYaml(g.key, {
1646
+ anonKey,
1647
+ serviceRoleKey,
1648
+ dashboardUsername,
1649
+ dashboardPassword,
1650
+ extraAnonKeys,
1651
+ extraServiceRoleKeys,
1652
+ functions: withFunctions,
1653
+ }),
1654
+ },
1655
+ ],
1656
+ ports: [8000, 8443],
1657
+ ...(opts.hostname ? { tls: [{ hostname: opts.hostname, port: 8000 }] } : {}),
1658
+ readyCheck: { type: "exec", command: "kong health", timeoutSecs: 90 },
1659
+ };
1660
+
1661
+ return parts;
1662
+ },
1663
+ // The project's own SQL, applied with the whole stack up (the group
1664
+ // hook chains after the primary, and the primary is the group's
1665
+ // dependency sink). Still inside bring-up, so the result is captured
1666
+ // into the cached environment like everything else.
1667
+ setup: async (ctx) => {
1668
+ if (opts.migrations === undefined) return;
1669
+ await applyMigrations(ctx, opts.migrations, dbKey);
1670
+ await reloadPostgrestSchema(ctx, dbKey, `${name}-rest`);
1671
+ },
1672
+ // The group's consolidated handle at `ctx.svc.<name>`.
1673
+ helpers: (): SupabaseHelpers => ({
1674
+ sql: new SQL(adminDbUrl, { label: name }),
1675
+ url,
1676
+ anonKey,
1677
+ serviceRoleKey,
1678
+ ...(withMail ? { mail: emailHelpers(mailKey) } : {}),
1679
+ }),
1680
+ });
1681
+
1682
+ const appEnv = {
1683
+ SUPABASE_URL: url,
1684
+ SUPABASE_ANON_KEY: anonKey,
1685
+ SUPABASE_SERVICE_ROLE_KEY: serviceRoleKey,
1686
+ DATABASE_URL: dbUrl,
1687
+ };
1688
+
1689
+ return {
1690
+ group,
1691
+ url,
1692
+ anonKey,
1693
+ serviceRoleKey,
1694
+ dbUrl,
1695
+ ready: name,
1696
+ appEnv,
1697
+ ...(withMail ? { smtp: { host: mailKey, port: 1025 } } : {}),
1698
+ };
1699
+ }