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
17 changes: 10 additions & 7 deletions apps/cli/src/legacy/commands/config/push/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ local → if changed, print the unified diff and confirm → PATCH/PUT/POST.
| ------------------------------------------------ | ------------------------- | --------------------------------------------------------------- |
| `<workdir>/supabase/config.toml` | TOML | always, before any network call (parse error aborts, exit 1) |
| `<workdir>/supabase/.env`, `.env.local` | dotenv | always, to resolve `env(VAR)` references inside `config.toml` |
| Auth email template HTML (`content_path`) | HTML | when `auth.enabled`; paths resolved per Go rules (see below) |
| `~/.supabase/<workdir-hash>/linked-project.json` | JSON | project-ref fallback (flag → `SUPABASE_PROJECT_ID` → this file) |
| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable |

Expand Down Expand Up @@ -60,13 +61,14 @@ when its local gate is off.

## Exit Codes

| Code | Condition |
| ---- | ------------------------------------------------------------------------------------- |
| `0` | success, **including** declining a confirmation prompt (Go returns nil and continues) |
| `1` | malformed `config.toml` |
| `1` | two `[remotes.*]` blocks declare the same `project_id` as the target ref |
| `1` | list-addons failure (network or non-200) |
| `1` | any per-service read/update failure (network or unexpected status) |
| Code | Condition |
| ---- | ------------------------------------------------------------------------------------------ |
| `0` | success, **including** declining a confirmation prompt (Go returns nil and continues) |
| `1` | malformed `config.toml` |
| `1` | invalid `auth.email.*.content_path` (missing/unreadable template file when `auth.enabled`) |
| `1` | two `[remotes.*]` blocks declare the same `project_id` as the target ref |
| `1` | list-addons failure (network or non-200) |
| `1` | any per-service read/update failure (network or unexpected status) |

## Output

Expand Down Expand Up @@ -109,6 +111,7 @@ keys mirror `config.toml` paths.
## Notes

- Run from the project root (or pass `--workdir`); `config.toml` is read relative to it.
- Auth email `content_path` resolution (Go parity): `[auth.email.template.*]` paths are relative to the discovered project root; `[auth.email.notification.*]` paths are relative to `supabase/`. Notification HTML is read only when `enabled = true`.
- Diff bytes are byte-for-byte identical to the Go CLI (BurntSushi TOML encoder + anchored diff ports).
- Optional `*pointer` sections (`db.ssl_enforcement`, `storage.image_transformation`, `storage.s3_protocol`) are decoded as defaulted-present by `@supabase/config`; their true presence is recovered from the raw (merged) config document so they are skipped when absent, matching Go's nil-pointer behaviour.
- **`[remotes.*]` overrides are merged before push.** When a `[remotes.<name>]` block declares `project_id == <ref>`, `@supabase/config` merges that block's subtree over the base config at the raw (pre-decode) level — Go's `mergeRemoteConfig` (`apps/cli-go/pkg/config/config.go:550`) — so only the keys the block declares override the base. `Loading config override: [remotes.<name>]` prints to stderr. Two remotes sharing the target `project_id` abort with Go's `duplicate project_id for [remotes.<b>] and [remotes.<a>]` message.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { diff } from "./config-sync.diff.ts";
import { type TomlField, type TomlValue, encodeToml } from "./config-sync.toml.ts";
import { intToUint } from "../../../../shared/legacy-size-units.ts";
import { durationString, parseDuration, secondsToDurationString } from "./config-sync.duration.ts";
import type { AuthEmailContent } from "./config-sync.auth-email-content.ts";
import { secretHash } from "./config-sync.secret.ts";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -912,6 +913,7 @@ export function authSubsetFromConfig(
config: ProjectConfig,
projectId: string,
presence: AuthPresence,
emailContent: AuthEmailContent = { template: {}, notification: {} },
): AuthSubset {
const a = config.auth;

Expand Down Expand Up @@ -1004,15 +1006,16 @@ export function authSubsetFromConfig(
inactivity_timeout: normalizeDurationStr(sessConfig?.inactivity_timeout),
};

// Email templates: TS config has `subject` (string) and `content_path` (string)
// There is no `content` field in the TS config; content is set only via fromAuthConfig.
// Email templates: `content` is loaded from `content_path` by `loadAuthEmailContent`
// before this call (Go's `email.validate`).
const emailTmplMap = a.email.template;
const templateEntries: Record<string, EmailTemplateSubset> = {};
for (const [k, t] of Object.entries(emailTmplMap)) {
const contentPath = t.content_path ?? "";
templateEntries[k] = {
subject: t.subject !== undefined ? t.subject : undefined,
content: undefined, // TS config has no content field
content_path: t.content_path ?? "",
content: contentPath.length > 0 ? emailContent.template[k] : undefined,
content_path: contentPath,
};
}
// Nil map (no templates configured) → undefined, mirrors Go nil map behaviour.
Expand All @@ -1022,11 +1025,12 @@ export function authSubsetFromConfig(
const emailNotifMap = a.email.notification;
const notificationEntries: Record<string, NotificationSubset> = {};
for (const [k, n] of Object.entries(emailNotifMap)) {
const contentPath = n.content_path ?? "";
notificationEntries[k] = {
enabled: n.enabled,
subject: n.subject !== undefined ? n.subject : undefined,
content: undefined, // TS config has no content field
content_path: n.content_path ?? "",
content: n.enabled && contentPath.length > 0 ? emailContent.notification[k] : undefined,
content_path: contentPath,
};
}
// Nil map (no notifications configured) → undefined.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* Port of Go `(*email).validate` file-loading from `apps/cli-go/pkg/config/config.go`
* and path resolution from `(*baseConfig).resolve`.
*
* `config push` reads HTML from `content_path` before building the auth push
* subset. Templates and notifications use different base directories:
* - `[auth.email.template.*]` → relative to project root (parent of `supabase/`)
* - `[auth.email.notification.*]` → relative to `supabase/`
*/

import type { ProjectConfig } from "@supabase/config";
import { readFileSync } from "node:fs";
import { isAbsolute, dirname, join } from "node:path";

type AuthEmail = ProjectConfig["auth"]["email"];

/**
* HTML bodies loaded from `content_path` for auth email templates and
* notifications. Keys are template/notification names (e.g. `invite`,
* `password_changed`); values are the raw file contents.
*/
export interface AuthEmailContent {
readonly template: Readonly<Record<string, string>>;
readonly notification: Readonly<Record<string, string>>;
}

const EMPTY_AUTH_EMAIL_CONTENT: AuthEmailContent = {
template: {},
notification: {},
};

/**
* Derives project root and `supabase/` paths from a loaded config file path.
*
* Config lives at `<projectRoot>/supabase/config.{toml,json}` — the same rule
* `loadProjectConfigFile` uses for env resolution.
*
* @param configPath - Absolute path returned by `loadProjectConfig` (`loaded.path`).
*/
export function projectDirsFromConfigPath(configPath: string): {
readonly projectRoot: string;
readonly supabaseDir: string;
} {
const projectRoot = dirname(dirname(configPath));
return { projectRoot, supabaseDir: join(projectRoot, "supabase") };
}

/**
* Resolves a `content_path` to an absolute filesystem path.
*
* @param contentPath - Path from `config.toml` (absolute or relative to `baseDir`).
* @param baseDir - Project root for templates, or `supabase/` for notifications.
* @returns Absolute path, or `""` when `contentPath` is empty.
*/
function resolveContentPath(contentPath: string, baseDir: string): string {
if (contentPath.length === 0) {
return "";
}
return isAbsolute(contentPath) ? contentPath : join(baseDir, contentPath);
}

/**
* Reads a template HTML file and wraps filesystem errors in Go-shaped messages.
*
* @param kind - `template` or `notification` (used in the error prefix).
* @param name - Config key (e.g. `invite`, `password_changed`).
* @param resolvedPath - Absolute path from {@link resolveContentPath}.
* @returns File contents as UTF-8 text.
* @throws When the file cannot be read.
*/
function readTemplateContent(
kind: "template" | "notification",
name: string,
resolvedPath: string,
): string {
try {
return readFileSync(resolvedPath, "utf8");
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
throw new Error(`Invalid config for auth.email.${kind}.${name}.content_path: ${message}`);
}
}

/**
* Loads auth email template HTML from disk for `config push`.
*
* Mirrors Go `(*email).validate` + `(*baseConfig).resolve`: transactional
* templates resolve `content_path` from the project root; notifications resolve
* from `supabase/` and are only read when `enabled = true`.
*
* @param cwd - Discovered project root (parent of `supabase/`).
* @param supabaseDir - Absolute path to the `supabase/` directory.
* @param email - Decoded `config.auth.email` from `@supabase/config`.
* @returns Loaded HTML keyed by template/notification name. Empty records when
* nothing was configured or all `content_path` values were empty.
* @throws When a configured `content_path` points to a missing or unreadable file.
*/
export function loadAuthEmailContent(
cwd: string,
supabaseDir: string,
email: AuthEmail,
): AuthEmailContent {
const template: Record<string, string> = {};
const notification: Record<string, string> = {};

for (const [name, tmpl] of Object.entries(email.template)) {
const contentPath = tmpl.content_path ?? "";
if (contentPath.length === 0) {
continue;
}
const resolved = resolveContentPath(contentPath, cwd);
template[name] = readTemplateContent("template", name, resolved);
}

for (const [name, notif] of Object.entries(email.notification)) {
if (!notif.enabled) {
continue;
}
const contentPath = notif.content_path ?? "";
if (contentPath.length === 0) {
continue;
}
const resolved = resolveContentPath(contentPath, supabaseDir);
notification[name] = readTemplateContent("notification", name, resolved);
}

if (Object.keys(template).length === 0 && Object.keys(notification).length === 0) {
return EMPTY_AUTH_EMAIL_CONTENT;
}

return { template, notification };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/**
* Unit tests for config-sync.auth-email-content.ts — parity with Go
* `(*email).validate` and `(*baseConfig).resolve` path rules.
*/

import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, describe, expect, it } from "vitest";

import {
loadAuthEmailContent,
projectDirsFromConfigPath,
} from "./config-sync.auth-email-content.ts";

const emptyEmail = {
enable_signup: true,
double_confirm_changes: true,
enable_confirmations: false,
secure_password_change: false,
max_frequency: "1s",
otp_length: 6,
otp_expiry: 3600,
template: {},
notification: {},
};

describe("projectDirsFromConfigPath", () => {
it("derives project root and supabase dir from a config file path", () => {
expect(projectDirsFromConfigPath("/home/user/myapp/supabase/config.toml")).toEqual({
projectRoot: "/home/user/myapp",
supabaseDir: "/home/user/myapp/supabase",
});
});
});

describe("loadAuthEmailContent", () => {
let workdir = "";

afterEach(() => {
if (workdir.length > 0) {
rmSync(workdir, { recursive: true, force: true });
workdir = "";
}
});

function setup(): { cwd: string; supabaseDir: string } {
workdir = mkdtempSync(join(tmpdir(), "auth-email-content-"));
const supabaseDir = join(workdir, "supabase");
mkdirSync(supabaseDir, { recursive: true });
return { cwd: workdir, supabaseDir };
}

it("loads transactional templates relative to the project root", () => {
const { cwd, supabaseDir } = setup();
const templateDir = join(cwd, "templates");
mkdirSync(templateDir, { recursive: true });
writeFileSync(join(templateDir, "invite.html"), "<h1>Invite</h1>");

const content = loadAuthEmailContent(cwd, supabaseDir, {
...emptyEmail,
template: {
invite: {
subject: "You are invited",
content_path: "./templates/invite.html",
},
},
});

expect(content.template["invite"]).toBe("<h1>Invite</h1>");
expect(content.notification).toEqual({});
});

it("loads notification templates relative to supabase/", () => {
const { cwd, supabaseDir } = setup();
writeFileSync(join(supabaseDir, "password_changed.html"), "<p>Changed</p>");

const content = loadAuthEmailContent(cwd, supabaseDir, {
...emptyEmail,
notification: {
password_changed: {
enabled: true,
subject: "Password changed",
content_path: "./password_changed.html",
},
},
});

expect(content.notification["password_changed"]).toBe("<p>Changed</p>");
expect(content.template).toEqual({});
});

it("skips notification templates when disabled", () => {
const { cwd, supabaseDir } = setup();
writeFileSync(join(supabaseDir, "password_changed.html"), "<p>Changed</p>");

const content = loadAuthEmailContent(cwd, supabaseDir, {
...emptyEmail,
notification: {
password_changed: {
enabled: false,
subject: "Password changed",
content_path: "./password_changed.html",
},
},
});

expect(content.notification).toEqual({});
});

it("skips entries with an empty content_path", () => {
const { cwd, supabaseDir } = setup();

const content = loadAuthEmailContent(cwd, supabaseDir, {
...emptyEmail,
template: {
invite: {
subject: "You are invited",
content_path: "",
},
},
});

expect(content.template).toEqual({});
expect(content.notification).toEqual({});
});

it("throws a Go-shaped error when a template file is missing", () => {
const { cwd, supabaseDir } = setup();

expect(() =>
loadAuthEmailContent(cwd, supabaseDir, {
...emptyEmail,
template: {
invite: {
subject: "You are invited",
content_path: "./templates/missing.html",
},
},
}),
).toThrow(/^Invalid config for auth\.email\.template\.invite\.content_path:/);
});
});
Loading
Loading