@specific.dev/spectest 0.45.0 → 0.47.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-config.d.ts +89 -0
- package/dist/components/supabase-config.js +447 -0
- package/dist/components/supabase.d.ts +124 -84
- package/dist/components/supabase.js +395 -136
- package/dist/daemon.js +9 -2
- package/dist/harness/container-run.d.ts +6 -0
- package/dist/harness/container-run.js +13 -0
- package/package.json +1 -1
- package/src/components/supabase-config.test.ts +261 -0
- package/src/components/supabase-config.ts +543 -0
- package/src/components/supabase-dir.test.ts +287 -0
- package/src/components/supabase.ts +581 -230
- package/src/daemon.ts +11 -2
- package/src/harness/container-run.test.ts +8 -0
- package/src/harness/container-run.ts +13 -0
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
// (`sb.url`, `sb.appEnv`) embeds it — a mismatch errors at load.
|
|
51
51
|
|
|
52
52
|
import { createHmac } from "node:crypto";
|
|
53
|
-
import { existsSync } from "node:fs";
|
|
53
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
54
54
|
import { readdir, stat } from "node:fs/promises";
|
|
55
55
|
import { dirname, join } from "node:path";
|
|
56
56
|
|
|
@@ -63,7 +63,22 @@ import {
|
|
|
63
63
|
type ServicesMap,
|
|
64
64
|
} from "../index.js";
|
|
65
65
|
import { SQL, type SqlClient } from "../sql.js";
|
|
66
|
+
import { apple } from "./apple.js";
|
|
67
|
+
import type { OAuthClient } from "./emulate/service.js";
|
|
66
68
|
import { email, emailHelpers, type EmailHelpers } from "./email.js";
|
|
69
|
+
import { github } from "./github.js";
|
|
70
|
+
import { google } from "./google.js";
|
|
71
|
+
import { microsoft } from "./microsoft.js";
|
|
72
|
+
import {
|
|
73
|
+
mapSupabaseConfig,
|
|
74
|
+
parseSupabaseConfigFile,
|
|
75
|
+
type MailTemplateId,
|
|
76
|
+
type MailTemplateSpec,
|
|
77
|
+
type ExternalProvider,
|
|
78
|
+
type MappedSupabaseConfig,
|
|
79
|
+
} from "./supabase-config.js";
|
|
80
|
+
|
|
81
|
+
export type { MailTemplateId, MailTemplateSpec } from "./supabase-config.js";
|
|
67
82
|
|
|
68
83
|
// ──────────────────────────────────────────────────────────────────────────
|
|
69
84
|
// Pinned upstream image tags (github.com/supabase/supabase docker-compose.yml,
|
|
@@ -78,11 +93,101 @@ const IMAGES = {
|
|
|
78
93
|
realtime: "supabase/realtime:v2.102.3",
|
|
79
94
|
storage: "supabase/storage-api:v1.60.4",
|
|
80
95
|
imgproxy: "darthsim/imgproxy:v3.30.1",
|
|
81
|
-
meta: "supabase/postgres-meta:v0.96.6",
|
|
82
|
-
studio: "supabase/studio:2026.07.07-sha-a6a04f2",
|
|
83
96
|
functions: "supabase/edge-runtime:v1.74.0",
|
|
97
|
+
// Serves custom mail templates to GoTrue over HTTP (see the `templates`
|
|
98
|
+
// part). Tiny, and only pulled by a stack that configures a template.
|
|
99
|
+
templates: "busybox:1.37",
|
|
84
100
|
} as const;
|
|
85
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Social sign-in providers this stack can serve, by the name GoTrue (and
|
|
104
|
+
* `[auth.external.<name>]`) uses. Each factory builds one container answering
|
|
105
|
+
* at that provider's **real** endpoints — `accounts.google.com`,
|
|
106
|
+
* `login.microsoftonline.com`, … — with DNS, a CA-trusted certificate and a
|
|
107
|
+
* proxy pointing there, so GoTrue performs the ordinary flow against the
|
|
108
|
+
* ordinary URLs and never learns it is under test.
|
|
109
|
+
*
|
|
110
|
+
* A provider that is enabled in the config and absent here is reported rather
|
|
111
|
+
* than served: there is nothing to point GoTrue at.
|
|
112
|
+
*/
|
|
113
|
+
const EXTERNAL_PROVIDERS: Record<string, (client: OAuthClient) => ServiceDefinition> = {
|
|
114
|
+
google: (client) => google({ client }),
|
|
115
|
+
// `azure` is what GoTrue calls Entra ID.
|
|
116
|
+
azure: (client) => microsoft({ client }),
|
|
117
|
+
github: (client) => github({ client }),
|
|
118
|
+
apple: (client) => apple({ client }),
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The client credentials GoTrue presents to a provider. Taken from the config
|
|
123
|
+
* where it holds a literal, and otherwise made up: the file's values are
|
|
124
|
+
* `env(NAME)` references to a shell this environment does not have, and no
|
|
125
|
+
* real credential is meaningful against an emulator. Only GoTrue and the
|
|
126
|
+
* provider have to agree, and both are ours.
|
|
127
|
+
*/
|
|
128
|
+
function providerCredentials(
|
|
129
|
+
providerName: string,
|
|
130
|
+
declared: ExternalProvider,
|
|
131
|
+
env: Record<string, string> | undefined,
|
|
132
|
+
): { clientId: string; clientSecret: string } {
|
|
133
|
+
const read = (from: string | undefined): string | undefined =>
|
|
134
|
+
from === undefined ? undefined : env?.[from];
|
|
135
|
+
return {
|
|
136
|
+
clientId: declared.clientId ?? read(declared.clientIdFrom) ?? `spectest-${providerName}-client`,
|
|
137
|
+
clientSecret: declared.secret ?? read(declared.secretFrom) ?? `spectest-${providerName}-secret`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The edge runtime, with every remote module its functions import already in
|
|
143
|
+
* the module cache.
|
|
144
|
+
*
|
|
145
|
+
* A Supabase function may import straight from a URL
|
|
146
|
+
* (`import Fuse from "https://deno.land/x/fuse@v6.4.1/…"`), and Deno fetches
|
|
147
|
+
* that when the worker boots. Inside a hermetic VM that is a network call at
|
|
148
|
+
* test time — slow at best, and a failed boot at worst.
|
|
149
|
+
*
|
|
150
|
+
* So it is done at **build** time instead. `edge-runtime bundle` walks the
|
|
151
|
+
* module graph and, unless told otherwise, fills `DENO_DIR` as it goes; the
|
|
152
|
+
* eszip it produces is thrown away, since all we want is the cache. Docker
|
|
153
|
+
* builds run on the host builder, which has ordinary network access, and the
|
|
154
|
+
* cache rides into the image as a layer — so at run time the worker resolves
|
|
155
|
+
* those URLs locally and never reaches for the network.
|
|
156
|
+
*
|
|
157
|
+
* One synthetic entrypoint imports every function, so the whole set is one
|
|
158
|
+
* graph rather than one per function. Bundling is best-effort: a function that
|
|
159
|
+
* fails to bundle (a type error, an import that only resolves at run time)
|
|
160
|
+
* leaves the cache thinner and is never a build failure — the worker then
|
|
161
|
+
* behaves exactly as it did before.
|
|
162
|
+
*/
|
|
163
|
+
function functionsDockerfile(fnRelPath: string): string {
|
|
164
|
+
return `FROM ${IMAGES.functions}
|
|
165
|
+
ENV DENO_DIR=${DENO_DIR}
|
|
166
|
+
COPY ${fnRelPath} /warm/functions
|
|
167
|
+
RUN set -eu; \
|
|
168
|
+
: > /warm/all.ts; \
|
|
169
|
+
for d in /warm/functions/*/; do \
|
|
170
|
+
[ -f "$d/index.ts" ] || continue; \
|
|
171
|
+
n=$(basename "$d"); \
|
|
172
|
+
printf 'import "./functions/%s/index.ts";\\n' "$n" >> /warm/all.ts; \
|
|
173
|
+
done; \
|
|
174
|
+
edge-runtime bundle --entrypoint /warm/all.ts --output /tmp/warm.eszip --quiet || true; \
|
|
175
|
+
rm -rf /tmp/warm.eszip /warm
|
|
176
|
+
`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Where the module cache lives in the edge-runtime image, written at build
|
|
180
|
+
* time and read at run time. Outside the repo mount, deliberately. */
|
|
181
|
+
const DENO_DIR = "/deno-dir";
|
|
182
|
+
|
|
183
|
+
/** Where the project root is mounted in the edge-runtime container. Functions
|
|
184
|
+
* keep their position relative to it, so an import that reaches outside the
|
|
185
|
+
* functions directory resolves exactly as it does in the repo. */
|
|
186
|
+
const REPO_MOUNT = "/repo";
|
|
187
|
+
|
|
188
|
+
/** Where the mail-template server listens; upstream's CLI uses the same port. */
|
|
189
|
+
const TEMPLATE_PORT = 8088;
|
|
190
|
+
|
|
86
191
|
/**
|
|
87
192
|
* The project root inside the VM. A service's `volumes[].source` is a static
|
|
88
193
|
* string in the wire config, so the edge runtime's bind mount cannot go
|
|
@@ -93,6 +198,11 @@ function projectRootPath(): string {
|
|
|
93
198
|
return process.env.SPECTEST_WORKSPACE ?? "/workspace";
|
|
94
199
|
}
|
|
95
200
|
|
|
201
|
+
/** A caller-supplied path: absolute as given, otherwise project-root relative. */
|
|
202
|
+
function absProjectPath(p: string): string {
|
|
203
|
+
return p.startsWith("/") ? p : join(projectRootPath(), p);
|
|
204
|
+
}
|
|
205
|
+
|
|
96
206
|
export interface SupabaseOptions {
|
|
97
207
|
/**
|
|
98
208
|
* Key of the API-gateway service and the prefix for every other piece.
|
|
@@ -114,26 +224,17 @@ export interface SupabaseOptions {
|
|
|
114
224
|
* deterministically, so the warm-template cache stays stable.
|
|
115
225
|
*/
|
|
116
226
|
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
227
|
|
|
122
|
-
/**
|
|
123
|
-
|
|
228
|
+
/**
|
|
229
|
+
* GoTrue auth. `false` drops it from the stack; an object configures the
|
|
230
|
+
* two settings that are **addresses in this environment** rather than
|
|
231
|
+
* behaviour — everything else about auth comes from your `config.toml`.
|
|
232
|
+
*/
|
|
233
|
+
auth?: boolean | SupabaseAuthOptions;
|
|
124
234
|
/** Include storage-api + imgproxy (`<name>-storage`, `<name>-imgproxy`). Default `true`. */
|
|
125
235
|
storage?: boolean;
|
|
126
236
|
/** Include Realtime (`<name>-realtime`). Default `true`. */
|
|
127
237
|
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
238
|
|
|
138
239
|
/**
|
|
139
240
|
* Also serve the gateway over **HTTPS** at `https://<hostname>` via the
|
|
@@ -177,77 +278,132 @@ export interface SupabaseOptions {
|
|
|
177
278
|
extraApiKeys?: { anon?: string[]; serviceRole?: string[] };
|
|
178
279
|
|
|
179
280
|
/**
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
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:
|
|
281
|
+
* The project's Supabase folder, e.g. `"supabase"` (relative to the project
|
|
282
|
+
* root, or absolute) — the layout `supabase init` creates and the CLI
|
|
283
|
+
* requires. This is the **only** way to point the stack at your project, and
|
|
284
|
+
* everything in the folder is used the way the Supabase CLI uses it:
|
|
189
285
|
*
|
|
190
286
|
* ```
|
|
191
287
|
* supabase/
|
|
192
|
-
*
|
|
193
|
-
* migrations/
|
|
194
|
-
* seed.sql
|
|
288
|
+
* config.toml ← auth rules, mail templates, per-function JWT verification
|
|
289
|
+
* migrations/ ← applied in filename order once the stack is up
|
|
290
|
+
* seed.sql ← applied after them, if it's there
|
|
291
|
+
* functions/ ← one directory per function, served at /functions/v1/<name>
|
|
195
292
|
* ```
|
|
196
293
|
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
* PostgREST's schema cache is reloaded afterwards.
|
|
294
|
+
* `config.json` is read in preference to `config.toml` where a newer CLI has
|
|
295
|
+
* written one. Nothing is derived unless you name the folder, and each piece
|
|
296
|
+
* is optional inside it — a folder with only a `config.toml` is fine.
|
|
201
297
|
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
298
|
+
* There is deliberately no per-piece path option and no way to configure
|
|
299
|
+
* mail, OTP, redirect or JWT behaviour here: those live in `config.toml`,
|
|
300
|
+
* where your project already declares them and where the Supabase CLI reads
|
|
301
|
+
* them too. One source of truth, and the environment matches the one you run
|
|
302
|
+
* locally.
|
|
206
303
|
*/
|
|
207
|
-
|
|
304
|
+
dir?: string;
|
|
208
305
|
|
|
209
306
|
/**
|
|
210
|
-
*
|
|
211
|
-
* `
|
|
212
|
-
*
|
|
307
|
+
* The `.env` of this environment — the values your `config.toml` reads with
|
|
308
|
+
* `env(...)`.
|
|
309
|
+
*
|
|
310
|
+
* ```toml
|
|
311
|
+
* # supabase/config.toml
|
|
312
|
+
* [edge_runtime.secrets]
|
|
313
|
+
* STRIPE_KEY = "env(STRIPE_SECRET_KEY)"
|
|
314
|
+
* FEATURE_X = "true"
|
|
315
|
+
*
|
|
316
|
+
* [auth.external.google]
|
|
317
|
+
* client_id = "env(GOOGLE_OAUTH_CLIENT_ID)"
|
|
318
|
+
* ```
|
|
319
|
+
* ```ts
|
|
320
|
+
* supabase({
|
|
321
|
+
* dir: "supabase",
|
|
322
|
+
* env: {
|
|
323
|
+
* STRIPE_SECRET_KEY: "sk_test_…",
|
|
324
|
+
* GOOGLE_OAUTH_CLIENT_ID: "1234.apps.googleusercontent.com",
|
|
325
|
+
* },
|
|
326
|
+
* })
|
|
327
|
+
* ```
|
|
213
328
|
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
* entirely, along with the gateway's `/functions/v1` route.
|
|
329
|
+
* Keyed exactly as your `supabase/.env` is: by the name inside `env(...)`.
|
|
330
|
+
* That file is what the Supabase CLI resolves against — locally at `start`,
|
|
331
|
+
* and in CI at `config push` / `secrets set` — and it is excluded from the
|
|
332
|
+
* upload here, deliberately, since real credentials should not enter a test
|
|
333
|
+
* environment. This is its stand-in, holding test values.
|
|
220
334
|
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
335
|
+
* It covers every `env(...)` in the file, not only secrets: a provider's
|
|
336
|
+
* client id is a field on your project's auth settings rather than a named
|
|
337
|
+
* secret, and it reads from the same place.
|
|
223
338
|
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
339
|
+
* A value reaches your **functions** because `[edge_runtime.secrets]`
|
|
340
|
+
* declares it — `SECRET = "env(NAME)"` — not merely because it is here. A
|
|
341
|
+
* deployed project can have secrets its config never mentions (the dashboard
|
|
342
|
+
* and `supabase secrets set` write straight to the project's store), but
|
|
343
|
+
* declaring them is what makes the test environment's inputs reviewable in
|
|
344
|
+
* the project's own config rather than implicit in its test setup. A name
|
|
345
|
+
* passed here and referenced nowhere is called out in the boot log.
|
|
346
|
+
*
|
|
347
|
+
* A literal in the file needs nothing. An inline `encrypted:` ciphertext
|
|
348
|
+
* cannot be supplied this way at all — it names no variable, and decrypting
|
|
349
|
+
* it needs a `DOTENV_PRIVATE_KEY` a test environment has no business
|
|
350
|
+
* holding — so it is named in the boot log and left unset. Anything else
|
|
351
|
+
* left unresolved is reported the same way, and never passed through as the
|
|
352
|
+
* string `env(NAME)` or as base64.
|
|
226
353
|
*/
|
|
227
|
-
|
|
354
|
+
env?: Record<string, string>;
|
|
228
355
|
}
|
|
229
356
|
|
|
230
|
-
/** Tuning for
|
|
231
|
-
export interface
|
|
357
|
+
/** Tuning for {@link SupabaseOptions.auth}. */
|
|
358
|
+
export interface SupabaseAuthOptions {
|
|
232
359
|
/**
|
|
233
|
-
*
|
|
234
|
-
*
|
|
360
|
+
* Where GoTrue sends a user when a flow finishes — the base of every
|
|
361
|
+
* emailed confirmation, recovery and invite link's `redirect_to`, and the
|
|
362
|
+
* fallback when a requested redirect is refused. Defaults to the gateway
|
|
363
|
+
* (`http://<name>:8000`).
|
|
364
|
+
*
|
|
365
|
+
* This is deliberately **not** read from `config.toml`. That file's
|
|
366
|
+
* `site_url` is the developer's own machine (`http://localhost:3000`),
|
|
367
|
+
* which inside a VM is the browser's own namespace rather than the app
|
|
368
|
+
* container — so honouring it would point every redirect at nothing. Set
|
|
369
|
+
* it to the app under test's address in *this* environment:
|
|
370
|
+
*
|
|
371
|
+
* ```ts
|
|
372
|
+
* supabase({ dir: "supabase", auth: { siteUrl: "https://app.test" } })
|
|
373
|
+
* ```
|
|
235
374
|
*/
|
|
236
|
-
|
|
375
|
+
siteUrl?: string;
|
|
237
376
|
/**
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
377
|
+
* Extra addresses a flow may redirect to, beyond `siteUrl` (which GoTrue
|
|
378
|
+
* always allows). Glob patterns, exactly as `config.toml` spells them:
|
|
379
|
+
* `["https://app.test/**"]`.
|
|
380
|
+
*
|
|
381
|
+
* **Redirect validation is off unless you set this.** GoTrue has no switch
|
|
382
|
+
* for it — an empty allow list is its *strictest* state, not its loosest,
|
|
383
|
+
* because a redirect is then refused unless it matches `siteUrl` — so the
|
|
384
|
+
* default here is the wildcard, and a suite is never rejected for a
|
|
385
|
+
* redirect it didn't declare. Set it to make the environment enforce what
|
|
386
|
+
* production enforces; the addresses are per-environment, so they belong
|
|
387
|
+
* here rather than in the config file.
|
|
242
388
|
*/
|
|
243
|
-
|
|
389
|
+
additionalRedirectUrls?: string[];
|
|
244
390
|
/**
|
|
245
|
-
*
|
|
246
|
-
* the
|
|
247
|
-
* `
|
|
248
|
-
*
|
|
391
|
+
* Provider components to serve the config's `[auth.external.*]` blocks
|
|
392
|
+
* with, keyed by the provider name GoTrue uses (`google`, `azure`,
|
|
393
|
+
* `github`, `apple`). **You do not normally need this**: a provider the
|
|
394
|
+
* config enables is stood up automatically with that component's defaults.
|
|
395
|
+
* Supply one to choose the accounts it offers, or to serve a provider this
|
|
396
|
+
* stack has no built-in emulator for:
|
|
397
|
+
*
|
|
398
|
+
* ```ts
|
|
399
|
+
* auth: {
|
|
400
|
+
* providers: {
|
|
401
|
+
* google: google({ users: [{ email: "alice@corp.test", name: "Alice" }] }),
|
|
402
|
+
* },
|
|
403
|
+
* }
|
|
404
|
+
* ```
|
|
249
405
|
*/
|
|
250
|
-
|
|
406
|
+
providers?: Record<string, ServiceDefinition>;
|
|
251
407
|
}
|
|
252
408
|
|
|
253
409
|
/** Tuning for `SupabaseOptions.mail`. */
|
|
@@ -257,26 +413,14 @@ export interface SupabaseMailOptions {
|
|
|
257
413
|
* services-map key) instead of adding one to the group — the way to keep a
|
|
258
414
|
* single global mailbox when the app under test sends mail too. Assumes
|
|
259
415
|
* 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
416
|
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
* go nowhere.
|
|
417
|
+
* This is the only mail option, deliberately. Everything else about mail —
|
|
418
|
+
* whether a signup is confirmed, the sender identity, custom templates and
|
|
419
|
+
* their subjects — is GoTrue behaviour your project already declares in
|
|
420
|
+
* `config.toml` (`[auth.email]`, `[auth.email.smtp]`,
|
|
421
|
+
* `[auth.email.template.*]`), and it is read from there.
|
|
274
422
|
*/
|
|
275
|
-
|
|
276
|
-
/** Sender address GoTrue mails from. Default `admin@example.com`. */
|
|
277
|
-
adminEmail?: string;
|
|
278
|
-
/** Sender display name. Default `Supabase`. */
|
|
279
|
-
senderName?: string;
|
|
423
|
+
service?: string;
|
|
280
424
|
}
|
|
281
425
|
|
|
282
426
|
/** Helpers the gateway service exposes on `ctx.svc.<name>`. */
|
|
@@ -286,6 +430,11 @@ export interface SupabaseHelpers {
|
|
|
286
430
|
* `postgres` superuser — every query lands on the test event log and its rows
|
|
287
431
|
* come back provenance-wrapped, so `expect(...)` on a read links to the
|
|
288
432
|
* query. Point of direct DB verification alongside the REST/auth surface.
|
|
433
|
+
*
|
|
434
|
+
* The same client the `postgres()` component exposes: a tagged template for
|
|
435
|
+
* ordinary queries, plus `unsafe(text, params?)` for what a template cannot
|
|
436
|
+
* carry — a whole `.sql` file, or a dynamic column list — which is recorded
|
|
437
|
+
* on the timeline just the same. See `spectest docs /components/postgres`.
|
|
289
438
|
*/
|
|
290
439
|
sql: SqlClient;
|
|
291
440
|
/** The gateway base URL the app uses as `SUPABASE_URL` (e.g. `http://supabase:8000`). */
|
|
@@ -774,6 +923,36 @@ services:
|
|
|
774
923
|
if auth == nil or auth == "" or auth:find("^%s*$") then
|
|
775
924
|
kong.service.request.clear_header("authorization")
|
|
776
925
|
end
|
|
926
|
+
# >>> oauthserver (added only when the config enables the OAuth server)
|
|
927
|
+
# The OAuth-server endpoints must be reachable by a client that has no
|
|
928
|
+
# Supabase API key at all — a third-party connector knows only what the
|
|
929
|
+
# authorization-server metadata advertises. So they bypass the key-auth on
|
|
930
|
+
# /auth/v1/ below, exactly as /auth/v1/verify and /auth/v1/callback do,
|
|
931
|
+
# and the client's own Authorization header (a user token at the consent
|
|
932
|
+
# step, client credentials at the token endpoint) passes through untouched.
|
|
933
|
+
# RFC 8414 discovery. The issuer carries a path (.../auth/v1), so a compliant
|
|
934
|
+
# client asks for the metadata with that path INSERTED after the well-known
|
|
935
|
+
# segment — which is where hosted Supabase publishes it, and what an MCP
|
|
936
|
+
# connector will request. GoTrue only registers the bare path, so map it.
|
|
937
|
+
- name: well-known-oauth-issuer-path
|
|
938
|
+
url: http://auth:9999/.well-known/oauth-authorization-server
|
|
939
|
+
routes:
|
|
940
|
+
- name: well-known-oauth-issuer-path
|
|
941
|
+
strip_path: true
|
|
942
|
+
paths:
|
|
943
|
+
- /.well-known/oauth-authorization-server/auth/v1
|
|
944
|
+
plugins:
|
|
945
|
+
- name: cors
|
|
946
|
+
- name: auth-v1-open-oauth
|
|
947
|
+
url: http://auth:9999/oauth/
|
|
948
|
+
routes:
|
|
949
|
+
- name: auth-v1-open-oauth
|
|
950
|
+
strip_path: true
|
|
951
|
+
paths:
|
|
952
|
+
- /auth/v1/oauth/
|
|
953
|
+
plugins:
|
|
954
|
+
- name: cors
|
|
955
|
+
# <<< oauthserver
|
|
777
956
|
# >>> functions (dropped when the edge runtime is off)
|
|
778
957
|
- name: functions-v1
|
|
779
958
|
url: http://functions:9000/
|
|
@@ -795,34 +974,6 @@ services:
|
|
|
795
974
|
- /.well-known/oauth-authorization-server
|
|
796
975
|
plugins:
|
|
797
976
|
- 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
977
|
`;
|
|
827
978
|
|
|
828
979
|
// ──────────────────────────────────────────────────────────────────────────
|
|
@@ -839,9 +990,15 @@ services:
|
|
|
839
990
|
// ──────────────────────────────────────────────────────────────────────────
|
|
840
991
|
|
|
841
992
|
const FUNCTIONS_MAIN_TS = String.raw`// spectest-generated edge-functions router. Do not edit.
|
|
842
|
-
const FUNCTIONS_DIR = "/
|
|
993
|
+
const FUNCTIONS_DIR = Deno.env.get("FUNCTIONS_DIR") ?? "/repo/supabase/functions";
|
|
843
994
|
const JWT_SECRET = Deno.env.get("JWT_SECRET") ?? "";
|
|
844
995
|
const VERIFY_JWT = Deno.env.get("VERIFY_JWT") === "true";
|
|
996
|
+
// Per-function overrides (the project's config file). Hosted Supabase decides
|
|
997
|
+
// this per function, so the global flag is only the default for a function the
|
|
998
|
+
// map says nothing about.
|
|
999
|
+
const VERIFY_JWT_MAP: Record<string, boolean> = JSON.parse(
|
|
1000
|
+
Deno.env.get("VERIFY_JWT_MAP") ?? "{}",
|
|
1001
|
+
);
|
|
845
1002
|
|
|
846
1003
|
function json(body: unknown, status: number): Response {
|
|
847
1004
|
return new Response(JSON.stringify(body), {
|
|
@@ -893,7 +1050,7 @@ Deno.serve(async (req: Request) => {
|
|
|
893
1050
|
const name = pathname.split("/")[1] ?? "";
|
|
894
1051
|
if (name === "") return json({ msg: "missing function name in request" }, 400);
|
|
895
1052
|
|
|
896
|
-
if (VERIFY_JWT && req.method !== "OPTIONS") {
|
|
1053
|
+
if ((VERIFY_JWT_MAP[name] ?? VERIFY_JWT) && req.method !== "OPTIONS") {
|
|
897
1054
|
const header = req.headers.get("authorization") ?? "";
|
|
898
1055
|
const [scheme, token] = header.split(" ");
|
|
899
1056
|
if (scheme !== "Bearer" || !token || !(await validJwt(token))) {
|
|
@@ -948,8 +1105,6 @@ function buildKongYaml(
|
|
|
948
1105
|
vals: {
|
|
949
1106
|
anonKey: string;
|
|
950
1107
|
serviceRoleKey: string;
|
|
951
|
-
dashboardUsername: string;
|
|
952
|
-
dashboardPassword: string;
|
|
953
1108
|
extraAnonKeys: string[];
|
|
954
1109
|
extraServiceRoleKeys: string[];
|
|
955
1110
|
/** Is the edge runtime part of this stack? When it isn't, the
|
|
@@ -958,10 +1113,14 @@ function buildKongYaml(
|
|
|
958
1113
|
* gateway's catch-all and is rejected there (measured: 401), instead
|
|
959
1114
|
* of proxying to an unresolvable host. */
|
|
960
1115
|
functions: boolean;
|
|
1116
|
+
/** Is Supabase Auth acting as an OAuth authorization server? Its endpoints
|
|
1117
|
+
* need a key-auth-free route, since an OAuth client has no API key. */
|
|
1118
|
+
oauthServer: boolean;
|
|
961
1119
|
},
|
|
962
1120
|
): string {
|
|
963
1121
|
let y = KONG_YML_RAW;
|
|
964
1122
|
y = stripMarkedBlock(y, "functions", vals.functions);
|
|
1123
|
+
y = stripMarkedBlock(y, "oauthserver", vals.oauthServer);
|
|
965
1124
|
// Extra API keys land where kong-entrypoint.sh's opaque keys would: as
|
|
966
1125
|
// additional keyauth credentials on the matching consumer. The template's
|
|
967
1126
|
// placeholder lines are either expanded here or dropped by the filter below.
|
|
@@ -982,8 +1141,6 @@ function buildKongYaml(
|
|
|
982
1141
|
.replaceAll("http://auth:9999", `http://${key("auth")}:9999`)
|
|
983
1142
|
.replaceAll("http://rest:3000", `http://${key("rest")}:3000`)
|
|
984
1143
|
.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
1144
|
.replaceAll("http://functions:9000", `http://${key("functions")}:9000`)
|
|
988
1145
|
.replaceAll("realtime-dev.supabase-realtime", `realtime-dev.${key("realtime")}`);
|
|
989
1146
|
// Lua expressions first (they contain no `$SUPABASE_*` tokens).
|
|
@@ -999,8 +1156,7 @@ function buildKongYaml(
|
|
|
999
1156
|
y = y
|
|
1000
1157
|
.replaceAll("$SUPABASE_ANON_KEY", vals.anonKey)
|
|
1001
1158
|
.replaceAll("$SUPABASE_SERVICE_KEY", vals.serviceRoleKey)
|
|
1002
|
-
|
|
1003
|
-
.replaceAll("$DASHBOARD_PASSWORD", vals.dashboardPassword);
|
|
1159
|
+
;
|
|
1004
1160
|
// Drop the unconfigured opaque-key credential lines (kong-entrypoint.sh does
|
|
1005
1161
|
// the same with a `sed` after substitution).
|
|
1006
1162
|
y = y
|
|
@@ -1153,12 +1309,20 @@ async function reloadPostgrestSchema(
|
|
|
1153
1309
|
// queued before we start watching for the result.
|
|
1154
1310
|
await applySqlFile(ctx, "notify pgrst, 'reload schema';\n", "schema reload", "postgres", db);
|
|
1155
1311
|
|
|
1312
|
+
// Only the tables PostgREST will actually publish. The spec below is
|
|
1313
|
+
// fetched unauthenticated, so it is the `anon` role's view — and a table
|
|
1314
|
+
// deliberately granted to nobody else (Supabase projects do this routinely:
|
|
1315
|
+
// a lookup table only `supabase_auth_admin` may read, RLS with no anon
|
|
1316
|
+
// grant) never appears in it. Waiting for those would wait for ever, on a
|
|
1317
|
+
// schema cache that is in fact up to date.
|
|
1156
1318
|
const listed = await ctx.exec(
|
|
1157
1319
|
db,
|
|
1158
1320
|
[
|
|
1159
1321
|
"psql", "-U", "postgres", "-d", "postgres", "-t", "-A", "-c",
|
|
1160
|
-
"select
|
|
1161
|
-
"where
|
|
1322
|
+
"select c.relname from pg_class c join pg_namespace n on n.oid = c.relnamespace " +
|
|
1323
|
+
"where n.nspname = 'public' and c.relkind = 'r' " +
|
|
1324
|
+
// `anon` is created by this component's own bootstrap, so it exists.
|
|
1325
|
+
"and has_table_privilege('anon', c.oid, 'SELECT') order by 1",
|
|
1162
1326
|
],
|
|
1163
1327
|
{ timeoutMs: 60_000 },
|
|
1164
1328
|
);
|
|
@@ -1222,51 +1386,153 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
1222
1386
|
`supabase(): jwtSecret must be at least 32 characters (got ${jwtSecret.length})`,
|
|
1223
1387
|
);
|
|
1224
1388
|
}
|
|
1225
|
-
const
|
|
1226
|
-
const
|
|
1227
|
-
opts.dashboardPassword ?? "this_password_is_insecure_and_should_be_updated";
|
|
1228
|
-
|
|
1229
|
-
const withAuth = opts.auth ?? true;
|
|
1389
|
+
const withAuth = opts.auth !== false;
|
|
1390
|
+
const authOpts: SupabaseAuthOptions = typeof opts.auth === "object" ? opts.auth : {};
|
|
1230
1391
|
const withStorage = opts.storage ?? true;
|
|
1231
1392
|
const withRealtime = opts.realtime ?? true;
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
//
|
|
1236
|
-
//
|
|
1237
|
-
//
|
|
1238
|
-
//
|
|
1239
|
-
|
|
1240
|
-
|
|
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)) {
|
|
1393
|
+
|
|
1394
|
+
// ── the project's supabase/ folder ────────────────────────────────────
|
|
1395
|
+
// One option locates everything, because the Supabase CLI fixes the layout:
|
|
1396
|
+
// it finds a project by `supabase/config.toml`, `supabase migration new`
|
|
1397
|
+
// writes to `supabase/migrations`, and a function is `supabase/functions/
|
|
1398
|
+
// <name>/`. So there is nothing to point at piece by piece — name the
|
|
1399
|
+
// folder, and each piece inside it is optional.
|
|
1400
|
+
const dirPath = opts.dir === undefined ? null : absProjectPath(opts.dir);
|
|
1401
|
+
if (dirPath !== null && !existsSync(dirPath)) {
|
|
1256
1402
|
throw new Error(
|
|
1257
|
-
`supabase():
|
|
1258
|
-
`
|
|
1259
|
-
`
|
|
1260
|
-
`project — check it isn't ignored
|
|
1403
|
+
`supabase(): dir ${JSON.stringify(opts.dir)} does not resolve to a ` +
|
|
1404
|
+
`directory (looked at ${dirPath}). It should be the project's Supabase ` +
|
|
1405
|
+
`folder — the one holding config.toml / migrations / functions. The ` +
|
|
1406
|
+
`path is resolved inside the uploaded project — check it isn't ignored ` +
|
|
1407
|
+
`(e.g. via .spectestignore).`,
|
|
1261
1408
|
);
|
|
1262
1409
|
}
|
|
1410
|
+
const inDir = (rel: string): string | null => {
|
|
1411
|
+
const p = dirPath === null ? null : join(dirPath, rel);
|
|
1412
|
+
return p !== null && existsSync(p) ? p : null;
|
|
1413
|
+
};
|
|
1414
|
+
|
|
1415
|
+
const fnDir = inDir("functions");
|
|
1416
|
+
const withFunctions = fnDir !== null;
|
|
1417
|
+
// The functions directory as the runtime will see it: the same path it has
|
|
1418
|
+
// in the repo, under the root mount.
|
|
1419
|
+
const root = projectRootPath();
|
|
1420
|
+
/** The functions directory relative to the build context (the project root). */
|
|
1421
|
+
const fnRelPath =
|
|
1422
|
+
fnDir === null ? "" : fnDir.startsWith(`${root}/`) ? fnDir.slice(root.length + 1) : "supabase/functions";
|
|
1423
|
+
const fnServePath =
|
|
1424
|
+
fnDir === null
|
|
1425
|
+
? ""
|
|
1426
|
+
: fnDir.startsWith(`${root}/`)
|
|
1427
|
+
? `${REPO_MOUNT}/${fnDir.slice(root.length + 1)}`
|
|
1428
|
+
: `${REPO_MOUNT}/supabase/functions`;
|
|
1429
|
+
const migrationsPath = inDir("migrations");
|
|
1430
|
+
|
|
1431
|
+
// ── config file (config.toml / config.json) ───────────────────────────
|
|
1432
|
+
// The CLI's own project config, mapped onto the stack. Read at build time:
|
|
1433
|
+
// the values become container config, so they must be in hand before the
|
|
1434
|
+
// services map is returned. `config.json` wins where a newer CLI wrote one.
|
|
1435
|
+
const configPath = inDir("config.json") ?? inDir("config.toml");
|
|
1436
|
+
let mapped: MappedSupabaseConfig | null = null;
|
|
1437
|
+
if (configPath !== null) {
|
|
1438
|
+
const shown = configPath.startsWith(projectRootPath())
|
|
1439
|
+
? configPath.slice(projectRootPath().length + 1)
|
|
1440
|
+
: configPath;
|
|
1441
|
+
mapped = mapSupabaseConfig(parseSupabaseConfigFile(configPath));
|
|
1442
|
+
// What the file asked for and did not get. Reported, never fatal: the
|
|
1443
|
+
// config belongs to the project and describes more than this environment,
|
|
1444
|
+
// so a key that is meaningless in a hermetic VM is not grounds to refuse
|
|
1445
|
+
// to start. Silence is the thing to avoid, not the setting itself.
|
|
1446
|
+
for (const { key, reason } of mapped.unsupported) {
|
|
1447
|
+
console.warn(`supabase(): ${shown} sets ${key}, which this stack does not apply — ${reason}.`);
|
|
1448
|
+
}
|
|
1449
|
+
const unmet = Object.entries(mapped.deferredSecrets)
|
|
1450
|
+
.filter(([, from]) => opts.env?.[from] === undefined)
|
|
1451
|
+
.map(([name, from]) => (name === from ? name : `${name} (env ${from})`));
|
|
1452
|
+
if (unmet.length > 0) {
|
|
1453
|
+
console.warn(
|
|
1454
|
+
`supabase(): ${shown} reads ${unmet.length} value(s) from the ` +
|
|
1455
|
+
`environment: ${unmet.join(", ")}. They are unset here — pass them ` +
|
|
1456
|
+
`with env: { … } if a test needs them.`,
|
|
1457
|
+
);
|
|
1458
|
+
}
|
|
1459
|
+
if (mapped.encryptedValues.length > 0) {
|
|
1460
|
+
console.warn(
|
|
1461
|
+
`supabase(): ${shown} carries encrypted values (${mapped.encryptedValues.join(", ")}), ` +
|
|
1462
|
+
`which need a DOTENV_PRIVATE_KEY this environment does not have. They ` +
|
|
1463
|
+
`are unset here.`,
|
|
1464
|
+
);
|
|
1465
|
+
}
|
|
1466
|
+
// The other direction: something supplied here that the config never
|
|
1467
|
+
// reads. Without this the gap is silent — a function goes without a value
|
|
1468
|
+
// and nothing says why.
|
|
1469
|
+
const referenced = new Set<string>([
|
|
1470
|
+
...Object.values(mapped.deferredSecrets),
|
|
1471
|
+
...Object.values(mapped.externalProviders).flatMap((p) =>
|
|
1472
|
+
[p.clientIdFrom, p.secretFrom].filter((n): n is string => n !== undefined),
|
|
1473
|
+
),
|
|
1474
|
+
]);
|
|
1475
|
+
const unread = Object.keys(opts.env ?? {}).filter((k) => !referenced.has(k));
|
|
1476
|
+
if (unread.length > 0) {
|
|
1477
|
+
console.warn(
|
|
1478
|
+
`supabase(): env supplies ${unread.join(", ")}, which ${shown} never ` +
|
|
1479
|
+
`reads with env(...) — nothing receives them. Declare what a function ` +
|
|
1480
|
+
`needs in [edge_runtime.secrets], e.g. NAME = "env(NAME)".`,
|
|
1481
|
+
);
|
|
1482
|
+
}
|
|
1483
|
+
for (const key of mapped.warnings) {
|
|
1484
|
+
console.warn(
|
|
1485
|
+
`supabase(): ${shown} sets ${key}, which this component does not know about — ignoring it.`,
|
|
1486
|
+
);
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
// The OAuth server is on only if the project's config says so — it adds
|
|
1491
|
+
// gateway routes, so the stack has to know before Kong's config is built.
|
|
1492
|
+
const withOAuthServer = mapped?.auth.GOTRUE_OAUTH_SERVER_ENABLED === "true";
|
|
1263
1493
|
|
|
1264
1494
|
const withMail = opts.mail !== false;
|
|
1265
1495
|
const mailOpts: SupabaseMailOptions =
|
|
1266
1496
|
typeof opts.mail === "object" ? opts.mail : {};
|
|
1267
1497
|
const mailExternal = mailOpts.service !== undefined;
|
|
1268
1498
|
const mailKey = mailOpts.service ?? `${name}-mail`;
|
|
1269
|
-
|
|
1499
|
+
|
|
1500
|
+
// ── custom mail templates ─────────────────────────────────────────────
|
|
1501
|
+
// `[auth.email.template.*]` from the project's config, keyed by GoTrue's own
|
|
1502
|
+
// template ids — which is what the container variables are named after.
|
|
1503
|
+
const templates: Partial<Record<MailTemplateId, MailTemplateSpec>> = { ...mapped?.templates };
|
|
1504
|
+
// A subject with no body is legitimate (GoTrue's default template, the
|
|
1505
|
+
// project's wording), and only a body needs serving.
|
|
1506
|
+
const templateBodies = Object.entries(templates).filter(([, spec]) => spec.path !== "") as [
|
|
1507
|
+
MailTemplateId,
|
|
1508
|
+
MailTemplateSpec,
|
|
1509
|
+
][];
|
|
1510
|
+
if (templateBodies.length > 0 && (!withMail || !withAuth)) {
|
|
1511
|
+
const why = !withAuth ? "auth is off (auth: false)" : "mail is off (mail: false)";
|
|
1512
|
+
throw new Error(
|
|
1513
|
+
`supabase(): the config file sets mail templates (${templateBodies
|
|
1514
|
+
.map(([id]) => id)
|
|
1515
|
+
.join(", ")}) but ${why}, so nothing would ever send them.`,
|
|
1516
|
+
);
|
|
1517
|
+
}
|
|
1518
|
+
const templateFiles = templateBodies.map(([id, spec]) => {
|
|
1519
|
+
// `content_path` is written relative to the project root, the way the CLI
|
|
1520
|
+
// resolves it — not relative to the supabase/ folder.
|
|
1521
|
+
const path = absProjectPath(spec.path);
|
|
1522
|
+
if (!existsSync(path)) {
|
|
1523
|
+
throw new Error(
|
|
1524
|
+
`supabase(): mail template ${JSON.stringify(spec.path)} for ${id} does ` +
|
|
1525
|
+
`not resolve to a file (looked at ${path}). The path is resolved ` +
|
|
1526
|
+
`inside the uploaded project — check it isn't ignored (e.g. via ` +
|
|
1527
|
+
`.spectestignore).`,
|
|
1528
|
+
);
|
|
1529
|
+
}
|
|
1530
|
+
// Read now rather than bind-mounting the directory: a template's config
|
|
1531
|
+
// key decides the served name, which a mount cannot rename, and the paths
|
|
1532
|
+
// may be anywhere in the repo.
|
|
1533
|
+
return { path: `/srv/email/${id}.html`, content: readFileSync(path, "utf8") };
|
|
1534
|
+
});
|
|
1535
|
+
const withTemplates = templateFiles.length > 0;
|
|
1270
1536
|
|
|
1271
1537
|
const extraAnonKeys = opts.extraApiKeys?.anon ?? [];
|
|
1272
1538
|
const extraServiceRoleKeys = opts.extraApiKeys?.serviceRole ?? [];
|
|
@@ -1295,6 +1561,41 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
1295
1561
|
// `auth`/`storage` schemas — regardless of ownership.
|
|
1296
1562
|
const adminDbUrl = `postgresql://supabase_admin:${dbPassword}@${dbKey}:5432/postgres`;
|
|
1297
1563
|
|
|
1564
|
+
// ── social sign-in ────────────────────────────────────────────────────
|
|
1565
|
+
// Every `[auth.external.<name>]` the config enables: served in-environment
|
|
1566
|
+
// where there is an emulator for it, reported where there isn't. The app
|
|
1567
|
+
// under test keeps its production configuration either way — the provider
|
|
1568
|
+
// answers at its own real endpoints.
|
|
1569
|
+
const providerOverrides = authOpts.providers ?? {};
|
|
1570
|
+
const externalProviders = Object.entries(mapped?.externalProviders ?? {})
|
|
1571
|
+
.filter(([, p]) => p.enabled)
|
|
1572
|
+
.map(([providerName, declared]) => {
|
|
1573
|
+
const { clientId, clientSecret } = providerCredentials(providerName, declared, opts.env);
|
|
1574
|
+
// Registering the client makes the provider validate it, so a callback
|
|
1575
|
+
// URL that doesn't match fails here rather than in production.
|
|
1576
|
+
const service =
|
|
1577
|
+
providerOverrides[providerName] ??
|
|
1578
|
+
EXTERNAL_PROVIDERS[providerName]?.({
|
|
1579
|
+
clientId,
|
|
1580
|
+
clientSecret,
|
|
1581
|
+
redirectUris: [`${url}/auth/v1/callback`],
|
|
1582
|
+
});
|
|
1583
|
+
return { providerName, declared, service };
|
|
1584
|
+
});
|
|
1585
|
+
for (const { providerName, service } of externalProviders) {
|
|
1586
|
+
if (service === undefined) {
|
|
1587
|
+
console.warn(
|
|
1588
|
+
`supabase(): the config enables the ${providerName} sign-in provider, which ` +
|
|
1589
|
+
`this stack has no emulator for — sign-in through it will not work here. ` +
|
|
1590
|
+
`Supply one with auth: { providers: { ${providerName}: … } }.`,
|
|
1591
|
+
);
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
const servedProviders = externalProviders.filter(
|
|
1595
|
+
(p): p is typeof p & { service: ServiceDefinition } => p.service !== undefined,
|
|
1596
|
+
);
|
|
1597
|
+
|
|
1598
|
+
|
|
1298
1599
|
const group = serviceGroup({
|
|
1299
1600
|
// Kong: the group key itself is the gateway (`http://<name>:8000`), and
|
|
1300
1601
|
// as the primary it's the group's sink — `dependsOn: [sb.ready]` from
|
|
@@ -1369,6 +1670,9 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
1369
1670
|
PGRST_ADMIN_SERVER_PORT: "3001",
|
|
1370
1671
|
PGRST_DB_MAX_ROWS: "1000",
|
|
1371
1672
|
PGRST_DB_EXTRA_SEARCH_PATH: "public",
|
|
1673
|
+
// `api.schemas` / `extra_search_path` / `max_rows` from the project's
|
|
1674
|
+
// config file, if it has one.
|
|
1675
|
+
...(mapped?.rest ?? {}),
|
|
1372
1676
|
},
|
|
1373
1677
|
ports: [3000],
|
|
1374
1678
|
dependsOn: ["db"],
|
|
@@ -1382,9 +1686,41 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
1382
1686
|
parts.mail = email();
|
|
1383
1687
|
}
|
|
1384
1688
|
|
|
1689
|
+
// ── social sign-in providers ────────────────────────────────────
|
|
1690
|
+
for (const { providerName, service } of servedProviders) {
|
|
1691
|
+
parts[providerName] = service;
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
// ── mail templates (static server) ──────────────────────────────
|
|
1695
|
+
// GoTrue fetches a template over HTTP — `http.DefaultClient.Do` on the
|
|
1696
|
+
// configured URL — so a bind-mounted path or a `file://` URL does not
|
|
1697
|
+
// work. Upstream's CLI solves this with an nginx static server beside
|
|
1698
|
+
// Kong; this is the same shape as its own part, and only exists when a
|
|
1699
|
+
// template is configured.
|
|
1700
|
+
if (withTemplates) {
|
|
1701
|
+
parts.templates = {
|
|
1702
|
+
image: { type: "registry", reference: IMAGES.templates },
|
|
1703
|
+
command: `httpd -f -p ${TEMPLATE_PORT} -h /srv`,
|
|
1704
|
+
files: templateFiles,
|
|
1705
|
+
ports: [TEMPLATE_PORT],
|
|
1706
|
+
readyCheck: { type: "tcp", port: TEMPLATE_PORT, timeoutSecs: 60 },
|
|
1707
|
+
};
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1385
1710
|
// ── auth (GoTrue) ───────────────────────────────────────────────
|
|
1386
1711
|
if (withAuth) {
|
|
1387
1712
|
const apiExternalUrl = `${url}/auth/v1`;
|
|
1713
|
+
const templateHost = `${g.key("templates")}:${TEMPLATE_PORT}`;
|
|
1714
|
+
const templateEnv: Record<string, string> = {};
|
|
1715
|
+
for (const [id, spec] of Object.entries(templates)) {
|
|
1716
|
+
if (spec.path !== "") {
|
|
1717
|
+
templateEnv[`GOTRUE_MAILER_TEMPLATES_${id.toUpperCase()}`] =
|
|
1718
|
+
`http://${templateHost}/email/${id}.html`;
|
|
1719
|
+
}
|
|
1720
|
+
if (spec.subject !== undefined) {
|
|
1721
|
+
templateEnv[`GOTRUE_MAILER_SUBJECTS_${id.toUpperCase()}`] = spec.subject;
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1388
1724
|
parts.auth = {
|
|
1389
1725
|
image: { type: "registry", reference: IMAGES.auth },
|
|
1390
1726
|
env: {
|
|
@@ -1393,8 +1729,13 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
1393
1729
|
API_EXTERNAL_URL: apiExternalUrl,
|
|
1394
1730
|
GOTRUE_DB_DRIVER: "postgres",
|
|
1395
1731
|
GOTRUE_DB_DATABASE_URL: `postgres://supabase_auth_admin:${dbPassword}@${db}:5432/postgres`,
|
|
1396
|
-
|
|
1397
|
-
|
|
1732
|
+
// Addresses in *this* environment, so they come from the option
|
|
1733
|
+
// rather than the config file (whose values name the developer's
|
|
1734
|
+
// machine). The wildcard is deliberate: GoTrue has no way to turn
|
|
1735
|
+
// redirect validation off, and an empty allow list is its
|
|
1736
|
+
// strictest state, not its loosest.
|
|
1737
|
+
GOTRUE_SITE_URL: authOpts.siteUrl ?? url,
|
|
1738
|
+
GOTRUE_URI_ALLOW_LIST: authOpts.additionalRedirectUrls?.join(",") ?? "*",
|
|
1398
1739
|
GOTRUE_DISABLE_SIGNUP: "false",
|
|
1399
1740
|
GOTRUE_JWT_ADMIN_ROLES: "service_role",
|
|
1400
1741
|
GOTRUE_JWT_AUD: "authenticated",
|
|
@@ -1413,8 +1754,9 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
1413
1754
|
// Always ON when there is no mailbox (`mail: false`), whatever
|
|
1414
1755
|
// was asked for: the confirmation would be sent nowhere and no
|
|
1415
1756
|
// account could ever be used.
|
|
1416
|
-
|
|
1417
|
-
|
|
1757
|
+
// One-step signups by default; `[auth.email] enable_confirmations`
|
|
1758
|
+
// in the project's config turns the real confirmation flow on.
|
|
1759
|
+
GOTRUE_MAILER_AUTOCONFIRM: "true",
|
|
1418
1760
|
GOTRUE_EXTERNAL_PHONE_ENABLED: "false",
|
|
1419
1761
|
GOTRUE_SMS_AUTOCONFIRM: "true",
|
|
1420
1762
|
...(withMail
|
|
@@ -1426,9 +1768,8 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
1426
1768
|
// GoTrue's Go smtp.PlainAuth refuses to send them over a
|
|
1427
1769
|
// plaintext connection ("unencrypted connection" → every
|
|
1428
1770
|
// mail 500s). Unauthenticated submission is accepted.
|
|
1429
|
-
GOTRUE_SMTP_ADMIN_EMAIL:
|
|
1430
|
-
|
|
1431
|
-
GOTRUE_SMTP_SENDER_NAME: mailOpts.senderName ?? "Supabase",
|
|
1771
|
+
GOTRUE_SMTP_ADMIN_EMAIL: "admin@example.com",
|
|
1772
|
+
GOTRUE_SMTP_SENDER_NAME: "Supabase",
|
|
1432
1773
|
// GoTrue rate-limits repeat mail to the same address
|
|
1433
1774
|
// (default 1/min) — far too slow for tests that
|
|
1434
1775
|
// request an OTP, assert, and request again.
|
|
@@ -1439,9 +1780,44 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
1439
1780
|
GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: "/auth/v1/verify",
|
|
1440
1781
|
}
|
|
1441
1782
|
: {}),
|
|
1783
|
+
// The project's config file outranks the defaults above — that is
|
|
1784
|
+
// what reading it is for.
|
|
1785
|
+
...(mapped?.auth ?? {}),
|
|
1786
|
+
...templateEnv,
|
|
1787
|
+
// One block per served provider. The credentials are this
|
|
1788
|
+
// environment's own: the config file's are `env(...)` references
|
|
1789
|
+
// to the developer's shell, and nothing here needs the real ones —
|
|
1790
|
+
// only GoTrue and the provider have to agree, and both are ours.
|
|
1791
|
+
...Object.fromEntries(
|
|
1792
|
+
servedProviders.flatMap(({ providerName, declared }) => {
|
|
1793
|
+
const upper = providerName.toUpperCase();
|
|
1794
|
+
const { clientId, clientSecret } = providerCredentials(providerName, declared, opts.env);
|
|
1795
|
+
return [
|
|
1796
|
+
[`GOTRUE_EXTERNAL_${upper}_ENABLED`, "true"],
|
|
1797
|
+
[`GOTRUE_EXTERNAL_${upper}_CLIENT_ID`, clientId],
|
|
1798
|
+
[`GOTRUE_EXTERNAL_${upper}_SECRET`, clientSecret],
|
|
1799
|
+
[`GOTRUE_EXTERNAL_${upper}_REDIRECT_URI`, `${url}/auth/v1/callback`],
|
|
1800
|
+
...(declared.url === undefined
|
|
1801
|
+
? []
|
|
1802
|
+
: [[`GOTRUE_EXTERNAL_${upper}_URL`, declared.url]]),
|
|
1803
|
+
];
|
|
1804
|
+
}),
|
|
1805
|
+
),
|
|
1806
|
+
// Autoconfirm is forced on when there is no mailbox, whatever the
|
|
1807
|
+
// config said: the confirmation would go nowhere, and no account
|
|
1808
|
+
// could ever be used.
|
|
1809
|
+
...(!withMail ? { GOTRUE_MAILER_AUTOCONFIRM: "true" } : {}),
|
|
1442
1810
|
},
|
|
1443
1811
|
ports: [9999],
|
|
1444
|
-
dependsOn: [
|
|
1812
|
+
dependsOn: [
|
|
1813
|
+
"db",
|
|
1814
|
+
...(withMail && !mailExternal ? ["mail"] : []),
|
|
1815
|
+
...(withTemplates ? ["templates"] : []),
|
|
1816
|
+
// GoTrue reads the provider's discovery document during a flow,
|
|
1817
|
+
// not at boot — but a test that signs in immediately should not
|
|
1818
|
+
// race the emulator's first start.
|
|
1819
|
+
...servedProviders.map((p) => p.providerName),
|
|
1820
|
+
],
|
|
1445
1821
|
readyCheck: { type: "http", port: 9999, path: "/health", timeoutSecs: 90 },
|
|
1446
1822
|
};
|
|
1447
1823
|
}
|
|
@@ -1539,19 +1915,48 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
1539
1915
|
// ── functions (edge runtime) ────────────────────────────────────
|
|
1540
1916
|
if (withFunctions) {
|
|
1541
1917
|
parts.functions = {
|
|
1542
|
-
|
|
1543
|
-
//
|
|
1544
|
-
//
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1918
|
+
// Built rather than pulled, so the functions' remote imports are
|
|
1919
|
+
// fetched by the host builder and cached into the image — see
|
|
1920
|
+
// `functionsDockerfile`.
|
|
1921
|
+
image: {
|
|
1922
|
+
type: "dockerfile",
|
|
1923
|
+
content: functionsDockerfile(fnRelPath),
|
|
1924
|
+
},
|
|
1925
|
+
// The **project root**, mounted with the functions directory in its
|
|
1926
|
+
// real position underneath. Mounting only `supabase/functions`
|
|
1927
|
+
// would be enough for a self-contained function, but a monorepo's
|
|
1928
|
+
// functions routinely import shared code from outside it
|
|
1929
|
+
// (`../../../../packages/…`), and that only resolves if the
|
|
1930
|
+
// directory sits where it sits in the repo. Read-only: the runtime
|
|
1931
|
+
// only reads sources, and a function writing into its own directory
|
|
1932
|
+
// would be writing into the user's checkout.
|
|
1933
|
+
volumes: [{ source: projectRootPath(), target: REPO_MOUNT, readOnly: true }],
|
|
1548
1934
|
// Our router (see FUNCTIONS_MAIN_TS) sits outside that mount, so
|
|
1549
1935
|
// the project never has to carry a `main/` function of ours.
|
|
1550
1936
|
files: [{ path: "/home/deno/main/index.ts", content: FUNCTIONS_MAIN_TS }],
|
|
1551
1937
|
args: ["start", "--main-service", "/home/deno/main", "--port", "9000"],
|
|
1552
1938
|
env: {
|
|
1553
1939
|
JWT_SECRET: jwtSecret,
|
|
1554
|
-
|
|
1940
|
+
// Where the router looks for `<name>/`, inside the repo mount.
|
|
1941
|
+
FUNCTIONS_DIR: fnServePath,
|
|
1942
|
+
// The default a function inherits when the config says nothing:
|
|
1943
|
+
// reject an unauthenticated call, as hosted Supabase does.
|
|
1944
|
+
VERIFY_JWT: "true",
|
|
1945
|
+
// Per-function overrides from the config file's `[functions.<name>]`
|
|
1946
|
+
// blocks. Hosted Supabase verifies per function, so one global flag
|
|
1947
|
+
// makes a project pick a single answer for the whole set.
|
|
1948
|
+
VERIFY_JWT_MAP: JSON.stringify(mapped?.functionVerifyJwt ?? {}),
|
|
1949
|
+
// `[edge_runtime.secrets]`, then the values the file deferred to
|
|
1950
|
+
// the environment.
|
|
1951
|
+
...(mapped?.functionSecrets ?? {}),
|
|
1952
|
+
// …the ones it reads from the environment, under the names the
|
|
1953
|
+
// file gives them…
|
|
1954
|
+
...Object.fromEntries(
|
|
1955
|
+
Object.entries(mapped?.deferredSecrets ?? {})
|
|
1956
|
+
.map(([name, from]) => [name, opts.env?.[from]])
|
|
1957
|
+
.filter(([, v]) => v !== undefined) as [string, string][],
|
|
1958
|
+
),
|
|
1959
|
+
|
|
1555
1960
|
// A function calls back into its own project through the
|
|
1556
1961
|
// gateway, exactly as it does when hosted.
|
|
1557
1962
|
SUPABASE_URL: `http://${g.name}:8000`,
|
|
@@ -1559,7 +1964,6 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
1559
1964
|
SUPABASE_ANON_KEY: anonKey,
|
|
1560
1965
|
SUPABASE_SERVICE_ROLE_KEY: serviceRoleKey,
|
|
1561
1966
|
SUPABASE_DB_URL: `postgresql://postgres:${dbPassword}@${db}:5432/postgres`,
|
|
1562
|
-
...(fnOpts!.env ?? {}),
|
|
1563
1967
|
},
|
|
1564
1968
|
ports: [9000],
|
|
1565
1969
|
dependsOn: ["db"],
|
|
@@ -1572,58 +1976,6 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
1572
1976
|
};
|
|
1573
1977
|
}
|
|
1574
1978
|
|
|
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
1979
|
// ── kong (gateway / primary) — no explicit dependsOn: the group
|
|
1628
1980
|
// expansion makes the primary depend on every member ──────────────
|
|
1629
1981
|
parts.gateway = {
|
|
@@ -1645,11 +1997,10 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
1645
1997
|
content: buildKongYaml(g.key, {
|
|
1646
1998
|
anonKey,
|
|
1647
1999
|
serviceRoleKey,
|
|
1648
|
-
dashboardUsername,
|
|
1649
|
-
dashboardPassword,
|
|
1650
2000
|
extraAnonKeys,
|
|
1651
2001
|
extraServiceRoleKeys,
|
|
1652
2002
|
functions: withFunctions,
|
|
2003
|
+
oauthServer: withOAuthServer,
|
|
1653
2004
|
}),
|
|
1654
2005
|
},
|
|
1655
2006
|
],
|
|
@@ -1665,8 +2016,8 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
1665
2016
|
// dependency sink). Still inside bring-up, so the result is captured
|
|
1666
2017
|
// into the cached environment like everything else.
|
|
1667
2018
|
setup: async (ctx) => {
|
|
1668
|
-
if (
|
|
1669
|
-
await applyMigrations(ctx,
|
|
2019
|
+
if (migrationsPath === null) return;
|
|
2020
|
+
await applyMigrations(ctx, migrationsPath, dbKey);
|
|
1670
2021
|
await reloadPostgrestSchema(ctx, dbKey, `${name}-rest`);
|
|
1671
2022
|
},
|
|
1672
2023
|
// The group's consolidated handle at `ctx.svc.<name>`.
|