@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.
- package/dist/aws-sigv4.d.ts +42 -0
- package/dist/aws-sigv4.js +166 -0
- package/dist/browser.d.ts +314 -0
- package/dist/browser.js +1320 -0
- package/dist/components/email.d.ts +135 -0
- package/dist/components/email.js +271 -0
- package/dist/components/expo.d.ts +69 -0
- package/dist/components/expo.js +125 -0
- package/dist/components/index.d.ts +8 -0
- package/dist/components/index.js +18 -0
- package/dist/components/k3s.d.ts +172 -0
- package/dist/components/k3s.js +1124 -0
- package/dist/components/postgres.d.ts +93 -0
- package/dist/components/postgres.js +58 -0
- package/dist/components/replayFake.d.ts +169 -0
- package/dist/components/replayFake.js +738 -0
- package/dist/components/s3.d.ts +99 -0
- package/dist/components/s3.js +81 -0
- package/dist/components/supabase.d.ts +197 -0
- package/dist/components/supabase.js +1003 -0
- package/dist/daemon.d.ts +1 -0
- package/dist/daemon.js +4611 -0
- package/dist/ids.d.ts +2 -0
- package/{src/ids.ts → dist/ids.js} +46 -50
- package/dist/index.d.ts +1328 -0
- package/dist/index.js +769 -0
- package/dist/ingress.d.ts +114 -0
- package/dist/ingress.js +210 -0
- package/dist/inspect.d.ts +228 -0
- package/dist/inspect.js +429 -0
- package/dist/locator.d.ts +260 -0
- package/dist/locator.js +293 -0
- package/dist/mobile.d.ts +71 -0
- package/dist/mobile.js +65 -0
- package/dist/record-secrets.d.ts +9 -0
- package/{src/record-secrets.ts → dist/record-secrets.js} +13 -15
- package/dist/recorder.d.ts +527 -0
- package/dist/recorder.js +219 -0
- package/dist/redis.d.ts +54 -0
- package/dist/redis.js +126 -0
- package/dist/replay-bundle.d.ts +38 -0
- package/{src/replay-bundle.ts → dist/replay-bundle.js} +29 -47
- package/dist/resolver.d.ts +1 -0
- package/dist/resolver.js +309 -0
- package/dist/s3.d.ts +89 -0
- package/dist/s3.js +198 -0
- package/dist/sql.d.ts +74 -0
- package/dist/sql.js +151 -0
- package/dist/terminal.d.ts +161 -0
- package/dist/terminal.js +538 -0
- package/package.json +24 -9
- package/src/browser.ts +0 -1819
- package/src/components/email.ts +0 -398
- package/src/components/expo.ts +0 -167
- package/src/components/index.ts +0 -63
- package/src/components/k3s.ts +0 -1312
- package/src/components/postgres.ts +0 -105
- package/src/components/replayFake.ts +0 -848
- package/src/components/s3.ts +0 -132
- package/src/components/supabase.ts +0 -1299
- package/src/daemon.ts +0 -4969
- package/src/index.ts +0 -2350
- package/src/ingress.ts +0 -288
- package/src/inspect.ts +0 -673
- package/src/locator.ts +0 -594
- package/src/mobile.ts +0 -133
- package/src/recorder.ts +0 -817
- package/src/redis.ts +0 -202
- package/src/resolver.ts +0 -351
- package/src/s3.ts +0 -333
- package/src/sql.ts +0 -243
- package/src/terminal.ts +0 -740
- package/src/vendor/rrweb-plugin-console-record.umd.js +0 -521
- 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
|
+
}
|