Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 23 additions & 8 deletions apps/cli/src/command-internal/config-validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -611,7 +611,15 @@ export function signingKeysDecodeErrorMessage(cause: unknown): string {
* project root all fail. This applies unconditionally to every caller (config validation,
* `config push` content loading, `start`'s pre-Docker containment pass) — there is no opt-out.
*/
export const resolveEmailTemplateContentPath = Effect.fnUntraced(function* (args: {
export const resolveEmailTemplateContentPath = (
args: Parameters<typeof resolveEmailTemplatePaths>[0],
) => resolveEmailTemplatePaths(args).pipe(Effect.map((paths) => paths?.canonical));

/**
* {@link resolveEmailTemplateContentPath} with the configured candidate it validated, resolved
* once, which the stack reads per fetch so an in-root link may retarget.
*/
export const resolveEmailTemplatePaths = Effect.fnUntraced(function* (args: {
readonly section: "template" | "notification";
readonly name: string;
/** Post-env-expand; `""` = absent. */
Expand All @@ -629,12 +637,7 @@ export const resolveEmailTemplateContentPath = Effect.fnUntraced(function* (args
return undefined;
}
const path = yield* Path.Path;
const candidate =
args.section === "notification"
? yield* resolveNotificationContentPath(args.base, args.contentPath)
: path.isAbsolute(args.contentPath)
? args.contentPath
: path.join(args.base, args.contentPath);
const candidate = yield* emailTemplateCandidatePath(args.section, args.base, args.contentPath);
const resolvedCanonical = yield* canonicalPathForContainment(candidate);
const canonicalBase = yield* canonicalPathForContainment(args.base);
if (!isPathContainedInRoot(path, canonicalBase, resolvedCanonical)) {
Expand All @@ -647,7 +650,19 @@ export const resolveEmailTemplateContentPath = Effect.fnUntraced(function* (args
`move the file inside the project, or use a relative path that stays inside it.`,
);
}
return resolvedCanonical;
return { candidate, canonical: resolvedCanonical };
});

/* A non-empty `content_path` as configured, joined to `base` but not dereferenced, with the
notification fallback below. */
const emailTemplateCandidatePath = Effect.fnUntraced(function* (
section: "template" | "notification",
base: string,
contentPath: string,
) {
const path = yield* Path.Path;
if (section === "notification") return yield* resolveNotificationContentPath(base, contentPath);
return path.isAbsolute(contentPath) ? contentPath : path.join(base, contentPath);
});

/**
Expand Down
50 changes: 41 additions & 9 deletions apps/cli/src/command-internal/stack-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
resolveAuthExternalProviders,
} from "./local-config-values.ts";
import { generateAsymmetricLocalJwt } from "./local-jwt.ts";
import { resolveEmailTemplatePaths } from "./config-validate.ts";
import { recordOrioleDbTelemetry } from "./db-image.ts";
import {
resolveRemoteJwks,
Expand Down Expand Up @@ -233,14 +234,6 @@ const firstDifference = (left: unknown, right: unknown, path: string): string |

const configValidationError = (config: CliConfig): string | undefined => {
const defaults = getDefaultCliConfig();
if (config.auth.enabled) {
for (const [name, template] of Object.entries(config.auth.email.template))
if (template.content_path !== "")
return `auth.email.template.${name}.content_path requires template serving, which is not supported by the experimental stack`;
for (const [name, notification] of Object.entries(config.auth.email.notification))
if (notification.enabled && notification.content_path !== "")
return `auth.email.notification.${name}.content_path requires template serving, which is not supported by the experimental stack`;
}
for (const { path, active } of unsupportedConfigPaths) {
if (!active(config)) continue;
const value = pathValue(config, path);
Expand Down Expand Up @@ -546,10 +539,48 @@ export const loadStackConfig = Effect.fn("StackConfig.load")(
config: {},
endpoints: { http: endpoint(undefined) },
};
const { template, notification } = validatedConfig.auth.email;
const emailTemplates = Effect.forEach(
!validatedConfig.auth.enabled
? []
: [
...Object.entries(template).map(([name, entry]) => ({
section: "template" as const,
name,
id: name,
contentPath: entry.content_path,
})),
...Object.entries(notification)
.filter(([, entry]) => entry.enabled)
.map(([name, entry]) => ({
section: "notification" as const,
name,
id: `${name}_notification`,
contentPath: entry.content_path,
})),
],
({ section, name, id, contentPath }) =>
resolveEmailTemplatePaths({
section,
name,
contentPath,
contentPresent: false,
base: projectRoot,
}).pipe(
Effect.map((paths) =>
paths === undefined ? [] : [{ id, path: paths.candidate, root: projectRoot }],
),
),
).pipe(
Effect.map((templates) => templates.flat()),
Effect.provideService(FileSystem.FileSystem, yield* FileSystem.FileSystem),
Effect.provideService(Path.Path, path),
Effect.mapError(toStackConfigError),
);
const createCreations = (
stackId: string,
): Effect.Effect<ReadonlyArray<ServiceCreationType>, StackConfigError> =>
Effect.sync(() => {
Effect.map(emailTemplates, (templates) => {
return [
{
service: "database",
Expand Down Expand Up @@ -619,6 +650,7 @@ export const loadStackConfig = Effect.fn("StackConfig.load")(
config: {
...authConfig,
...(jwtSecret === undefined ? {} : { jwtSecret: Redacted.value(jwtSecret) }),
...(templates.length === 0 ? {} : { templates }),
},
endpoints: { http: endpoint(apiPort) },
} satisfies ServiceCreationType,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,15 +223,103 @@ port = 55431
}).pipe(Effect.provide([BunServices.layer, BunCrypto.layer])),
);

it.effect("rejects Auth email template content paths deferred by the stack", () =>
it.effect("resolves Auth template and enabled notification files for the stack", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* project(`project_id = "stack-config-auth-template"
[auth.email.template.invite]
content_path = "./templates/invite.html"
subject = "Join us"
content_path = "./supabase/templates/invite.html"
[auth.email.notification.password_changed]
enabled = true
subject = "Changed"
content_path = "./templates/password_changed.txt"
[auth.email.notification.email_changed]
enabled = false
content_path = "./supabase/templates/disabled.html"
`);
const exit = yield* load(root).pipe(Effect.exit);
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) expect(String(exit.cause)).toContain("auth.email");
const templates = path.join(root, "supabase", "templates");
yield* fs.makeDirectory(templates);
yield* fs.writeFileString(path.join(templates, "invite.html"), "invite");
yield* fs.writeFileString(path.join(templates, "password_changed.txt"), "changed");

const config = yield* load(root);
const auth = byService(yield* config.creations("stack-auth-template")).get("auth");
if (auth?.service !== "auth") return yield* Effect.die("Auth creation missing");

expect(auth.config.templates).toEqual([
{ id: "invite", path: path.join(templates, "invite.html"), root },
{
id: "password_changed_notification",
path: path.join(templates, "password_changed.txt"),
root,
},
]);
expect(auth.config.settings?.email?.template.invite?.subject).toBe("Join us");
expect(auth.config.settings?.email?.notification.password_changed?.subject).toBe("Changed");
}).pipe(Effect.provide([BunServices.layer, BunCrypto.layer])),
);

it.effect(
"keeps an in-root symlinked Auth template, refuses an escaping one, and leaves a missing one to the start check",
() =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const missing = yield* project(`project_id = "stack-config-auth-template-missing"
[auth.email.template.invite]
content_path = "./supabase/templates/missing.html"
`);
const unread = byService(yield* (yield* load(missing)).creations("stack-auth-missing"));
expect(unread.get("auth")).toMatchObject({
config: { templates: [{ id: "invite" }] },
});

const linked = yield* project(`project_id = "stack-config-auth-template-link"
[auth.email.template.invite]
content_path = "./supabase/templates/invite.html"
`);
yield* fs.makeDirectory(path.join(linked, "supabase", "templates"));
yield* fs.writeFileString(path.join(linked, "shared.html"), "shared");
yield* fs.writeFileString(path.join(linked, "retargeted.html"), "retargeted");
const link = path.join(linked, "supabase", "templates", "invite.html");
yield* fs.symlink("../../shared.html", link);
const kept = byService(yield* (yield* load(linked)).creations("stack-auth-link"));
expect(kept.get("auth")).toMatchObject({
config: { templates: [{ id: "invite", path: link, root: linked }] },
});
/* The stack reads the configured path per fetch, so a retargeted link serves its new file. */
yield* fs.remove(link);
yield* fs.symlink("../../retargeted.html", link);
const auth = kept.get("auth");
const served = auth?.service === "auth" ? auth.config.templates?.[0]?.path : undefined;
expect(yield* fs.readFileString(served ?? "")).toBe("retargeted");

const escaping = yield* project(`project_id = "stack-config-auth-template-escape"
[auth.email.notification.password_changed]
enabled = true
content_path = "./supabase/link.html"
`);
const outside = path.join(yield* fs.makeTempDirectoryScoped(), "outside.html");
yield* fs.writeFileString(outside, "outside");
yield* fs.symlink(outside, path.join(escaping, "supabase", "link.html"));
const config = yield* load(escaping);
const failure = yield* Effect.flip(config.creations("stack-auth-template-escape"));
expect(failure.message).toContain("resolves outside the project root");
}).pipe(Effect.provide([BunServices.layer, BunCrypto.layer])),
);

it.effect("ignores Auth template files while Auth is disabled", () =>
Effect.gen(function* () {
const root = yield* project(`project_id = "stack-config-auth-template-disabled"
[auth]
enabled = false
[auth.email.template.invite]
content_path = "./supabase/templates/missing.html"
`);
const config = yield* load(root);
expect(byService(yield* config.creations("stack-auth-disabled")).has("auth")).toBe(false);
}).pipe(Effect.provide([BunServices.layer, BunCrypto.layer])),
);

Expand Down
21 changes: 19 additions & 2 deletions apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,25 @@ the effective root key is supplied through a stack-owned key file.
Studio receives the database connection, the Functions management directory/URL, and Analytics
credentials when present. Starting with Studio creates `supabase/snippets/`, where Studio saves SQL
snippets.
Email template `content_path` values and third-party identity providers remain unsupported: they
require template serving and shared external JWKS verification respectively.
Third-party identity providers remain unsupported: they require shared external JWKS verification.
While Auth is enabled, every `auth.email.template.*` and enabled `auth.email.notification.*`
`content_path` is held inside the project root, and start reads each one before launch through the
shared `config.toml` check, because Auth mails its built-in template when a fetch fails. While the
stack runs, the owner process serves those files at `/email/<id>` on the shared API listener, with
no new listener or port. Auth gets that address on its runtime host with a random token generated at
every `stack start`. The route answers only GET on the configured paths with that token, as
`text/plain` with `nosniff` and no CORS header, and reads the file on every request: the stack
resolves the configured path on every fetch, so an in-root symlink is served and retargeting it
applies at the next fetch, and the resolved file must be a regular file inside the project root,
else 404. The route is as reachable as the API port: Docker and Podman stacks listen on every
interface, so a LAN peer gets 404 without the token, and native stacks listen on loopback only. The
token limits who can fetch template source through this route; it does not make template text
private. A failed fetch or parse puts the token in Auth's logs, masked in shipped Analytics events
but not in `stack logs`, and a caller reaching the API and Mailpit ports can have a rendered
template mailed and read it there. A changed `content_path` applies after stop and start. Auth
sleeps after 60 s idle unless the stack started with `--eager`, and each wake refetches. A replaced
file reaches mail at the next wake, or once Auth's template cache expires. A deleted file mails
Auth's cached copy until the next wake, then the built-in template.
`db.orioledb_version` (or `SUPABASE_DB_ORIOLEDB_VERSION`) is a `local.services.database.version`
request for that OrioleDB build instead of the stock major, resolved like any other version (see
"Service versions and the lock"); the two keys must agree when both are set, and `db.major_version`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -768,6 +768,28 @@ describe("experimental stack start", () => {
}).pipe(Effect.provide([BunServices.layer, BunCrypto.layer])),
);

it.effect("refuses an unreadable Auth email template before creating a stack", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-template-" });
yield* fs.makeDirectory(`${root}/supabase`, { recursive: true });
yield* fs.writeFileString(
`${root}/supabase/config.toml`,
'[auth.email.template.invite]\ncontent_path = "./supabase/templates/missing.html"\n',
);
const fixture = fakeStack();
const error = yield* stackStart(flags()).pipe(
Effect.provide(layers(root, fixture)),
Effect.flip,
);
expect(error).toMatchObject({
reason: "invalid-config",
message: expect.stringContaining("auth.email.template.invite.content_path"),
});
expect(fixture.composed).toBe(0);
}).pipe(Effect.provide([BunServices.layer, BunCrypto.layer])),
);

it.effect("rejects malformed configuration before creating a stack", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
Expand Down
36 changes: 31 additions & 5 deletions apps/cli/tests/contract/cases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import { createClient, type RealtimeChannel } from "@supabase/supabase-js";
import { Data, Deferred, Effect, Layer, Option, Random, Schema, type Scope, Stream } from "effect";
import { FetchHttpClient, HttpClient, type HttpClientError } from "effect/http";
import * as Socket from "effect/socket/Socket";
import { ContractReset, ContractTarget, type ContractTargetError } from "./target.ts";
import {
ContractReset,
ContractTarget,
type ContractTargetError,
inviteTemplateMarker,
} from "./target.ts";

/** One consumer behavior, run unchanged against every setup's {@link ContractTarget}. */
interface ContractCase {
Expand Down Expand Up @@ -119,9 +124,10 @@ const decodeEvent = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown));
const mailError = (message: string, cause?: unknown) =>
new ContractCallError({ message: `Mailpit ${message}`, cause });

/** Signs `email` up while subscribed to Mailpit's events, then returns the link mailed to it. */
const signUpConfirmationLink = Effect.fn("Contract.signUpConfirmationLink")(function* (
/** Runs `send` while subscribed to Mailpit's events, then returns the mail it sent `email`. */
const mailedTo = Effect.fn("Contract.mailedTo")(function* <E, R>(
email: string,
send: Effect.Effect<unknown, E, R>,
) {
const { MAILPIT_URL } = yield* ContractTarget;
const events = new URL("/api/events", MAILPIT_URL);
Expand All @@ -130,7 +136,7 @@ const signUpConfirmationLink = Effect.fn("Contract.signUpConfirmationLink")(func
Effect.flatMap(Socket.readerString),
Effect.mapError((cause) => mailError("events socket", cause)),
);
yield* signUp(email);
yield* send;
const mail = yield* Stream.fromEffectRepeat(
Effect.mapError(pull, (cause) => mailError("events socket", cause)),
).pipe(
Expand All @@ -142,7 +148,14 @@ const signUpConfirmationLink = Effect.fn("Contract.signUpConfirmationLink")(func
);
if (Option.isNone(mail)) return yield* mailError("event stream ended");
const response = yield* HttpClient.get(`${MAILPIT_URL}/api/v1/message/${mail.value.Data.ID}`);
const { Text, HTML } = yield* Schema.decodeUnknownEffect(MailBody)(yield* response.json);
return yield* Schema.decodeUnknownEffect(MailBody)(yield* response.json);
});

/** Signs `email` up, then returns the link mailed to it. */
const signUpConfirmationLink = Effect.fn("Contract.signUpConfirmationLink")(function* (
email: string,
) {
const { Text, HTML } = yield* mailedTo(email, signUp(email));
const link = `${Text}\n${HTML}`
.replaceAll("&amp;", "&")
.match(/https?:\/\/[^\s"'<>]+\/verify\?[^\s"'<>]+/);
Expand Down Expand Up @@ -174,6 +187,19 @@ const authCases: ReadonlyArray<ContractCase> = [
expect(data.user?.email).toBe(email);
}),
},
{
name: "Auth mails an invite through the configured content_path template",
run: Effect.gen(function* () {
const email = `${yield* unique("invited")}@example.com`;
const admin = (yield* contractClient("SECRET_KEY")).auth.admin;
const { HTML } = yield* mailedTo(
email,
sdk("invite", () => admin.inviteUserByEmail(email)),
);
expect(HTML).toContain(inviteTemplateMarker);
expect(HTML).toMatch(/\/verify\?token=/u);
}),
},
];

const bytes = new Uint8Array([1, 2, 3, 250, 251, 252]);
Expand Down
Loading
Loading