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