Skip to content
Open
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
22 changes: 22 additions & 0 deletions apps/cli/src/command-internal/db-config.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,17 @@ const assertDbConfigDrift = (
});

describe("dbConfigResolver (local + db-url)", () => {
it.effect.each([
{ name: "local", flags: localFlags },
{ name: "db-url", flags: dbUrlFlags("postgres://alice:password@example.com:6543/appdb") },
])("resolves Data API exposure for $name", ({ flags }) =>
Effect.gen(function* () {
const dir = yield* withWorkdir('[api]\nschemas = ["app", "public"]');
const result = yield* resolve(dir, flags);
expect(result.exposedSchemas).toEqual(["app", "public"]);
}).pipe(Effect.scoped, Effect.provide(BunServices.layer)),
);

it.effect("local mode: uses 127.0.0.1 with config.toml db.port/password and is local", () =>
Effect.gen(function* () {
const dir = yield* withWorkdir(
Expand Down Expand Up @@ -560,6 +571,17 @@ describe("dbConfigResolver (db-url under the stack backend)", () => {
stackApi: Layer.Layer<StackApi> = projectStackApi(dir, "running"),
) => resolve(dir, dbUrlFlags(url), { stackApi }).pipe(Effect.provide(stackBackendLayer("stack")));

it.effect("resolves Data API exposure for the stack-backed local database", () =>
Effect.gen(function* () {
const dir = yield* withWorkdir('[api]\nschemas = ["app", "public"]');
const resolved = yield* resolve(dir, localFlags, {
projectStateMode: "stack",
stackApi: projectStackApi(dir, "running"),
}).pipe(Effect.provide(stackBackendLayer("stack")));
expect(resolved.exposedSchemas).toEqual(["app", "public"]);
}).pipe(Effect.scoped, Effect.provide(BunServices.layer)),
);

it.effect("treats the url printed by `stack status --env` as the local database", () =>
Effect.gen(function* () {
const resolved = yield* resolveOnStack(yield* withWorkdir(), stackUrl(STACK_SQL_PORT));
Expand Down
8 changes: 7 additions & 1 deletion apps/cli/src/command-internal/db-config.layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,7 @@ export const dbConfigResolverLayer = Layer.effect(
conn:
isLocal && conn.password.length === 0 ? { ...conn, password: localPassword } : conn,
isLocal,
exposedSchemas: tomlValues.exposedSchemas,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MINOR · correctness · source: claude

Remote --db-url advisor runs overwrite the database's exposure setting with local config, which can misclassify exposed schemas or suppress exposure-dependent security findings when the local Data API is disabled. The output does not identify this source.

Evidence: db-config.layer.ts:658-660 reads base local config, and :704-708 attaches its exposedSchemas regardless of isLocal. advisors.handler.ts:70 writes those schemas to pgrst.db_schemas, and :113-115 applies them unconditionally when defined. The embedded rls_disabled_in_public query in advisors.lints-sql.ts:11 filters by that setting.

Suggested fix: Apply local exposure automatically only to local targets. For remote URLs, preserve database-derived exposure or require an explicit exposure override and report its source.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping local [api] exposure for --db-url, including remote URLs. This is the case the ticket targets: --db-url through Supavisor after db push, where a backend left with pgrst.db_schemas = '' made the CLI report "No issues found". Limiting exposure to local targets would leave that case broken, and a raw URL does not identify a project whose Data API settings the CLI could read. SIDE_EFFECTS.md now documents that --db-url uses local [api] (or SUPABASE_API_SCHEMAS / SUPABASE_API_ENABLED) and points to --linked for the platform-derived exposure.

};
}

Expand Down Expand Up @@ -778,7 +779,11 @@ export const dbConfigResolverLayer = Layer.effect(
});
const backend = yield* currentStackBackend;
if (backend.kind === "stack") {
return { conn: yield* stackDatabaseConn, isLocal: true };
return {
conn: yield* stackDatabaseConn,
isLocal: true,
exposedSchemas: tomlValues.exposedSchemas,
};
}
return {
conn: {
Expand All @@ -789,6 +794,7 @@ export const dbConfigResolverLayer = Layer.effect(
database: "postgres",
},
isLocal: true,
exposedSchemas: tomlValues.exposedSchemas,
};
});

Expand Down
15 changes: 15 additions & 0 deletions apps/cli/src/command-internal/db-config.toml-read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export interface DbTomlValues {
*/
readonly envLookup: (name: EnvOverrideName) => string | undefined;
readonly apiSchemas: ReadonlyArray<string>;
/** Schemas the Data API serves; empty when `[api].enabled` is false. */
readonly exposedSchemas: ReadonlyArray<string>;
/** `[db] port`, default 54322 (`packages/config/src/db.ts`). */
readonly port: number;
/** `[db] shadow_port`, default 54320. */
Expand Down Expand Up @@ -1066,6 +1068,7 @@ const ENV_OVERRIDE_NAMES = [
"SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS",
"SUPABASE_API_AUTO_EXPOSE_NEW_TABLES",
"SUPABASE_API_SCHEMAS",
"SUPABASE_API_ENABLED",
"SUPABASE_EXPERIMENTAL_PG_DELTA",
] as const;

Expand Down Expand Up @@ -2187,10 +2190,22 @@ const readDbTomlCore = Effect.fnUntraced(function* (
});
}

const apiEnabled = yield* resolveBoolOrFail(
"api.enabled",
apiRaw?.["enabled"],
true,
lookup,
remoteWins("api.enabled") ? undefined : envOverride("SUPABASE_API_ENABLED"),
);
Comment on lines +2193 to +2199

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ NIT · behavior-change · source: claude

Malformed SUPABASE_API_ENABLED also fails --linked commands unless a matching remote block overrides api.enabled, but the query documentation describes this failure only for --local and --db-url.

Evidence: db-config.toml-read.ts:2193-2199 always resolves api.enabled; :889-892 rejects malformed overrides. db-config.layer.ts:734-736 invokes this reader on the linked branch. query.handler.ts:269-275 and advisors.handler.ts:152-158 invoke that resolver for linked commands. commands/db/query/SIDE_EFFECTS.md:38 limits the documented failure scope to --local / --db-url.

Suggested fix: Document that malformed SUPABASE_API_ENABLED can fail linked commands too, including the matching remote-override exception.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: both SIDE_EFFECTS.md files now say a malformed SUPABASE_API_ENABLED fails config load on every target unless a matching [remotes.*] block sets api.enabled.

const exposedSchemas = apiEnabled
? [...new Set(apiSchemas.map((schema) => schema.trim()).filter((schema) => schema.length > 0))]
: [];

const values: DbTomlValues = {
projectEnv,
envLookup: envOverride,
apiSchemas,
exposedSchemas,
port,
shadowPort,
password: passwordRaw !== undefined ? expandEnv(passwordRaw, lookup) : DEFAULT_PASSWORD,
Expand Down
59 changes: 59 additions & 0 deletions apps/cli/src/command-internal/db-config.toml-read.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2990,3 +2990,62 @@ describe("readDbToml OrioleDB telemetry", () => {
);
});
});

describe("readDbToml Data API exposure", () => {
it.effect.each([
{
name: "disabled in config",
config: '[api]\nenabled = false\nschemas = ["app"]',
env: { SUPABASE_API_ENABLED: "" },
expected: [],
},
{
name: "disabled by environment",
config: "[api]\nenabled = true",
env: { SUPABASE_API_ENABLED: "false" },
expected: [],
},
{
name: "trimmed and deduplicated",
config: '[api]\nschemas = ["app", " public", "app"]',
env: { SUPABASE_API_ENABLED: "" },
expected: ["app", "public"],
},
])("resolves exposed schemas: $name", ({ config, env, expected }) =>
withConfig(config).pipe(
Effect.flatMap(read),
Effect.tap((values) => Effect.sync(() => expect(values.exposedSchemas).toEqual(expected))),
withEnv(env),
),
);

it.effect("an explicit remote api.enabled beats SUPABASE_API_ENABLED", () => {
const ref = "abcdefghijklmnopqrst";
return withConfig(
["[remotes.prod]", `project_id = "${ref}"`, "[remotes.prod.api]", "enabled = false"].join(
"\n",
),
).pipe(
Effect.flatMap((dir) => readRef(dir, ref)),
Effect.tap((values) => Effect.sync(() => expect(values.exposedSchemas).toEqual([]))),
withEnv({ SUPABASE_API_ENABLED: "true" }),
);
});

it.effect("rejects malformed SUPABASE_API_ENABLED", () =>
withConfig("[api]\nenabled = true").pipe(
Effect.flatMap(read),
Effect.exit,
Effect.tap((exit) =>
Effect.sync(() => {
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
expect(Cause.pretty(exit.cause)).toContain("DbConfigLoadError");
expect(Cause.pretty(exit.cause)).toContain("invalid api.enabled");
}
}),
),
withEnv({ SUPABASE_API_ENABLED: "perhaps" }),
),
);
});
2 changes: 2 additions & 0 deletions apps/cli/src/command-internal/db-config.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ export interface DbConfigFlags {
export interface ResolvedDbConfig {
readonly conn: PgConnInput;
readonly isLocal: boolean;
/** Schemas the local config exposes through the Data API; set on the `--local` and `--db-url` paths. */
readonly exposedSchemas?: ReadonlyArray<string>;
/**
* The resolved linked project ref (`--linked` path only; `None` for `--local` / `--db-url`).
* Lets the caller re-read config with the ref applied so a matching `[remotes.<ref>]` block can
Expand Down
24 changes: 15 additions & 9 deletions apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Management API.

| Path | Format | When |
| -------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------ |
| `<workdir>/supabase/config.toml` | TOML | local / `--db-url` — to resolve the DB connection config |
| `<workdir>/supabase/config.toml` | TOML | local / `--db-url` — to resolve the DB connection and `[api].enabled` / `[api].schemas` |
| `~/.supabase/access-token` | plain text | `--linked` only, when `SUPABASE_ACCESS_TOKEN` unset (keyring → file) |
| `<workdir>/supabase/.temp/project-ref` | plain text | `--linked` only — to resolve the project ref; skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set |
| `PGSSL*` PEM, `pg_service.conf` | PEM, libpq | linked: `pg_service.conf` if `PGSERVICE` is set; PEM files only on the pooler temp-role probe; see `db push` |
Expand Down Expand Up @@ -36,19 +36,21 @@ Issued through the typed client; the generated schema keeps advisor

## Database (`--local` / `--db-url`)

One connection. Within one transaction: `BEGIN` → `set local search_path = ''`
One connection. Within one transaction: `BEGIN` → `set_config('pgrst.db_schemas', <exposed schemas or ''>, true)` → `set local search_path = ''`
(setup half of `templates/lints.sql`) → the multi-CTE lints query → `ROLLBACK`.
`--type` filters the resulting rows by category (`SECURITY` / `PERFORMANCE`).

## Environment Variables

| Variable | Purpose | Required? |
| ----------------------- | -------------------------------------------------------------------- | ----------------------------------------- |
| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` | no (keyring → `~/.supabase/access-token`) |
| `SUPABASE_PROJECT_ID` | linked project ref override (superseded by `--project-ref` when set) | no |
| `SUPABASE_PROFILE` | API profile (built-in name or YAML path) | no |
| `PGHOST` / `PGPORT` / … | connection overrides (local / `--db-url`) | no |
| `PG*` libpq | linked: validated; used only by the temp-role probe; see `db push` | no |
| Variable | Purpose | Required? |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |
| `SUPABASE_API_SCHEMAS` | overrides `[api].schemas` for local / `--db-url` | no |
| `SUPABASE_API_ENABLED` | overrides `[api].enabled`; malformed values fail config loading on every target unless a matching `[remotes.*]` block sets `api.enabled` | no |
| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` | no (keyring → `~/.supabase/access-token`) |
| `SUPABASE_PROJECT_ID` | linked project ref override (superseded by `--project-ref` when set) | no |
| `SUPABASE_PROFILE` | API profile (built-in name or YAML path) | no |
| `PGHOST` / `PGPORT` / … | connection overrides (local / `--db-url`) | no |
| `PG*` libpq | linked: validated; used only by the temp-role probe; see `db push` | no |

The API base URL is derived from `SUPABASE_PROFILE`; `SUPABASE_API_URL` is **not**
honored (see `command-settings.layer.unit.test.ts`).
Expand Down Expand Up @@ -101,3 +103,7 @@ the process exits non-zero (no error envelope is written over the payload).
equivalent env var simply leaves unused on a non-linked target).
- Not-logged-in suggestion: `Run supabase login first.`
- Telemetry: only the standard `cli_command_executed` event (no custom events).
- Exposure reflects config on disk, which may differ from a stack started with older config.
- `--db-url` uses the local `[api]` exposure even when the URL points at a remote database, because a raw URL does not identify a project whose Data API settings could be read; align `[api]` (or `SUPABASE_API_SCHEMAS` / `SUPABASE_API_ENABLED`) with the target, or use `--linked`.
- The exposure value reverts at rollback, but the setting stays defined and later reads `''` on that backend.
- `--linked` exposure is set by the platform.
22 changes: 21 additions & 1 deletion apps/cli/src/commands/db/advisors/advisors.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,21 @@ const queryLints = Effect.fn("DbAdvisors.queryLints")(function* (session: DbSess
return rows.map(scanAdvisorLintRow);
});

const setExposedSchemas = Effect.fn("DbAdvisors.setExposedSchemas")(function* (
session: DbSession,
schemas: ReadonlyArray<string>,
) {
yield* Effect.annotateCurrentSpan("advisor.exposed_schema_count", schemas.length);
yield* session
.query("select set_config('pgrst.db_schemas', $1, true)", [schemas.join(",")])
.pipe(
Effect.mapError(
(cause) =>
new DbAdvisorsSetupError({ message: `failed to prepare lint session: ${cause.message}` }),
),
);
});

/** Gathers lints from a local (or `--db-url`) database connection. */
const runLocal = Effect.fn("db.advisors.runLocal")(function* (
flags: DbAdvisorsFlags,
Expand Down Expand Up @@ -94,7 +109,12 @@ const runLocal = Effect.fn("db.advisors.runLocal")(function* (
}),
),
);
return yield* queryLints(session).pipe(
return yield* Effect.gen(function* () {
if (cfg.exposedSchemas !== undefined) {
yield* setExposedSchemas(session, cfg.exposedSchemas);
}
return yield* queryLints(session);
}).pipe(
Effect.ensuring(
session
.exec("rollback")
Expand Down
Loading
Loading