@specific.dev/spectest 0.12.0 → 0.14.0

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