@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.
@@ -49,12 +49,17 @@
49
49
  // matching the `name` option (default `"supabase"`) since the derived surface
50
50
  // (`sb.url`, `sb.appEnv`) embeds it — a mismatch errors at load.
51
51
  import { createHmac } from "node:crypto";
52
- import { existsSync } from "node:fs";
52
+ import { existsSync, readFileSync } from "node:fs";
53
53
  import { readdir, stat } from "node:fs/promises";
54
54
  import { dirname, join } from "node:path";
55
55
  import { serviceGroup, } from "../index.js";
56
56
  import { SQL } from "../sql.js";
57
+ import { apple } from "./apple.js";
57
58
  import { email, emailHelpers } from "./email.js";
59
+ import { github } from "./github.js";
60
+ import { google } from "./google.js";
61
+ import { microsoft } from "./microsoft.js";
62
+ import { mapSupabaseConfig, parseSupabaseConfigFile, } from "./supabase-config.js";
58
63
  // ──────────────────────────────────────────────────────────────────────────
59
64
  // Pinned upstream image tags (github.com/supabase/supabase docker-compose.yml,
60
65
  // current master). Bump together; each is pulled through the host Hub mirror on
@@ -68,10 +73,89 @@ const IMAGES = {
68
73
  realtime: "supabase/realtime:v2.102.3",
69
74
  storage: "supabase/storage-api:v1.60.4",
70
75
  imgproxy: "darthsim/imgproxy:v3.30.1",
71
- meta: "supabase/postgres-meta:v0.96.6",
72
- studio: "supabase/studio:2026.07.07-sha-a6a04f2",
73
76
  functions: "supabase/edge-runtime:v1.74.0",
77
+ // Serves custom mail templates to GoTrue over HTTP (see the `templates`
78
+ // part). Tiny, and only pulled by a stack that configures a template.
79
+ templates: "busybox:1.37",
74
80
  };
81
+ /**
82
+ * Social sign-in providers this stack can serve, by the name GoTrue (and
83
+ * `[auth.external.<name>]`) uses. Each factory builds one container answering
84
+ * at that provider's **real** endpoints — `accounts.google.com`,
85
+ * `login.microsoftonline.com`, … — with DNS, a CA-trusted certificate and a
86
+ * proxy pointing there, so GoTrue performs the ordinary flow against the
87
+ * ordinary URLs and never learns it is under test.
88
+ *
89
+ * A provider that is enabled in the config and absent here is reported rather
90
+ * than served: there is nothing to point GoTrue at.
91
+ */
92
+ const EXTERNAL_PROVIDERS = {
93
+ google: (client) => google({ client }),
94
+ // `azure` is what GoTrue calls Entra ID.
95
+ azure: (client) => microsoft({ client }),
96
+ github: (client) => github({ client }),
97
+ apple: (client) => apple({ client }),
98
+ };
99
+ /**
100
+ * The client credentials GoTrue presents to a provider. Taken from the config
101
+ * where it holds a literal, and otherwise made up: the file's values are
102
+ * `env(NAME)` references to a shell this environment does not have, and no
103
+ * real credential is meaningful against an emulator. Only GoTrue and the
104
+ * provider have to agree, and both are ours.
105
+ */
106
+ function providerCredentials(providerName, declared, env) {
107
+ const read = (from) => from === undefined ? undefined : env?.[from];
108
+ return {
109
+ clientId: declared.clientId ?? read(declared.clientIdFrom) ?? `spectest-${providerName}-client`,
110
+ clientSecret: declared.secret ?? read(declared.secretFrom) ?? `spectest-${providerName}-secret`,
111
+ };
112
+ }
113
+ /**
114
+ * The edge runtime, with every remote module its functions import already in
115
+ * the module cache.
116
+ *
117
+ * A Supabase function may import straight from a URL
118
+ * (`import Fuse from "https://deno.land/x/fuse@v6.4.1/…"`), and Deno fetches
119
+ * that when the worker boots. Inside a hermetic VM that is a network call at
120
+ * test time — slow at best, and a failed boot at worst.
121
+ *
122
+ * So it is done at **build** time instead. `edge-runtime bundle` walks the
123
+ * module graph and, unless told otherwise, fills `DENO_DIR` as it goes; the
124
+ * eszip it produces is thrown away, since all we want is the cache. Docker
125
+ * builds run on the host builder, which has ordinary network access, and the
126
+ * cache rides into the image as a layer — so at run time the worker resolves
127
+ * those URLs locally and never reaches for the network.
128
+ *
129
+ * One synthetic entrypoint imports every function, so the whole set is one
130
+ * graph rather than one per function. Bundling is best-effort: a function that
131
+ * fails to bundle (a type error, an import that only resolves at run time)
132
+ * leaves the cache thinner and is never a build failure — the worker then
133
+ * behaves exactly as it did before.
134
+ */
135
+ function functionsDockerfile(fnRelPath) {
136
+ return `FROM ${IMAGES.functions}
137
+ ENV DENO_DIR=${DENO_DIR}
138
+ COPY ${fnRelPath} /warm/functions
139
+ RUN set -eu; \
140
+ : > /warm/all.ts; \
141
+ for d in /warm/functions/*/; do \
142
+ [ -f "$d/index.ts" ] || continue; \
143
+ n=$(basename "$d"); \
144
+ printf 'import "./functions/%s/index.ts";\\n' "$n" >> /warm/all.ts; \
145
+ done; \
146
+ edge-runtime bundle --entrypoint /warm/all.ts --output /tmp/warm.eszip --quiet || true; \
147
+ rm -rf /tmp/warm.eszip /warm
148
+ `;
149
+ }
150
+ /** Where the module cache lives in the edge-runtime image, written at build
151
+ * time and read at run time. Outside the repo mount, deliberately. */
152
+ const DENO_DIR = "/deno-dir";
153
+ /** Where the project root is mounted in the edge-runtime container. Functions
154
+ * keep their position relative to it, so an import that reaches outside the
155
+ * functions directory resolves exactly as it does in the repo. */
156
+ const REPO_MOUNT = "/repo";
157
+ /** Where the mail-template server listens; upstream's CLI uses the same port. */
158
+ const TEMPLATE_PORT = 8088;
75
159
  /**
76
160
  * The project root inside the VM. A service's `volumes[].source` is a static
77
161
  * string in the wire config, so the edge runtime's bind mount cannot go
@@ -81,6 +165,10 @@ const IMAGES = {
81
165
  function projectRootPath() {
82
166
  return process.env.SPECTEST_WORKSPACE ?? "/workspace";
83
167
  }
168
+ /** A caller-supplied path: absolute as given, otherwise project-root relative. */
169
+ function absProjectPath(p) {
170
+ return p.startsWith("/") ? p : join(projectRootPath(), p);
171
+ }
84
172
  // Supabase's well-known demo secret (≥32 chars). Local-only; override via opts.
85
173
  const DEFAULT_JWT_SECRET = "your-super-secret-jwt-token-with-at-least-32-characters-long";
86
174
  // Fixed issued-at / expiry for the derived API keys. Constant (not `Date.now`)
@@ -495,6 +583,36 @@ services:
495
583
  if auth == nil or auth == "" or auth:find("^%s*$") then
496
584
  kong.service.request.clear_header("authorization")
497
585
  end
586
+ # >>> oauthserver (added only when the config enables the OAuth server)
587
+ # The OAuth-server endpoints must be reachable by a client that has no
588
+ # Supabase API key at all — a third-party connector knows only what the
589
+ # authorization-server metadata advertises. So they bypass the key-auth on
590
+ # /auth/v1/ below, exactly as /auth/v1/verify and /auth/v1/callback do,
591
+ # and the client's own Authorization header (a user token at the consent
592
+ # step, client credentials at the token endpoint) passes through untouched.
593
+ # RFC 8414 discovery. The issuer carries a path (.../auth/v1), so a compliant
594
+ # client asks for the metadata with that path INSERTED after the well-known
595
+ # segment — which is where hosted Supabase publishes it, and what an MCP
596
+ # connector will request. GoTrue only registers the bare path, so map it.
597
+ - name: well-known-oauth-issuer-path
598
+ url: http://auth:9999/.well-known/oauth-authorization-server
599
+ routes:
600
+ - name: well-known-oauth-issuer-path
601
+ strip_path: true
602
+ paths:
603
+ - /.well-known/oauth-authorization-server/auth/v1
604
+ plugins:
605
+ - name: cors
606
+ - name: auth-v1-open-oauth
607
+ url: http://auth:9999/oauth/
608
+ routes:
609
+ - name: auth-v1-open-oauth
610
+ strip_path: true
611
+ paths:
612
+ - /auth/v1/oauth/
613
+ plugins:
614
+ - name: cors
615
+ # <<< oauthserver
498
616
  # >>> functions (dropped when the edge runtime is off)
499
617
  - name: functions-v1
500
618
  url: http://functions:9000/
@@ -516,34 +634,6 @@ services:
516
634
  - /.well-known/oauth-authorization-server
517
635
  plugins:
518
636
  - name: cors
519
- - name: meta
520
- url: http://meta:8080/
521
- routes:
522
- - name: meta-all
523
- strip_path: true
524
- paths:
525
- - /pg/
526
- plugins:
527
- - name: key-auth
528
- config:
529
- hide_credentials: false
530
- - name: acl
531
- config:
532
- hide_groups_header: true
533
- allow:
534
- - admin
535
- - name: dashboard
536
- url: http://studio:3000/
537
- routes:
538
- - name: dashboard-all
539
- strip_path: true
540
- paths:
541
- - /
542
- plugins:
543
- - name: cors
544
- - name: basic-auth
545
- config:
546
- hide_credentials: true
547
637
  `;
548
638
  // ──────────────────────────────────────────────────────────────────────────
549
639
  // Edge functions — the runtime's "main service": the request router that
@@ -558,9 +648,15 @@ services:
558
648
  // with, and Web Crypto verifies it in a few lines with no dependency.
559
649
  // ──────────────────────────────────────────────────────────────────────────
560
650
  const FUNCTIONS_MAIN_TS = String.raw `// spectest-generated edge-functions router. Do not edit.
561
- const FUNCTIONS_DIR = "/home/deno/functions";
651
+ const FUNCTIONS_DIR = Deno.env.get("FUNCTIONS_DIR") ?? "/repo/supabase/functions";
562
652
  const JWT_SECRET = Deno.env.get("JWT_SECRET") ?? "";
563
653
  const VERIFY_JWT = Deno.env.get("VERIFY_JWT") === "true";
654
+ // Per-function overrides (the project's config file). Hosted Supabase decides
655
+ // this per function, so the global flag is only the default for a function the
656
+ // map says nothing about.
657
+ const VERIFY_JWT_MAP: Record<string, boolean> = JSON.parse(
658
+ Deno.env.get("VERIFY_JWT_MAP") ?? "{}",
659
+ );
564
660
 
565
661
  function json(body: unknown, status: number): Response {
566
662
  return new Response(JSON.stringify(body), {
@@ -612,7 +708,7 @@ Deno.serve(async (req: Request) => {
612
708
  const name = pathname.split("/")[1] ?? "";
613
709
  if (name === "") return json({ msg: "missing function name in request" }, 400);
614
710
 
615
- if (VERIFY_JWT && req.method !== "OPTIONS") {
711
+ if ((VERIFY_JWT_MAP[name] ?? VERIFY_JWT) && req.method !== "OPTIONS") {
616
712
  const header = req.headers.get("authorization") ?? "";
617
713
  const [scheme, token] = header.split(" ");
618
714
  if (scheme !== "Bearer" || !token || !(await validJwt(token))) {
@@ -664,6 +760,7 @@ function stripMarkedBlock(yaml, tag, keep) {
664
760
  function buildKongYaml(key, vals) {
665
761
  let y = KONG_YML_RAW;
666
762
  y = stripMarkedBlock(y, "functions", vals.functions);
763
+ y = stripMarkedBlock(y, "oauthserver", vals.oauthServer);
667
764
  // Extra API keys land where kong-entrypoint.sh's opaque keys would: as
668
765
  // additional keyauth credentials on the matching consumer. The template's
669
766
  // placeholder lines are either expanded here or dropped by the filter below.
@@ -678,8 +775,6 @@ function buildKongYaml(key, vals) {
678
775
  .replaceAll("http://auth:9999", `http://${key("auth")}:9999`)
679
776
  .replaceAll("http://rest:3000", `http://${key("rest")}:3000`)
680
777
  .replaceAll("http://storage:5000", `http://${key("storage")}:5000`)
681
- .replaceAll("http://meta:8080", `http://${key("meta")}:8080`)
682
- .replaceAll("http://studio:3000", `http://${key("studio")}:3000`)
683
778
  .replaceAll("http://functions:9000", `http://${key("functions")}:9000`)
684
779
  .replaceAll("realtime-dev.supabase-realtime", `realtime-dev.${key("realtime")}`);
685
780
  // Lua expressions first (they contain no `$SUPABASE_*` tokens).
@@ -691,9 +786,7 @@ function buildKongYaml(key, vals) {
691
786
  // API keys + dashboard credentials.
692
787
  y = y
693
788
  .replaceAll("$SUPABASE_ANON_KEY", vals.anonKey)
694
- .replaceAll("$SUPABASE_SERVICE_KEY", vals.serviceRoleKey)
695
- .replaceAll("$DASHBOARD_USERNAME", vals.dashboardUsername)
696
- .replaceAll("$DASHBOARD_PASSWORD", vals.dashboardPassword);
789
+ .replaceAll("$SUPABASE_SERVICE_KEY", vals.serviceRoleKey);
697
790
  // Drop the unconfigured opaque-key credential lines (kong-entrypoint.sh does
698
791
  // the same with a `sed` after substitution).
699
792
  y = y
@@ -783,10 +876,18 @@ async function reloadPostgrestSchema(ctx, db, rest) {
783
876
  // Ours first, so every reload — ours and the DDL trigger's — is already
784
877
  // queued before we start watching for the result.
785
878
  await applySqlFile(ctx, "notify pgrst, 'reload schema';\n", "schema reload", "postgres", db);
879
+ // Only the tables PostgREST will actually publish. The spec below is
880
+ // fetched unauthenticated, so it is the `anon` role's view — and a table
881
+ // deliberately granted to nobody else (Supabase projects do this routinely:
882
+ // a lookup table only `supabase_auth_admin` may read, RLS with no anon
883
+ // grant) never appears in it. Waiting for those would wait for ever, on a
884
+ // schema cache that is in fact up to date.
786
885
  const listed = await ctx.exec(db, [
787
886
  "psql", "-U", "postgres", "-d", "postgres", "-t", "-A", "-c",
788
- "select table_name from information_schema.tables " +
789
- "where table_schema = 'public' and table_type = 'BASE TABLE' order by 1",
887
+ "select c.relname from pg_class c join pg_namespace n on n.oid = c.relnamespace " +
888
+ "where n.nspname = 'public' and c.relkind = 'r' " +
889
+ // `anon` is created by this component's own bootstrap, so it exists.
890
+ "and has_table_privilege('anon', c.oid, 'SELECT') order by 1",
790
891
  ], { timeoutMs: 60_000 });
791
892
  const tables = String(listed.stdout)
792
893
  .split("\n")
@@ -845,42 +946,125 @@ export function supabase(opts = {}) {
845
946
  if (jwtSecret.length < 32) {
846
947
  throw new Error(`supabase(): jwtSecret must be at least 32 characters (got ${jwtSecret.length})`);
847
948
  }
848
- const dashboardUsername = opts.dashboardUsername ?? "supabase";
849
- const dashboardPassword = opts.dashboardPassword ?? "this_password_is_insecure_and_should_be_updated";
850
- const withAuth = opts.auth ?? true;
949
+ const withAuth = opts.auth !== false;
950
+ const authOpts = typeof opts.auth === "object" ? opts.auth : {};
851
951
  const withStorage = opts.storage ?? true;
852
952
  const withRealtime = opts.realtime ?? true;
853
- const withStudio = opts.studio ?? false;
854
- const withMeta = opts.meta ?? withStudio;
855
- // Mail capture (see SupabaseOptions.mail) — on unless explicitly disabled.
856
- // `mailKey` is the SMTP host GoTrue mails through — the in-group part
857
- // (`<name>-mail`) or a user-declared `email()` service shared with the app
858
- // under test.
859
- // Edge functions — like `migrations`, a path the caller gives or nothing
860
- // at all. A directory that doesn't resolve is an error rather than an
861
- // empty runtime whose every call 500s much later.
862
- const fnOpts = opts.functions === undefined
863
- ? null
864
- : typeof opts.functions === "string"
865
- ? { dir: opts.functions }
866
- : opts.functions;
867
- const withFunctions = fnOpts !== null;
868
- const fnDir = fnOpts === null
953
+ // ── the project's supabase/ folder ────────────────────────────────────
954
+ // One option locates everything, because the Supabase CLI fixes the layout:
955
+ // it finds a project by `supabase/config.toml`, `supabase migration new`
956
+ // writes to `supabase/migrations`, and a function is `supabase/functions/
957
+ // <name>/`. So there is nothing to point at piece by piece — name the
958
+ // folder, and each piece inside it is optional.
959
+ const dirPath = opts.dir === undefined ? null : absProjectPath(opts.dir);
960
+ if (dirPath !== null && !existsSync(dirPath)) {
961
+ throw new Error(`supabase(): dir ${JSON.stringify(opts.dir)} does not resolve to a ` +
962
+ `directory (looked at ${dirPath}). It should be the project's Supabase ` +
963
+ `folder — the one holding config.toml / migrations / functions. The ` +
964
+ `path is resolved inside the uploaded project — check it isn't ignored ` +
965
+ `(e.g. via .spectestignore).`);
966
+ }
967
+ const inDir = (rel) => {
968
+ const p = dirPath === null ? null : join(dirPath, rel);
969
+ return p !== null && existsSync(p) ? p : null;
970
+ };
971
+ const fnDir = inDir("functions");
972
+ const withFunctions = fnDir !== null;
973
+ // The functions directory as the runtime will see it: the same path it has
974
+ // in the repo, under the root mount.
975
+ const root = projectRootPath();
976
+ /** The functions directory relative to the build context (the project root). */
977
+ const fnRelPath = fnDir === null ? "" : fnDir.startsWith(`${root}/`) ? fnDir.slice(root.length + 1) : "supabase/functions";
978
+ const fnServePath = fnDir === null
869
979
  ? ""
870
- : fnOpts.dir.startsWith("/")
871
- ? fnOpts.dir
872
- : join(projectRootPath(), fnOpts.dir);
873
- if (withFunctions && !existsSync(fnDir)) {
874
- throw new Error(`supabase(): functions path ${JSON.stringify(fnOpts.dir)} does not ` +
875
- `resolve to a directory (looked at ${fnDir}). It should hold one ` +
876
- `subdirectory per function. The path is resolved inside the uploaded ` +
877
- `project — check it isn't ignored (e.g. via .spectestignore).`);
980
+ : fnDir.startsWith(`${root}/`)
981
+ ? `${REPO_MOUNT}/${fnDir.slice(root.length + 1)}`
982
+ : `${REPO_MOUNT}/supabase/functions`;
983
+ const migrationsPath = inDir("migrations");
984
+ // ── config file (config.toml / config.json) ───────────────────────────
985
+ // The CLI's own project config, mapped onto the stack. Read at build time:
986
+ // the values become container config, so they must be in hand before the
987
+ // services map is returned. `config.json` wins where a newer CLI wrote one.
988
+ const configPath = inDir("config.json") ?? inDir("config.toml");
989
+ let mapped = null;
990
+ if (configPath !== null) {
991
+ const shown = configPath.startsWith(projectRootPath())
992
+ ? configPath.slice(projectRootPath().length + 1)
993
+ : configPath;
994
+ mapped = mapSupabaseConfig(parseSupabaseConfigFile(configPath));
995
+ // What the file asked for and did not get. Reported, never fatal: the
996
+ // config belongs to the project and describes more than this environment,
997
+ // so a key that is meaningless in a hermetic VM is not grounds to refuse
998
+ // to start. Silence is the thing to avoid, not the setting itself.
999
+ for (const { key, reason } of mapped.unsupported) {
1000
+ console.warn(`supabase(): ${shown} sets ${key}, which this stack does not apply — ${reason}.`);
1001
+ }
1002
+ const unmet = Object.entries(mapped.deferredSecrets)
1003
+ .filter(([, from]) => opts.env?.[from] === undefined)
1004
+ .map(([name, from]) => (name === from ? name : `${name} (env ${from})`));
1005
+ if (unmet.length > 0) {
1006
+ console.warn(`supabase(): ${shown} reads ${unmet.length} value(s) from the ` +
1007
+ `environment: ${unmet.join(", ")}. They are unset here — pass them ` +
1008
+ `with env: { … } if a test needs them.`);
1009
+ }
1010
+ if (mapped.encryptedValues.length > 0) {
1011
+ console.warn(`supabase(): ${shown} carries encrypted values (${mapped.encryptedValues.join(", ")}), ` +
1012
+ `which need a DOTENV_PRIVATE_KEY this environment does not have. They ` +
1013
+ `are unset here.`);
1014
+ }
1015
+ // The other direction: something supplied here that the config never
1016
+ // reads. Without this the gap is silent — a function goes without a value
1017
+ // and nothing says why.
1018
+ const referenced = new Set([
1019
+ ...Object.values(mapped.deferredSecrets),
1020
+ ...Object.values(mapped.externalProviders).flatMap((p) => [p.clientIdFrom, p.secretFrom].filter((n) => n !== undefined)),
1021
+ ]);
1022
+ const unread = Object.keys(opts.env ?? {}).filter((k) => !referenced.has(k));
1023
+ if (unread.length > 0) {
1024
+ console.warn(`supabase(): env supplies ${unread.join(", ")}, which ${shown} never ` +
1025
+ `reads with env(...) — nothing receives them. Declare what a function ` +
1026
+ `needs in [edge_runtime.secrets], e.g. NAME = "env(NAME)".`);
1027
+ }
1028
+ for (const key of mapped.warnings) {
1029
+ console.warn(`supabase(): ${shown} sets ${key}, which this component does not know about — ignoring it.`);
1030
+ }
878
1031
  }
1032
+ // The OAuth server is on only if the project's config says so — it adds
1033
+ // gateway routes, so the stack has to know before Kong's config is built.
1034
+ const withOAuthServer = mapped?.auth.GOTRUE_OAUTH_SERVER_ENABLED === "true";
879
1035
  const withMail = opts.mail !== false;
880
1036
  const mailOpts = typeof opts.mail === "object" ? opts.mail : {};
881
1037
  const mailExternal = mailOpts.service !== undefined;
882
1038
  const mailKey = mailOpts.service ?? `${name}-mail`;
883
- const mailAutoconfirm = mailOpts.autoconfirm ?? true;
1039
+ // ── custom mail templates ─────────────────────────────────────────────
1040
+ // `[auth.email.template.*]` from the project's config, keyed by GoTrue's own
1041
+ // template ids — which is what the container variables are named after.
1042
+ const templates = { ...mapped?.templates };
1043
+ // A subject with no body is legitimate (GoTrue's default template, the
1044
+ // project's wording), and only a body needs serving.
1045
+ const templateBodies = Object.entries(templates).filter(([, spec]) => spec.path !== "");
1046
+ if (templateBodies.length > 0 && (!withMail || !withAuth)) {
1047
+ const why = !withAuth ? "auth is off (auth: false)" : "mail is off (mail: false)";
1048
+ throw new Error(`supabase(): the config file sets mail templates (${templateBodies
1049
+ .map(([id]) => id)
1050
+ .join(", ")}) but ${why}, so nothing would ever send them.`);
1051
+ }
1052
+ const templateFiles = templateBodies.map(([id, spec]) => {
1053
+ // `content_path` is written relative to the project root, the way the CLI
1054
+ // resolves it — not relative to the supabase/ folder.
1055
+ const path = absProjectPath(spec.path);
1056
+ if (!existsSync(path)) {
1057
+ throw new Error(`supabase(): mail template ${JSON.stringify(spec.path)} for ${id} does ` +
1058
+ `not resolve to a file (looked at ${path}). The path is resolved ` +
1059
+ `inside the uploaded project — check it isn't ignored (e.g. via ` +
1060
+ `.spectestignore).`);
1061
+ }
1062
+ // Read now rather than bind-mounting the directory: a template's config
1063
+ // key decides the served name, which a mount cannot rename, and the paths
1064
+ // may be anywhere in the repo.
1065
+ return { path: `/srv/email/${id}.html`, content: readFileSync(path, "utf8") };
1066
+ });
1067
+ const withTemplates = templateFiles.length > 0;
884
1068
  const extraAnonKeys = opts.extraApiKeys?.anon ?? [];
885
1069
  const extraServiceRoleKeys = opts.extraApiKeys?.serviceRole ?? [];
886
1070
  for (const k of [...extraAnonKeys, ...extraServiceRoleKeys]) {
@@ -903,6 +1087,34 @@ export function supabase(opts = {}) {
903
1087
  // `supabase_admin` so it can inspect everything — public tables plus the
904
1088
  // `auth`/`storage` schemas — regardless of ownership.
905
1089
  const adminDbUrl = `postgresql://supabase_admin:${dbPassword}@${dbKey}:5432/postgres`;
1090
+ // ── social sign-in ────────────────────────────────────────────────────
1091
+ // Every `[auth.external.<name>]` the config enables: served in-environment
1092
+ // where there is an emulator for it, reported where there isn't. The app
1093
+ // under test keeps its production configuration either way — the provider
1094
+ // answers at its own real endpoints.
1095
+ const providerOverrides = authOpts.providers ?? {};
1096
+ const externalProviders = Object.entries(mapped?.externalProviders ?? {})
1097
+ .filter(([, p]) => p.enabled)
1098
+ .map(([providerName, declared]) => {
1099
+ const { clientId, clientSecret } = providerCredentials(providerName, declared, opts.env);
1100
+ // Registering the client makes the provider validate it, so a callback
1101
+ // URL that doesn't match fails here rather than in production.
1102
+ const service = providerOverrides[providerName] ??
1103
+ EXTERNAL_PROVIDERS[providerName]?.({
1104
+ clientId,
1105
+ clientSecret,
1106
+ redirectUris: [`${url}/auth/v1/callback`],
1107
+ });
1108
+ return { providerName, declared, service };
1109
+ });
1110
+ for (const { providerName, service } of externalProviders) {
1111
+ if (service === undefined) {
1112
+ console.warn(`supabase(): the config enables the ${providerName} sign-in provider, which ` +
1113
+ `this stack has no emulator for — sign-in through it will not work here. ` +
1114
+ `Supply one with auth: { providers: { ${providerName}: … } }.`);
1115
+ }
1116
+ }
1117
+ const servedProviders = externalProviders.filter((p) => p.service !== undefined);
906
1118
  const group = serviceGroup({
907
1119
  // Kong: the group key itself is the gateway (`http://<name>:8000`), and
908
1120
  // as the primary it's the group's sink — `dependsOn: [sb.ready]` from
@@ -971,6 +1183,9 @@ export function supabase(opts = {}) {
971
1183
  PGRST_ADMIN_SERVER_PORT: "3001",
972
1184
  PGRST_DB_MAX_ROWS: "1000",
973
1185
  PGRST_DB_EXTRA_SEARCH_PATH: "public",
1186
+ // `api.schemas` / `extra_search_path` / `max_rows` from the project's
1187
+ // config file, if it has one.
1188
+ ...(mapped?.rest ?? {}),
974
1189
  },
975
1190
  ports: [3000],
976
1191
  dependsOn: ["db"],
@@ -982,9 +1197,39 @@ export function supabase(opts = {}) {
982
1197
  if (withMail && !mailExternal) {
983
1198
  parts.mail = email();
984
1199
  }
1200
+ // ── social sign-in providers ────────────────────────────────────
1201
+ for (const { providerName, service } of servedProviders) {
1202
+ parts[providerName] = service;
1203
+ }
1204
+ // ── mail templates (static server) ──────────────────────────────
1205
+ // GoTrue fetches a template over HTTP — `http.DefaultClient.Do` on the
1206
+ // configured URL — so a bind-mounted path or a `file://` URL does not
1207
+ // work. Upstream's CLI solves this with an nginx static server beside
1208
+ // Kong; this is the same shape as its own part, and only exists when a
1209
+ // template is configured.
1210
+ if (withTemplates) {
1211
+ parts.templates = {
1212
+ image: { type: "registry", reference: IMAGES.templates },
1213
+ command: `httpd -f -p ${TEMPLATE_PORT} -h /srv`,
1214
+ files: templateFiles,
1215
+ ports: [TEMPLATE_PORT],
1216
+ readyCheck: { type: "tcp", port: TEMPLATE_PORT, timeoutSecs: 60 },
1217
+ };
1218
+ }
985
1219
  // ── auth (GoTrue) ───────────────────────────────────────────────
986
1220
  if (withAuth) {
987
1221
  const apiExternalUrl = `${url}/auth/v1`;
1222
+ const templateHost = `${g.key("templates")}:${TEMPLATE_PORT}`;
1223
+ const templateEnv = {};
1224
+ for (const [id, spec] of Object.entries(templates)) {
1225
+ if (spec.path !== "") {
1226
+ templateEnv[`GOTRUE_MAILER_TEMPLATES_${id.toUpperCase()}`] =
1227
+ `http://${templateHost}/email/${id}.html`;
1228
+ }
1229
+ if (spec.subject !== undefined) {
1230
+ templateEnv[`GOTRUE_MAILER_SUBJECTS_${id.toUpperCase()}`] = spec.subject;
1231
+ }
1232
+ }
988
1233
  parts.auth = {
989
1234
  image: { type: "registry", reference: IMAGES.auth },
990
1235
  env: {
@@ -993,8 +1238,13 @@ export function supabase(opts = {}) {
993
1238
  API_EXTERNAL_URL: apiExternalUrl,
994
1239
  GOTRUE_DB_DRIVER: "postgres",
995
1240
  GOTRUE_DB_DATABASE_URL: `postgres://supabase_auth_admin:${dbPassword}@${db}:5432/postgres`,
996
- GOTRUE_SITE_URL: url,
997
- GOTRUE_URI_ALLOW_LIST: "*",
1241
+ // Addresses in *this* environment, so they come from the option
1242
+ // rather than the config file (whose values name the developer's
1243
+ // machine). The wildcard is deliberate: GoTrue has no way to turn
1244
+ // redirect validation off, and an empty allow list is its
1245
+ // strictest state, not its loosest.
1246
+ GOTRUE_SITE_URL: authOpts.siteUrl ?? url,
1247
+ GOTRUE_URI_ALLOW_LIST: authOpts.additionalRedirectUrls?.join(",") ?? "*",
998
1248
  GOTRUE_DISABLE_SIGNUP: "false",
999
1249
  GOTRUE_JWT_ADMIN_ROLES: "service_role",
1000
1250
  GOTRUE_JWT_AUD: "authenticated",
@@ -1013,7 +1263,9 @@ export function supabase(opts = {}) {
1013
1263
  // Always ON when there is no mailbox (`mail: false`), whatever
1014
1264
  // was asked for: the confirmation would be sent nowhere and no
1015
1265
  // account could ever be used.
1016
- GOTRUE_MAILER_AUTOCONFIRM: !withMail || mailAutoconfirm ? "true" : "false",
1266
+ // One-step signups by default; `[auth.email] enable_confirmations`
1267
+ // in the project's config turns the real confirmation flow on.
1268
+ GOTRUE_MAILER_AUTOCONFIRM: "true",
1017
1269
  GOTRUE_EXTERNAL_PHONE_ENABLED: "false",
1018
1270
  GOTRUE_SMS_AUTOCONFIRM: "true",
1019
1271
  ...(withMail
@@ -1025,8 +1277,8 @@ export function supabase(opts = {}) {
1025
1277
  // GoTrue's Go smtp.PlainAuth refuses to send them over a
1026
1278
  // plaintext connection ("unencrypted connection" → every
1027
1279
  // mail 500s). Unauthenticated submission is accepted.
1028
- GOTRUE_SMTP_ADMIN_EMAIL: mailOpts.adminEmail ?? "admin@example.com",
1029
- GOTRUE_SMTP_SENDER_NAME: mailOpts.senderName ?? "Supabase",
1280
+ GOTRUE_SMTP_ADMIN_EMAIL: "admin@example.com",
1281
+ GOTRUE_SMTP_SENDER_NAME: "Supabase",
1030
1282
  // GoTrue rate-limits repeat mail to the same address
1031
1283
  // (default 1/min) — far too slow for tests that
1032
1284
  // request an OTP, assert, and request again.
@@ -1037,9 +1289,42 @@ export function supabase(opts = {}) {
1037
1289
  GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: "/auth/v1/verify",
1038
1290
  }
1039
1291
  : {}),
1292
+ // The project's config file outranks the defaults above — that is
1293
+ // what reading it is for.
1294
+ ...(mapped?.auth ?? {}),
1295
+ ...templateEnv,
1296
+ // One block per served provider. The credentials are this
1297
+ // environment's own: the config file's are `env(...)` references
1298
+ // to the developer's shell, and nothing here needs the real ones —
1299
+ // only GoTrue and the provider have to agree, and both are ours.
1300
+ ...Object.fromEntries(servedProviders.flatMap(({ providerName, declared }) => {
1301
+ const upper = providerName.toUpperCase();
1302
+ const { clientId, clientSecret } = providerCredentials(providerName, declared, opts.env);
1303
+ return [
1304
+ [`GOTRUE_EXTERNAL_${upper}_ENABLED`, "true"],
1305
+ [`GOTRUE_EXTERNAL_${upper}_CLIENT_ID`, clientId],
1306
+ [`GOTRUE_EXTERNAL_${upper}_SECRET`, clientSecret],
1307
+ [`GOTRUE_EXTERNAL_${upper}_REDIRECT_URI`, `${url}/auth/v1/callback`],
1308
+ ...(declared.url === undefined
1309
+ ? []
1310
+ : [[`GOTRUE_EXTERNAL_${upper}_URL`, declared.url]]),
1311
+ ];
1312
+ })),
1313
+ // Autoconfirm is forced on when there is no mailbox, whatever the
1314
+ // config said: the confirmation would go nowhere, and no account
1315
+ // could ever be used.
1316
+ ...(!withMail ? { GOTRUE_MAILER_AUTOCONFIRM: "true" } : {}),
1040
1317
  },
1041
1318
  ports: [9999],
1042
- dependsOn: ["db", ...(withMail && !mailExternal ? ["mail"] : [])],
1319
+ dependsOn: [
1320
+ "db",
1321
+ ...(withMail && !mailExternal ? ["mail"] : []),
1322
+ ...(withTemplates ? ["templates"] : []),
1323
+ // GoTrue reads the provider's discovery document during a flow,
1324
+ // not at boot — but a test that signs in immediately should not
1325
+ // race the emulator's first start.
1326
+ ...servedProviders.map((p) => p.providerName),
1327
+ ],
1043
1328
  readyCheck: { type: "http", port: 9999, path: "/health", timeoutSecs: 90 },
1044
1329
  };
1045
1330
  }
@@ -1132,19 +1417,45 @@ export function supabase(opts = {}) {
1132
1417
  // ── functions (edge runtime) ────────────────────────────────────
1133
1418
  if (withFunctions) {
1134
1419
  parts.functions = {
1135
- image: { type: "registry", reference: IMAGES.functions },
1136
- // The repo's own function directory, mounted where the runtime
1137
- // looks. Read-only: the runtime only ever reads the sources, and
1138
- // a function that writes into its own directory would be writing
1139
- // into the user's checkout.
1140
- volumes: [{ source: fnDir, target: "/home/deno/functions", readOnly: true }],
1420
+ // Built rather than pulled, so the functions' remote imports are
1421
+ // fetched by the host builder and cached into the image — see
1422
+ // `functionsDockerfile`.
1423
+ image: {
1424
+ type: "dockerfile",
1425
+ content: functionsDockerfile(fnRelPath),
1426
+ },
1427
+ // The **project root**, mounted with the functions directory in its
1428
+ // real position underneath. Mounting only `supabase/functions`
1429
+ // would be enough for a self-contained function, but a monorepo's
1430
+ // functions routinely import shared code from outside it
1431
+ // (`../../../../packages/…`), and that only resolves if the
1432
+ // directory sits where it sits in the repo. Read-only: the runtime
1433
+ // only reads sources, and a function writing into its own directory
1434
+ // would be writing into the user's checkout.
1435
+ volumes: [{ source: projectRootPath(), target: REPO_MOUNT, readOnly: true }],
1141
1436
  // Our router (see FUNCTIONS_MAIN_TS) sits outside that mount, so
1142
1437
  // the project never has to carry a `main/` function of ours.
1143
1438
  files: [{ path: "/home/deno/main/index.ts", content: FUNCTIONS_MAIN_TS }],
1144
1439
  args: ["start", "--main-service", "/home/deno/main", "--port", "9000"],
1145
1440
  env: {
1146
1441
  JWT_SECRET: jwtSecret,
1147
- VERIFY_JWT: (fnOpts.verifyJwt ?? true) ? "true" : "false",
1442
+ // Where the router looks for `<name>/`, inside the repo mount.
1443
+ FUNCTIONS_DIR: fnServePath,
1444
+ // The default a function inherits when the config says nothing:
1445
+ // reject an unauthenticated call, as hosted Supabase does.
1446
+ VERIFY_JWT: "true",
1447
+ // Per-function overrides from the config file's `[functions.<name>]`
1448
+ // blocks. Hosted Supabase verifies per function, so one global flag
1449
+ // makes a project pick a single answer for the whole set.
1450
+ VERIFY_JWT_MAP: JSON.stringify(mapped?.functionVerifyJwt ?? {}),
1451
+ // `[edge_runtime.secrets]`, then the values the file deferred to
1452
+ // the environment.
1453
+ ...(mapped?.functionSecrets ?? {}),
1454
+ // …the ones it reads from the environment, under the names the
1455
+ // file gives them…
1456
+ ...Object.fromEntries(Object.entries(mapped?.deferredSecrets ?? {})
1457
+ .map(([name, from]) => [name, opts.env?.[from]])
1458
+ .filter(([, v]) => v !== undefined)),
1148
1459
  // A function calls back into its own project through the
1149
1460
  // gateway, exactly as it does when hosted.
1150
1461
  SUPABASE_URL: `http://${g.name}:8000`,
@@ -1152,7 +1463,6 @@ export function supabase(opts = {}) {
1152
1463
  SUPABASE_ANON_KEY: anonKey,
1153
1464
  SUPABASE_SERVICE_ROLE_KEY: serviceRoleKey,
1154
1465
  SUPABASE_DB_URL: `postgresql://postgres:${dbPassword}@${db}:5432/postgres`,
1155
- ...(fnOpts.env ?? {}),
1156
1466
  },
1157
1467
  ports: [9000],
1158
1468
  dependsOn: ["db"],
@@ -1164,56 +1474,6 @@ export function supabase(opts = {}) {
1164
1474
  },
1165
1475
  };
1166
1476
  }
1167
- // ── meta (postgres-meta) ────────────────────────────────────────
1168
- if (withMeta) {
1169
- parts.meta = {
1170
- image: { type: "registry", reference: IMAGES.meta },
1171
- env: {
1172
- PG_META_PORT: "8080",
1173
- PG_META_DB_HOST: db,
1174
- PG_META_DB_PORT: "5432",
1175
- PG_META_DB_NAME: "postgres",
1176
- PG_META_DB_USER: "postgres",
1177
- PG_META_DB_PASSWORD: dbPassword,
1178
- CRYPTO_KEY: "your-encryption-key-32-chars-min",
1179
- },
1180
- ports: [8080],
1181
- dependsOn: ["db"],
1182
- readyCheck: { type: "tcp", port: 8080, timeoutSecs: 90 },
1183
- };
1184
- }
1185
- // ── studio ──────────────────────────────────────────────────────
1186
- if (withStudio) {
1187
- parts.studio = {
1188
- image: { type: "registry", reference: IMAGES.studio },
1189
- env: {
1190
- HOSTNAME: "0.0.0.0",
1191
- STUDIO_PG_META_URL: `http://${g.key("meta")}:8080`,
1192
- POSTGRES_PORT: "5432",
1193
- POSTGRES_HOST: db,
1194
- POSTGRES_DB: "postgres",
1195
- POSTGRES_PASSWORD: dbPassword,
1196
- POSTGRES_USER_READ_WRITE: "postgres",
1197
- PG_META_CRYPTO_KEY: "your-encryption-key-32-chars-min",
1198
- PGRST_DB_SCHEMAS: "public,graphql_public",
1199
- DEFAULT_ORGANIZATION_NAME: "Default Organization",
1200
- DEFAULT_PROJECT_NAME: "Default Project",
1201
- SUPABASE_URL: `http://${g.name}:8000`,
1202
- SUPABASE_PUBLIC_URL: url,
1203
- SUPABASE_ANON_KEY: anonKey,
1204
- SUPABASE_SERVICE_KEY: serviceRoleKey,
1205
- AUTH_JWT_SECRET: jwtSecret,
1206
- },
1207
- ports: [3000],
1208
- dependsOn: ["meta"],
1209
- readyCheck: {
1210
- type: "http",
1211
- port: 3000,
1212
- path: "/api/platform/profile",
1213
- timeoutSecs: 180,
1214
- },
1215
- };
1216
- }
1217
1477
  // ── kong (gateway / primary) — no explicit dependsOn: the group
1218
1478
  // expansion makes the primary depend on every member ──────────────
1219
1479
  parts.gateway = {
@@ -1234,11 +1494,10 @@ export function supabase(opts = {}) {
1234
1494
  content: buildKongYaml(g.key, {
1235
1495
  anonKey,
1236
1496
  serviceRoleKey,
1237
- dashboardUsername,
1238
- dashboardPassword,
1239
1497
  extraAnonKeys,
1240
1498
  extraServiceRoleKeys,
1241
1499
  functions: withFunctions,
1500
+ oauthServer: withOAuthServer,
1242
1501
  }),
1243
1502
  },
1244
1503
  ],
@@ -1253,9 +1512,9 @@ export function supabase(opts = {}) {
1253
1512
  // dependency sink). Still inside bring-up, so the result is captured
1254
1513
  // into the cached environment like everything else.
1255
1514
  setup: async (ctx) => {
1256
- if (opts.migrations === undefined)
1515
+ if (migrationsPath === null)
1257
1516
  return;
1258
- await applyMigrations(ctx, opts.migrations, dbKey);
1517
+ await applyMigrations(ctx, migrationsPath, dbKey);
1259
1518
  await reloadPostgrestSchema(ctx, dbKey, `${name}-rest`);
1260
1519
  },
1261
1520
  // The group's consolidated handle at `ctx.svc.<name>`.