@specific.dev/spectest 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/browser.ts +408 -48
- package/src/components/email.ts +398 -0
- package/src/components/index.ts +9 -0
- package/src/components/supabase.ts +216 -26
- package/src/daemon.ts +222 -34
- package/src/index.ts +21 -5
- package/src/mobile.ts +47 -4
- package/src/recorder.ts +73 -1
|
@@ -63,6 +63,7 @@ import {
|
|
|
63
63
|
type ServicesMap,
|
|
64
64
|
} from "../index.js";
|
|
65
65
|
import { SQL, type SqlClient } from "../sql.js";
|
|
66
|
+
import { email, emailHelpers, type EmailHelpers } from "./email.js";
|
|
66
67
|
|
|
67
68
|
// ──────────────────────────────────────────────────────────────────────────
|
|
68
69
|
// Pinned upstream image tags (github.com/supabase/supabase docker-compose.yml,
|
|
@@ -132,12 +133,45 @@ export interface SupabaseOptions {
|
|
|
132
133
|
*/
|
|
133
134
|
hostname?: string;
|
|
134
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Capture GoTrue's outgoing mail (magic links, OTPs, password recovery,
|
|
138
|
+
* invites — and signup confirmations with `autoconfirm: false`) with the
|
|
139
|
+
* standard `email()` component. **On by default**: the stack includes the
|
|
140
|
+
* capture server (`<name>-mail`) with GoTrue's SMTP wired at it, and tests
|
|
141
|
+
* read the mailbox through the typed helpers at `ctx.svc.<name>.mail`
|
|
142
|
+
* (`lastEmail` / `emails` / `clear`), each captured message rendering
|
|
143
|
+
* mail-client-style on the dashboard timeline.
|
|
144
|
+
*
|
|
145
|
+
* Signups stay one-step by default (`autoconfirm: true`) so the mailbox is
|
|
146
|
+
* purely additive; pass `{ autoconfirm: false }` to test real
|
|
147
|
+
* email-confirmation flows. Pass `{ service: "<key>" }` to point GoTrue at
|
|
148
|
+
* an `email()` service the environment already declares — keep a single
|
|
149
|
+
* mailbox per environment rather than one per component: if the app under
|
|
150
|
+
* test also sends mail, share one `email()` between it and Supabase
|
|
151
|
+
* instead of standing up a second capture server. `false` disables mail
|
|
152
|
+
* capture entirely.
|
|
153
|
+
*/
|
|
154
|
+
mail?: boolean | SupabaseMailOptions;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Extra API keys the gateway accepts alongside the derived JWT keys — for
|
|
158
|
+
* clients that ship a hardcoded key (e.g. an `sb_publishable_…` /
|
|
159
|
+
* `sb_secret_…` pair baked into an app build). Each `anon` key is admitted
|
|
160
|
+
* with anon privileges and each `serviceRole` key with service-role
|
|
161
|
+
* privileges; Kong swaps the matching *derived JWT* in before proxying, so
|
|
162
|
+
* GoTrue/PostgREST/Realtime still receive a validly-signed token.
|
|
163
|
+
*/
|
|
164
|
+
extraApiKeys?: { anon?: string[]; serviceRole?: string[] };
|
|
165
|
+
|
|
135
166
|
/**
|
|
136
167
|
* Auto-apply SQL migrations found in the project before any dependent service
|
|
137
168
|
* starts. `true` (default) applies every `*.sql` under `supabase/migrations/`
|
|
138
169
|
* (sorted, the Supabase convention), then `supabase/seed.sql` if present — a
|
|
139
170
|
* no-op when the directory is absent. Pass a string to point at a different
|
|
140
171
|
* directory (relative to the project root or absolute), or `false` to skip.
|
|
172
|
+
* An *explicitly configured* path that doesn't resolve is an error at env
|
|
173
|
+
* start (a silent no-op there surfaces much later as a confusing
|
|
174
|
+
* `relation … does not exist`).
|
|
141
175
|
* Because `supabase/**` is project content, editing a migration correctly
|
|
142
176
|
* forces a cold rebuild and re-apply (unlike `spectest/tests/**`).
|
|
143
177
|
*/
|
|
@@ -150,6 +184,28 @@ export interface SupabaseOptions {
|
|
|
150
184
|
seed?: boolean | string;
|
|
151
185
|
}
|
|
152
186
|
|
|
187
|
+
/** Tuning for `SupabaseOptions.mail`. */
|
|
188
|
+
export interface SupabaseMailOptions {
|
|
189
|
+
/**
|
|
190
|
+
* Reuse an `email()` service the environment already declares (its
|
|
191
|
+
* services-map key) instead of adding one to the group — the way to keep a
|
|
192
|
+
* single global mailbox when the app under test sends mail too. Assumes
|
|
193
|
+
* the service's default ports (SMTP 1025, API 8025).
|
|
194
|
+
*/
|
|
195
|
+
service?: string;
|
|
196
|
+
/**
|
|
197
|
+
* Whether GoTrue autoconfirms signups. `true` (default): signups complete
|
|
198
|
+
* in one step and send no confirmation mail — recovery, magic-link and OTP
|
|
199
|
+
* mail is still sent and captured. Set `false` to exercise real
|
|
200
|
+
* email-confirmation flows (signups require the emailed verification).
|
|
201
|
+
*/
|
|
202
|
+
autoconfirm?: boolean;
|
|
203
|
+
/** Sender address GoTrue mails from. Default `admin@example.com`. */
|
|
204
|
+
adminEmail?: string;
|
|
205
|
+
/** Sender display name. Default `Supabase`. */
|
|
206
|
+
senderName?: string;
|
|
207
|
+
}
|
|
208
|
+
|
|
153
209
|
/** Helpers the gateway service exposes on `ctx.svc.<name>`. */
|
|
154
210
|
export interface SupabaseHelpers {
|
|
155
211
|
/**
|
|
@@ -165,6 +221,18 @@ export interface SupabaseHelpers {
|
|
|
165
221
|
anonKey: string;
|
|
166
222
|
/** Long-lived `service_role` API key (HS256 JWT). Bypasses RLS — server-side only. */
|
|
167
223
|
serviceRoleKey: string;
|
|
224
|
+
/**
|
|
225
|
+
* The captured-mail mailbox (`lastEmail` / `emails` / `clear`) — present
|
|
226
|
+
* unless the stack was built with `mail: false`. Wait for delivery with
|
|
227
|
+
* the standard `ctx.poll`:
|
|
228
|
+
*
|
|
229
|
+
* ```ts
|
|
230
|
+
* const mail = await ctx.poll("confirmation email", () =>
|
|
231
|
+
* ctx.svc.supabase.mail!.lastEmail());
|
|
232
|
+
* expect(mail.to).toContain("alice@example.com");
|
|
233
|
+
* ```
|
|
234
|
+
*/
|
|
235
|
+
mail?: EmailHelpers;
|
|
168
236
|
}
|
|
169
237
|
|
|
170
238
|
/** What `supabase()` returns: the service group plus the derived
|
|
@@ -185,6 +253,12 @@ export interface SupabaseStack {
|
|
|
185
253
|
/** The gateway service key — use in a dependent service's `dependsOn` to wait
|
|
186
254
|
* for the whole stack (the gateway waits on the services behind it). */
|
|
187
255
|
ready: string;
|
|
256
|
+
/**
|
|
257
|
+
* Where the stack's SMTP capture server listens (absent with
|
|
258
|
+
* `mail: false`) — point the app under test's own mail transport here to
|
|
259
|
+
* share the one mailbox: `env: { SMTP_HOST: sb.smtp.host, SMTP_PORT: String(sb.smtp.port) }`.
|
|
260
|
+
*/
|
|
261
|
+
smtp?: { host: string; port: number };
|
|
188
262
|
/** Ready-to-spread env for the app under test: `SUPABASE_URL`,
|
|
189
263
|
* `SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `DATABASE_URL`. */
|
|
190
264
|
appEnv: Record<string, string>;
|
|
@@ -277,12 +351,32 @@ function buildBootstrapSql(
|
|
|
277
351
|
// ──────────────────────────────────────────────────────────────────────────
|
|
278
352
|
|
|
279
353
|
// The two Lua router expressions kong-entrypoint.sh emits when the opaque
|
|
280
|
-
// `sb_`-style keys are NOT configured
|
|
281
|
-
//
|
|
282
|
-
// Authorization header.
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
354
|
+
// `sb_`-style keys are NOT configured: pass the incoming apikey (a legacy
|
|
355
|
+
// HS256 JWT) through unchanged, preferring an existing non-`sb_`
|
|
356
|
+
// Authorization header. `extraApiKeys` extends both: an extra key (arriving
|
|
357
|
+
// as `apikey` header, `Authorization: Bearer <key>`, or the websocket
|
|
358
|
+
// `?apikey=` query param) is swapped for the corresponding *derived JWT*
|
|
359
|
+
// before proxying, so the upstream services still see a validly-signed token.
|
|
360
|
+
function buildLuaExprs(
|
|
361
|
+
extra: Array<{ key: string; jwt: string }>,
|
|
362
|
+
): { auth: string; realtimeWs: string } {
|
|
363
|
+
// Keep a client's `Authorization: Bearer <extraKey>` from winning the
|
|
364
|
+
// header-passthrough clause with the raw (non-JWT) key.
|
|
365
|
+
const guards = extra
|
|
366
|
+
.map((m) => ` and headers.authorization ~= 'Bearer ${m.key}'`)
|
|
367
|
+
.join("");
|
|
368
|
+
const mappings = extra
|
|
369
|
+
.map(
|
|
370
|
+
(m) =>
|
|
371
|
+
` or ((headers.apikey == '${m.key}' or headers.authorization == 'Bearer ${m.key}') and 'Bearer ${m.jwt}')`,
|
|
372
|
+
)
|
|
373
|
+
.join("");
|
|
374
|
+
const auth = `$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_'${guards} and headers.authorization)${mappings} or headers.apikey)`;
|
|
375
|
+
const wsMappings = extra
|
|
376
|
+
.map((m) => `(query_params.apikey == '${m.key}' and '${m.jwt}') or `)
|
|
377
|
+
.join("");
|
|
378
|
+
return { auth, realtimeWs: `$(${wsMappings}query_params.apikey)` };
|
|
379
|
+
}
|
|
286
380
|
|
|
287
381
|
const KONG_YML_RAW = String.raw`_format_version: '2.1'
|
|
288
382
|
_transform: true
|
|
@@ -631,9 +725,26 @@ function buildKongYaml(
|
|
|
631
725
|
serviceRoleKey: string;
|
|
632
726
|
dashboardUsername: string;
|
|
633
727
|
dashboardPassword: string;
|
|
728
|
+
extraAnonKeys: string[];
|
|
729
|
+
extraServiceRoleKeys: string[];
|
|
634
730
|
},
|
|
635
731
|
): string {
|
|
636
732
|
let y = KONG_YML_RAW;
|
|
733
|
+
// Extra API keys land where kong-entrypoint.sh's opaque keys would: as
|
|
734
|
+
// additional keyauth credentials on the matching consumer. The template's
|
|
735
|
+
// placeholder lines are either expanded here or dropped by the filter below.
|
|
736
|
+
if (vals.extraAnonKeys.length > 0) {
|
|
737
|
+
y = y.replace(
|
|
738
|
+
" - key: $SUPABASE_PUBLISHABLE_KEY",
|
|
739
|
+
vals.extraAnonKeys.map((k) => ` - key: ${k}`).join("\n"),
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
if (vals.extraServiceRoleKeys.length > 0) {
|
|
743
|
+
y = y.replace(
|
|
744
|
+
" - key: $SUPABASE_SECRET_KEY",
|
|
745
|
+
vals.extraServiceRoleKeys.map((k) => ` - key: ${k}`).join("\n"),
|
|
746
|
+
);
|
|
747
|
+
}
|
|
637
748
|
// Rewrite upstream bare service hosts → the group's final DNS names.
|
|
638
749
|
y = y
|
|
639
750
|
.replaceAll("http://auth:9999", `http://${key("auth")}:9999`)
|
|
@@ -644,9 +755,13 @@ function buildKongYaml(
|
|
|
644
755
|
.replaceAll("http://functions:9000", `http://${key("functions")}:9000`)
|
|
645
756
|
.replaceAll("realtime-dev.supabase-realtime", `realtime-dev.${key("realtime")}`);
|
|
646
757
|
// Lua expressions first (they contain no `$SUPABASE_*` tokens).
|
|
647
|
-
|
|
758
|
+
const lua = buildLuaExprs([
|
|
759
|
+
...vals.extraAnonKeys.map((k) => ({ key: k, jwt: vals.anonKey })),
|
|
760
|
+
...vals.extraServiceRoleKeys.map((k) => ({ key: k, jwt: vals.serviceRoleKey })),
|
|
761
|
+
]);
|
|
762
|
+
y = y.replaceAll("$LUA_AUTH_EXPR", lua.auth).replaceAll(
|
|
648
763
|
"$LUA_RT_WS_EXPR",
|
|
649
|
-
|
|
764
|
+
lua.realtimeWs,
|
|
650
765
|
);
|
|
651
766
|
// API keys + dashboard credentials.
|
|
652
767
|
y = y
|
|
@@ -707,14 +822,17 @@ async function applyMigrations(
|
|
|
707
822
|
ctx: ServiceSetupContext,
|
|
708
823
|
cfg: { migrations: boolean | string; seed: boolean | string },
|
|
709
824
|
): Promise<void> {
|
|
710
|
-
// Migrations directory.
|
|
825
|
+
// Migrations directory. An *explicitly configured* path that doesn't
|
|
826
|
+
// resolve is a hard error — a silent no-op here surfaces much later as a
|
|
827
|
+
// baffling `relation "public.…" does not exist`. Only the `true` default
|
|
828
|
+
// (conventional `supabase/migrations/`) is allowed to be absent.
|
|
711
829
|
if (cfg.migrations !== false) {
|
|
712
|
-
const
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
830
|
+
const explicit = typeof cfg.migrations === "string";
|
|
831
|
+
const dir = explicit
|
|
832
|
+
? (cfg.migrations as string).startsWith("/")
|
|
833
|
+
? (cfg.migrations as string)
|
|
834
|
+
: join(ctx.projectRoot, cfg.migrations as string)
|
|
835
|
+
: join(ctx.projectRoot, "supabase", "migrations");
|
|
718
836
|
if (existsSync(dir) && (await stat(dir)).isDirectory()) {
|
|
719
837
|
const files = (await readdir(dir))
|
|
720
838
|
.filter((f) => f.endsWith(".sql"))
|
|
@@ -723,19 +841,34 @@ async function applyMigrations(
|
|
|
723
841
|
const sql = await ctx.readProjectFile(join(dir, f));
|
|
724
842
|
await applySqlFile(ctx, sql, `migration ${f}`, "postgres");
|
|
725
843
|
}
|
|
844
|
+
} else if (explicit) {
|
|
845
|
+
throw new Error(
|
|
846
|
+
`supabase: configured migrations path ${JSON.stringify(
|
|
847
|
+
cfg.migrations,
|
|
848
|
+
)} does not resolve to a directory (looked at ${dir}). ` +
|
|
849
|
+
`Fix the \`migrations\` option, or remove it to use the default ` +
|
|
850
|
+
`supabase/migrations/. Note the path is resolved inside the uploaded ` +
|
|
851
|
+
`project — check it isn't ignored (e.g. via .spectestignore).`,
|
|
852
|
+
);
|
|
726
853
|
}
|
|
727
854
|
}
|
|
728
|
-
// Seed file.
|
|
855
|
+
// Seed file — same contract: only the conventional default may be absent.
|
|
729
856
|
if (cfg.seed !== false) {
|
|
730
|
-
const
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
857
|
+
const explicit = typeof cfg.seed === "string";
|
|
858
|
+
const seedPath = explicit
|
|
859
|
+
? (cfg.seed as string).startsWith("/")
|
|
860
|
+
? (cfg.seed as string)
|
|
861
|
+
: join(ctx.projectRoot, cfg.seed as string)
|
|
862
|
+
: join(ctx.projectRoot, "supabase", "seed.sql");
|
|
736
863
|
if (existsSync(seedPath)) {
|
|
737
864
|
const sql = await ctx.readProjectFile(seedPath);
|
|
738
865
|
await applySqlFile(ctx, sql, "seed.sql", "postgres");
|
|
866
|
+
} else if (explicit) {
|
|
867
|
+
throw new Error(
|
|
868
|
+
`supabase: configured seed path ${JSON.stringify(cfg.seed)} not found ` +
|
|
869
|
+
`(looked at ${seedPath}). Fix the \`seed\` option or remove it to ` +
|
|
870
|
+
`use the default supabase/seed.sql.`,
|
|
871
|
+
);
|
|
739
872
|
}
|
|
740
873
|
}
|
|
741
874
|
}
|
|
@@ -773,6 +906,29 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
773
906
|
const withStudio = opts.studio ?? false;
|
|
774
907
|
const withMeta = opts.meta ?? withStudio;
|
|
775
908
|
|
|
909
|
+
// Mail capture (see SupabaseOptions.mail) — on unless explicitly disabled.
|
|
910
|
+
// `mailKey` is the SMTP host GoTrue mails through — the in-group part
|
|
911
|
+
// (`<name>-mail`) or a user-declared `email()` service shared with the app
|
|
912
|
+
// under test.
|
|
913
|
+
const withMail = opts.mail !== false;
|
|
914
|
+
const mailOpts: SupabaseMailOptions =
|
|
915
|
+
typeof opts.mail === "object" ? opts.mail : {};
|
|
916
|
+
const mailExternal = mailOpts.service !== undefined;
|
|
917
|
+
const mailKey = mailOpts.service ?? `${name}-mail`;
|
|
918
|
+
const mailAutoconfirm = mailOpts.autoconfirm ?? true;
|
|
919
|
+
|
|
920
|
+
const extraAnonKeys = opts.extraApiKeys?.anon ?? [];
|
|
921
|
+
const extraServiceRoleKeys = opts.extraApiKeys?.serviceRole ?? [];
|
|
922
|
+
for (const k of [...extraAnonKeys, ...extraServiceRoleKeys]) {
|
|
923
|
+
// The keys are spliced into Kong's YAML and its quoted Lua expressions —
|
|
924
|
+
// restrict to the token charset real API keys use so neither can break.
|
|
925
|
+
if (!/^[A-Za-z0-9._-]+$/.test(k)) {
|
|
926
|
+
throw new Error(
|
|
927
|
+
`supabase(): extraApiKeys entries must match [A-Za-z0-9._-]+ (got ${JSON.stringify(k)})`,
|
|
928
|
+
);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
776
932
|
const anonKey = apiKey("anon", jwtSecret);
|
|
777
933
|
const serviceRoleKey = apiKey("service_role", jwtSecret);
|
|
778
934
|
|
|
@@ -872,6 +1028,11 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
872
1028
|
readyCheck: { type: "tcp", port: 3000, timeoutSecs: 120 },
|
|
873
1029
|
};
|
|
874
1030
|
|
|
1031
|
+
// ── mail (SMTP capture, the standard email() component) ─────────
|
|
1032
|
+
if (withMail && !mailExternal) {
|
|
1033
|
+
parts.mail = email();
|
|
1034
|
+
}
|
|
1035
|
+
|
|
875
1036
|
// ── auth (GoTrue) ───────────────────────────────────────────────
|
|
876
1037
|
if (withAuth) {
|
|
877
1038
|
const apiExternalUrl = `${url}/auth/v1`;
|
|
@@ -894,14 +1055,39 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
894
1055
|
GOTRUE_JWT_ISSUER: apiExternalUrl,
|
|
895
1056
|
GOTRUE_EXTERNAL_EMAIL_ENABLED: "true",
|
|
896
1057
|
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: "true",
|
|
897
|
-
// Autoconfirm
|
|
898
|
-
//
|
|
899
|
-
|
|
1058
|
+
// Autoconfirm defaults ON (one-step signups, no confirmation
|
|
1059
|
+
// mail) so the always-on mailbox is purely additive; the mail
|
|
1060
|
+
// option's `autoconfirm: false` opts into real confirmation
|
|
1061
|
+
// flows.
|
|
1062
|
+
GOTRUE_MAILER_AUTOCONFIRM:
|
|
1063
|
+
withMail && !mailAutoconfirm ? "false" : "true",
|
|
900
1064
|
GOTRUE_EXTERNAL_PHONE_ENABLED: "false",
|
|
901
1065
|
GOTRUE_SMS_AUTOCONFIRM: "true",
|
|
1066
|
+
...(withMail
|
|
1067
|
+
? {
|
|
1068
|
+
GOTRUE_SMTP_HOST: mailKey,
|
|
1069
|
+
GOTRUE_SMTP_PORT: "1025",
|
|
1070
|
+
// Deliberately NO GOTRUE_SMTP_USER/PASS: the capture
|
|
1071
|
+
// server advertises AUTH, and with credentials set
|
|
1072
|
+
// GoTrue's Go smtp.PlainAuth refuses to send them over a
|
|
1073
|
+
// plaintext connection ("unencrypted connection" → every
|
|
1074
|
+
// mail 500s). Unauthenticated submission is accepted.
|
|
1075
|
+
GOTRUE_SMTP_ADMIN_EMAIL:
|
|
1076
|
+
mailOpts.adminEmail ?? "admin@example.com",
|
|
1077
|
+
GOTRUE_SMTP_SENDER_NAME: mailOpts.senderName ?? "Supabase",
|
|
1078
|
+
// GoTrue rate-limits repeat mail to the same address
|
|
1079
|
+
// (default 1/min) — far too slow for tests that
|
|
1080
|
+
// request an OTP, assert, and request again.
|
|
1081
|
+
GOTRUE_SMTP_MAX_FREQUENCY: "1s",
|
|
1082
|
+
GOTRUE_MAILER_URLPATHS_INVITE: "/auth/v1/verify",
|
|
1083
|
+
GOTRUE_MAILER_URLPATHS_CONFIRMATION: "/auth/v1/verify",
|
|
1084
|
+
GOTRUE_MAILER_URLPATHS_RECOVERY: "/auth/v1/verify",
|
|
1085
|
+
GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: "/auth/v1/verify",
|
|
1086
|
+
}
|
|
1087
|
+
: {}),
|
|
902
1088
|
},
|
|
903
1089
|
ports: [9999],
|
|
904
|
-
dependsOn: ["db"],
|
|
1090
|
+
dependsOn: ["db", ...(withMail && !mailExternal ? ["mail"] : [])],
|
|
905
1091
|
readyCheck: { type: "http", port: 9999, path: "/health", timeoutSecs: 90 },
|
|
906
1092
|
};
|
|
907
1093
|
}
|
|
@@ -1071,6 +1257,8 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
1071
1257
|
serviceRoleKey,
|
|
1072
1258
|
dashboardUsername,
|
|
1073
1259
|
dashboardPassword,
|
|
1260
|
+
extraAnonKeys,
|
|
1261
|
+
extraServiceRoleKeys,
|
|
1074
1262
|
}),
|
|
1075
1263
|
},
|
|
1076
1264
|
],
|
|
@@ -1087,6 +1275,7 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
1087
1275
|
url,
|
|
1088
1276
|
anonKey,
|
|
1089
1277
|
serviceRoleKey,
|
|
1278
|
+
...(withMail ? { mail: emailHelpers(mailKey) } : {}),
|
|
1090
1279
|
}),
|
|
1091
1280
|
});
|
|
1092
1281
|
|
|
@@ -1105,5 +1294,6 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
1105
1294
|
dbUrl,
|
|
1106
1295
|
ready: name,
|
|
1107
1296
|
appEnv,
|
|
1297
|
+
...(withMail ? { smtp: { host: mailKey, port: 1025 } } : {}),
|
|
1108
1298
|
};
|
|
1109
1299
|
}
|