@specific.dev/spectest 0.44.0 → 0.46.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.
@@ -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
- /** Include GoTrue auth (`<name>-auth`). Default `true`. */
123
- auth?: boolean;
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
- * 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:
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
- * migrations/0001_init.sql ← applied, sorted
193
- * migrations/0002_todos.sql
194
- * seed.sql ← applied last
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
- * 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.
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
- * 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/**`).
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
- migrations?: string;
304
+ dir?: string;
208
305
 
209
306
  /**
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`).
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
- * **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.
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
- * Function code is read straight from the repo — nothing is copied or
222
- * bundled — so editing a function is an ordinary project edit.
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
- * 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(...)`.
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
- functions?: string | SupabaseFunctionsOptions;
354
+ env?: Record<string, string>;
228
355
  }
229
356
 
230
- /** Tuning for `SupabaseOptions.functions`. */
231
- export interface SupabaseFunctionsOptions {
357
+ /** Tuning for {@link SupabaseOptions.auth}. */
358
+ export interface SupabaseAuthOptions {
232
359
  /**
233
- * Directory holding one subdirectory per function, relative to the project
234
- * root (or absolute). Required — there is no assumed location.
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
- dir: string;
375
+ siteUrl?: string;
237
376
  /**
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).
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
- verifyJwt?: boolean;
389
+ additionalRedirectUrls?: string[];
244
390
  /**
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.
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
- env?: Record<string, string>;
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
- * 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.
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
- autoconfirm?: boolean;
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 = "/home/deno/functions";
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
- .replaceAll("$DASHBOARD_USERNAME", vals.dashboardUsername)
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 table_name from information_schema.tables " +
1161
- "where table_schema = 'public' and table_type = 'BASE TABLE' order by 1",
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 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;
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
- 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)) {
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(): 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).`,
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
- const mailAutoconfirm = mailOpts.autoconfirm ?? true;
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
- GOTRUE_SITE_URL: url,
1397
- GOTRUE_URI_ALLOW_LIST: "*",
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
- GOTRUE_MAILER_AUTOCONFIRM:
1417
- !withMail || mailAutoconfirm ? "true" : "false",
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
- mailOpts.adminEmail ?? "admin@example.com",
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: ["db", ...(withMail && !mailExternal ? ["mail"] : [])],
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
- 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 }],
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
- VERIFY_JWT: (fnOpts!.verifyJwt ?? true) ? "true" : "false",
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 (opts.migrations === undefined) return;
1669
- await applyMigrations(ctx, opts.migrations, dbKey);
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>`.