@specific.dev/spectest 0.26.0 → 0.28.0

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