diff --git a/apps/cli/src/command-internal/connect-errors.ts b/apps/cli/src/command-internal/connect-errors.ts index 2a60426432..69a6260429 100644 --- a/apps/cli/src/command-internal/connect-errors.ts +++ b/apps/cli/src/command-internal/connect-errors.ts @@ -55,6 +55,13 @@ export function isIPv6ConnectivityError(message: string): boolean { export const SUGGEST_ENV_VAR = "Connect to your database by setting the env var correctly: SUPABASE_DB_PASSWORD"; +/** + * Hint shown when the server certificate does not verify against the CA the connection trusts, + * which on a hosted project is the bundled Supabase CA unless the user chose a trust setting. + */ +export const SUGGEST_CA_VERIFICATION = + "The server certificate did not verify against the trusted CA (the bundled Supabase CA on a hosted project). Behind a TLS-inspecting proxy, set PGSSLROOTCERT to a CA bundle that trusts it, or set PGSSLMODE=require to skip certificate verification."; + /** * Shown instead of the remote-only "Network Restrictions" hint when `ctx.isLocal` is true — a * refused `--local` connection (Docker/Postgres not running) needs a different fix than an @@ -70,13 +77,16 @@ export function localStackSuggestion(linkedCheckout: boolean | undefined): strin : SUGGEST_LOCAL_STACK; } +export const SUGGEST_NETWORK_RESTRICTIONS = + "Make sure your local IP is allowed in Network Restrictions and Network Bans."; + /** * Remote-only "Network Restrictions" hint, shown for a connection refused or blocked by IP * allow-listing. Shared by both the always-remote `Address not in tenant allow_list` branch * and the non-local `ECONNREFUSED`/`connection refused` branch in `connectSuggestion`. */ -function suggestNetworkRestrictions(dashboardUrl: string): string { - return `Make sure your local IP is allowed in Network Restrictions and Network Bans.\n${dashboardUrl}/project/_/database/settings`; +export function suggestNetworkRestrictions(dashboardUrl: string): string { + return `${SUGGEST_NETWORK_RESTRICTIONS}\n${dashboardUrl}/project/_/database/settings`; } /** Context the connect-suggestion needs but cannot derive from the error alone. */ @@ -221,6 +231,31 @@ const TLS_ERROR_CODES = new Set([ "CERT_REJECTED", "HOSTNAME_MISMATCH", ]); + +const CERT_VERIFICATION_CODES: ReadonlySet = new Set([ + ...[...TLS_ERROR_CODES].filter((code) => code !== "OUT_OF_MEM"), + // Node and Bun's `checkServerIdentity` failure, which `verify-full` raises on a hostname mismatch. + "ERR_TLS_CERT_ALTNAME_INVALID", +]); + +/** + * Whether the remote pooler rejected the caller's IP for project network restrictions. Only + * the allow-list changing can fix it, so it sets `DbConnectError.networkRestricted`, which the + * temp-role probe keys off to stop retrying. + */ +export function isNetworkRestrictionFailure(error: unknown): boolean { + return collectConnectErrorText(error).includes("Address not in tenant allow_list"); +} + +/** + * Whether a connect failure is an X509 certificate-verification rejection. It is deterministic: + * the same server certificate fails the same way on every attempt. Sets + * `DbConnectError.certificateRejected`, which the temp-role probe keys off to stop retrying. + */ +export function isCertificateVerificationFailure(error: unknown): boolean { + const cause = deepestConnectCause(error); + return hasStringCode(cause) && CERT_VERIFICATION_CODES.has(cause.code); +} // node-postgres' own message when the server answers `N` to SSLRequest // (`pg/lib/connection.js`); rendered as `tls error (server refused TLS connection)`. const SERVER_REFUSED_SSL = "The server does not support SSL connections"; @@ -378,7 +413,7 @@ export function connectSuggestion( const text = collectConnectErrorText(error); // "Address not in tenant allow_list" only ever comes from the remote pooler rejecting the // caller's IP, so it always means network restrictions. - if (text.includes("Address not in tenant allow_list")) { + if (isNetworkRestrictionFailure(error)) { return suggestNetworkRestrictions(ctx.dashboardUrl); } // Don't send the user to the dashboard's Network Restrictions page for a --local connection. @@ -387,6 +422,7 @@ export function connectSuggestion( ? localStackSuggestion(ctx.linkedCheckout) : suggestNetworkRestrictions(ctx.dashboardUrl); } + if (isCertificateVerificationFailure(error)) return SUGGEST_CA_VERIFICATION; if ( text.includes("SCRAM exchange: Wrong password") || text.includes("failed SASL auth") || diff --git a/apps/cli/src/command-internal/connect-errors.unit.test.ts b/apps/cli/src/command-internal/connect-errors.unit.test.ts index 795d813025..33f6c0f953 100644 --- a/apps/cli/src/command-internal/connect-errors.unit.test.ts +++ b/apps/cli/src/command-internal/connect-errors.unit.test.ts @@ -3,11 +3,14 @@ import * as Pg from "pg"; import { describe, expect, it } from "vitest"; import { + SUGGEST_CA_VERIFICATION, SUGGEST_ENV_VAR, SUGGEST_LOCAL_STACK, connectFailureMessage, connectSuggestion, ipv6Suggestion, + isCertificateVerificationFailure, + isNetworkRestrictionFailure, isDialFailure, isIPv6ConnectivityError, isIPv6ConnectivityErrorCause, @@ -270,6 +273,13 @@ describe("connectSuggestion", () => { ); }); + it("maps a certificate-verification failure to the CA hint", () => { + const err = sqlError( + systemError("self-signed certificate in certificate chain", "SELF_SIGNED_CERT_IN_CHAIN"), + ); + expect(connectSuggestion(err, ctx)).toBe(SUGGEST_CA_VERIFICATION); + }); + it("maps a refused local connection to the local stack hint", () => { const err = sqlError(systemError("connect ECONNREFUSED 127.0.0.1:54322", "ECONNREFUSED")); expect(connectSuggestion(err, { ...ctx, isLocal: true })).toBe(SUGGEST_LOCAL_STACK); @@ -422,6 +432,34 @@ describe("connectSuggestion", () => { }); }); +describe("isCertificateVerificationFailure", () => { + it("matches the X509 verification codes but not other TLS or dial failures", () => { + const withCode = (code: string) => + realSqlConnectError(Object.assign(new Error("failure"), { code })); + expect(isCertificateVerificationFailure(withCode("SELF_SIGNED_CERT_IN_CHAIN"))).toBe(true); + expect(isCertificateVerificationFailure(withCode("CERT_HAS_EXPIRED"))).toBe(true); + expect(isCertificateVerificationFailure(withCode("ERR_TLS_CERT_ALTNAME_INVALID"))).toBe(true); + expect(isCertificateVerificationFailure(withCode("OUT_OF_MEM"))).toBe(false); + expect(isCertificateVerificationFailure(withCode("ECONNREFUSED"))).toBe(false); + expect(isCertificateVerificationFailure(withCode("ECONNRESET"))).toBe(false); + expect(isCertificateVerificationFailure(authFailedError())).toBe(false); + }); +}); + +describe("isNetworkRestrictionFailure", () => { + it("matches the pooler's allow-list rejection but not other failures", () => { + expect( + isNetworkRestrictionFailure( + realSqlConnectError(new Error("Address not in tenant allow_list: 1.2.3.4")), + ), + ).toBe(true); + expect(isNetworkRestrictionFailure(authFailedError())).toBe(false); + expect(isNetworkRestrictionFailure(realSqlConnectError(new Error("some other failure")))).toBe( + false, + ); + }); +}); + describe("isDialFailure", () => { it("classifies dial errno failures", () => { expect(isDialFailure(realSqlConnectError(dialError("ECONNREFUSED", "127.0.0.1", 54322)))).toBe( diff --git a/apps/cli/src/command-internal/db-config.integration.test.ts b/apps/cli/src/command-internal/db-config.integration.test.ts index e6b1b6f782..a972f6e8f2 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -85,8 +85,15 @@ import type { DbConnectionInput } from "./db-config.types.ts"; import { hostedMutationPreapproval } from "./hosted-consent/hosted-consent.ts"; import type { HostedMutationConsent } from "./hosted-consent/hosted-consent.token.ts"; import type { ResolvedTarget } from "./target/resolve-target.ts"; +import { + SUGGEST_CA_VERIFICATION, + SUGGEST_ENV_VAR, + SUGGEST_NETWORK_RESTRICTIONS, +} from "./connect-errors.ts"; import { DbConnectError } from "./db-connection.errors.ts"; +import { toConnectError } from "./db-connection.sql-pg.layer.ts"; import { DbConnection, type DbSession, type PgConnInput } from "./db-connection.service.ts"; +import { rootCaBundle } from "./supabase-tls.ts"; import { StackApi } from "./stack-api.ts"; import { stackBackendLayer } from "./stack-backend.ts"; import { selection } from "../../tests/helpers/pins.ts"; @@ -984,6 +991,8 @@ describe("dbConfigResolver (linked config ordering)", () => { user: `cli_login_role.${adHocRef}`, password: "temporary-role-password", database: "postgres", + sslmode: "require", + sslrootcertInline: rootCaBundle(), suggestionContext: { dashboardUrl: "https://supabase.com/dashboard", profileName: "supabase", @@ -1008,6 +1017,8 @@ describe("dbConfigResolver (linked config ordering)", () => { user: `cli_login_role.${adHocRef}`, password: "temporary-role-password", database: "postgres", + sslmode: "require", + sslrootcertInline: rootCaBundle(), }, isLocal: false, dnsResolver: "native", @@ -1149,6 +1160,8 @@ describe("dbConfigResolver (linked config ordering)", () => { user: `cli_login_role.${adHocRef}`, password: "temporary-role-password", database: "postgres", + sslmode: "require", + sslrootcertInline: rootCaBundle(), suggestionContext: { dashboardUrl: "https://supabase.com/dashboard", profileName: "supabase", @@ -1173,6 +1186,8 @@ describe("dbConfigResolver (linked config ordering)", () => { user: `cli_login_role.${adHocRef}`, password: "temporary-role-password", database: "postgres", + sslmode: "require", + sslrootcertInline: rootCaBundle(), }, isLocal: false, dnsResolver: "native", @@ -1293,6 +1308,8 @@ describe("dbConfigResolver (linked config ordering)", () => { user: `postgres.${linkedRef}`, password: "linked-password", database: "postgres", + sslmode: "require", + sslrootcertInline: rootCaBundle(), suggestionContext: { dashboardUrl: "https://supabase.com/dashboard", profileName: "supabase", @@ -1403,6 +1420,8 @@ describe("dbConfigResolver (an explicit --project-ref fetches the pooler config) user: `postgres.${ref}`, password: "ambient-password", database: "postgres", + sslmode: "require", + sslrootcertInline: rootCaBundle(), suggestionContext: { dashboardUrl: "https://supabase.com/dashboard", profileName: "supabase", @@ -1521,6 +1540,8 @@ describe("dbConfigResolver (an explicit --project-ref fetches the pooler config) user: `postgres.${targetRef}`, password: "flag-password", database: "postgres", + sslmode: "require", + sslrootcertInline: rootCaBundle(), suggestionContext: { dashboardUrl: "https://supabase.com/dashboard", profileName: "supabase", @@ -1919,6 +1940,8 @@ describe("dbConfigResolver (container pooler fallback)", () => { host: "aws-0-us-east-1.pooler.supabase.com", user: `cli_login_role.${ref}`, password: "temporary-role-password", + sslmode: "require", + sslrootcertInline: rootCaBundle(), }); } expect(requests).toEqual([ @@ -2073,6 +2096,8 @@ describe("resolveLinkedConn libpq TLS", () => { readonly reachable?: boolean; readonly password?: Option.Option; readonly poolerUrl?: string; + readonly profile?: string; + readonly projectHost?: string; } = {}, ) => withConfigEnv( @@ -2082,11 +2107,16 @@ describe("resolveLinkedConn libpq TLS", () => { return yield* resolveLinkedConn( ref, opts.workdir ?? (yield* projectWorkdir([])), - "invalid", + opts.profile ?? "supabase", + opts.projectHost ?? "invalid", "supabase.com", "native", opts.password ?? Option.some("secret"), - { removeNetworkBans: false, poolerUrl: Option.fromNullishOr(opts.poolerUrl) }, + { + removeNetworkBans: false, + poolerUrl: Option.fromNullishOr(opts.poolerUrl), + dashboardUrl: "https://supabase.com/dashboard", + }, ); }), ).pipe(Effect.scoped); @@ -2168,6 +2198,163 @@ describe("resolveLinkedConn libpq TLS", () => { }).pipe(Effect.scoped, Effect.provide(linkedConnLayer)), ); + const pinned = { sslmode: "require", sslrootcertInline: rootCaBundle() }; + const serviceFileWith = (content: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "cli-linked-service-" }); + const serviceFile = path.join(dir, "pg_service.conf"); + yield* fs.writeFileString(serviceFile, content); + return { PGSERVICE: "svc", PGSERVICEFILE: serviceFile }; + }); + const hostedPoolerUrl = `postgres://postgres.${ref}:[YOUR-PASSWORD]@${poolerHost}:6543/postgres`; + + describe("Supabase CA pin", () => { + for (const [profile, projectHost] of [ + ["supabase", "supabase.co"], + ["supabase-staging", "supabase.red"], + ["snap", "snapcloud.dev"], + ] as const) { + it.effect(`pins the bundled CA on the ${profile} direct host when TLS is unset`, () => + Effect.gen(function* () { + const conn = yield* linkedConn({}, { profile, projectHost }); + expect(conn).toMatchObject({ host: `db.${ref}.${projectHost}`, ...pinned }); + }).pipe(Effect.provide(linkedConnLayer)), + ); + } + + it.effect("pins the bundled CA on the direct host before minting a login role", () => + Effect.gen(function* () { + const conn = yield* linkedConn( + {}, + { projectHost: "supabase.co", password: Option.some("") }, + ); + expect(conn).toMatchObject({ user: "cli_login_x", password: "temp", ...pinned }); + }).pipe(Effect.provide(linkedConnLayer)), + ); + + it.effect("keeps verify-full with the user's own root cert", () => + Effect.gen(function* () { + const conn = yield* linkedConn( + { PGSSLMODE: "verify-full", PGSSLROOTCERT: "/ca.pem" }, + { projectHost: "supabase.co" }, + ); + expect(conn).toMatchObject({ sslmode: "verify-full", sslrootcert: "/ca.pem" }); + expect(conn.sslrootcertInline).toBeUndefined(); + }).pipe(Effect.provide(linkedConnLayer)), + ); + + it.effect("keeps PGSSLMODE=disable", () => + Effect.gen(function* () { + const conn = yield* linkedConn({ PGSSLMODE: "disable" }, { projectHost: "supabase.co" }); + expect(conn.sslmode).toBe("disable"); + expect(conn.sslrootcertInline).toBeUndefined(); + }).pipe(Effect.provide(linkedConnLayer)), + ); + + it.effect("verifies against a PGSSLROOTCERT with no sslmode instead of the bundled CA", () => + Effect.gen(function* () { + const conn = yield* linkedConn( + { PGSSLROOTCERT: "/ca.pem" }, + { projectHost: "supabase.co" }, + ); + expect(conn).toMatchObject({ sslmode: "require", sslrootcert: "/ca.pem" }); + expect(conn.sslrootcertInline).toBeUndefined(); + }).pipe(Effect.provide(linkedConnLayer)), + ); + + it.effect("keeps a service-file sslmode", () => + Effect.gen(function* () { + const env = yield* serviceFileWith("[svc]\nsslmode=require\n"); + const conn = yield* linkedConn(env, { projectHost: "supabase.co" }); + expect(conn.sslmode).toBe("require"); + expect(conn.sslrootcertInline).toBeUndefined(); + }).pipe(Effect.scoped, Effect.provide(linkedConnLayer)), + ); + + it.effect("pins the bundled CA on the pooler when TLS is unset", () => + Effect.gen(function* () { + const conn = yield* linkedConn( + {}, + { reachable: false, poolerUrl: hostedPoolerUrl, password: Option.some("secret") }, + ); + expect(conn).toMatchObject({ host: poolerHost, ...pinned }); + }).pipe(Effect.scoped, Effect.provide(linkedConnLayer)), + ); + + it.effect("keeps an explicit sslmode in the saved pooler URL", () => + Effect.gen(function* () { + const conn = yield* linkedConn( + {}, + { + reachable: false, + poolerUrl: `${hostedPoolerUrl}?sslmode=disable`, + password: Option.some("secret"), + }, + ); + expect(conn.sslmode).toBe("disable"); + expect(conn.sslrootcertInline).toBeUndefined(); + }).pipe(Effect.scoped, Effect.provide(linkedConnLayer)), + ); + + it.effect("pins the temp-role probe connection through the pooler", () => { + const probed: Array = []; + const probeSession: DbSession = { + exec: () => Effect.void, + execBatch: () => Effect.void, + query: () => Effect.succeed([]), + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "SELECT 1" }), + }; + return Effect.gen(function* () { + const conn = yield* linkedConn( + {}, + { reachable: false, poolerUrl: hostedPoolerUrl, password: Option.some("") }, + ); + expect(probed).toHaveLength(1); + expect(probed[0]).toMatchObject({ + host: poolerHost, + user: `cli_login_x.${ref}`, + ...pinned, + }); + expect(conn).toMatchObject(pinned); + }).pipe( + Effect.scoped, + Effect.provideService(DbConnection, { + connect: (conn) => { + probed.push(conn); + return Effect.succeed(probeSession); + }, + }), + Effect.provide(linkedConnLayer), + ); + }); + + for (const [profile, projectHost] of [ + ["supabase-local", "supabase.red"], + ["my-profile", "supabase.co"], + ] as const) { + it.effect(`does not pin the ${profile} profile`, () => + Effect.gen(function* () { + const conn = yield* linkedConn({}, { profile, projectHost }); + expect(conn.host).toBe(`db.${ref}.${projectHost}`); + expect(conn.sslmode).toBeUndefined(); + expect(conn.sslrootcertInline).toBeUndefined(); + }).pipe(Effect.provide(linkedConnLayer)), + ); + } + + it.effect("does not pin a host outside the profile's domains", () => + Effect.gen(function* () { + const conn = yield* linkedConn({}, { profile: "supabase", projectHost: "supabase.red" }); + expect(conn.sslmode).toBeUndefined(); + expect(conn.sslrootcertInline).toBeUndefined(); + }).pipe(Effect.provide(linkedConnLayer)), + ); + }); + it.effect("names the libpq variables when PGSSLMODE is invalid on a reachable direct host", () => Effect.gen(function* () { const error = yield* linkedConn({ PGSSLMODE: "verifyfull" }).pipe(Effect.flip); @@ -2497,19 +2684,23 @@ describe("dbConfigResolver.resolveConnection", () => { } return undefined; }); - const failingConnect = (failures: number) => { + const failingConnect = ( + failures: number, + failure: (cfg: PgConnInput) => DbConnectError = () => + new DbConnectError({ message: "password authentication failed" }), + ) => { const attempts = { count: 0 }; const layer = Layer.succeed(DbConnection, { - connect: () => + connect: (cfg) => Effect.suspend(() => { attempts.count += 1; - return attempts.count <= failures - ? Effect.fail(new DbConnectError({ message: "password authentication failed" })) - : Effect.succeed(session); + return attempts.count <= failures ? Effect.fail(failure(cfg)) : Effect.succeed(session); }), }); return { attempts, layer }; }; + const driverFailure = (code: string, message: string) => (cfg: PgConnInput) => + toConnectError(cfg, false, Object.assign(new Error(message), { code })); const forkResolve = ( input: DbConnectionInput, connection: Layer.Layer, @@ -2569,6 +2760,126 @@ describe("dbConfigResolver.resolveConnection", () => { }, ); + it.effect( + "keeps looking for bans when the dial fails, as a ban surfaces as a dial failure", + () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + const connection = failingConnect( + Number.POSITIVE_INFINITY, + driverFailure("ETIMEDOUT", "connect ETIMEDOUT 203.0.113.9:6543"), + ); + return Effect.gen(function* () { + yield* bannedRequests(requests, ["203.0.113.7"]); + const { fiber, advance } = yield* forkResolve( + inputFor(linkedTarget(ref, { source: "flag", matchesLinkedRef: false })), + connection.layer, + ); + yield* advance(8); + const exit = yield* Fiber.await(fiber); + expect(failureText(exit)).toContain("LinkedDbNetworkBannedError"); + expect(connection.attempts.count).toBe(9); + }).pipe(Effect.scoped, Effect.provide([BunServices.layer, BunCrypto.layer])); + }, + ); + + it.effect("fails at once on a rejected server certificate and names the CA setting", () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + const connection = failingConnect( + Number.POSITIVE_INFINITY, + driverFailure("SELF_SIGNED_CERT_IN_CHAIN", "self-signed certificate in certificate chain"), + ); + return Effect.gen(function* () { + yield* bannedRequests(requests, ["203.0.113.7"]); + const { fiber } = yield* forkResolve( + inputFor(linkedTarget(ref, { source: "flag", matchesLinkedRef: false })), + connection.layer, + ); + const exit = yield* Fiber.await(fiber); + const error = Exit.isFailure(exit) ? Cause.findErrorOption(exit.cause) : Option.none(); + expect(error).toEqual( + Option.some( + expect.objectContaining({ + _tag: "DbConfigConnectTempRoleError", + message: expect.stringContaining("tls error (self-signed certificate"), + suggestion: SUGGEST_CA_VERIFICATION, + }), + ), + ); + expect(SUGGEST_CA_VERIFICATION).not.toBe(SUGGEST_ENV_VAR); + expect(connection.attempts.count).toBe(1); + expect(requests.some((request) => request.path.includes("network-bans"))).toBe(false); + }).pipe(Effect.scoped, Effect.provide([BunServices.layer, BunCrypto.layer])); + }); + + it.effect("stops retrying authentication failures once the certificate is rejected", () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + const connection = failingConnect(Number.POSITIVE_INFINITY, (cfg) => + connection.attempts.count <= 2 + ? new DbConnectError({ message: "password authentication failed" }) + : driverFailure("CERT_HAS_EXPIRED", "certificate has expired")(cfg), + ); + return Effect.gen(function* () { + yield* bannedRequests(requests, []); + const { fiber, advance } = yield* forkResolve( + inputFor(linkedTarget(ref, { source: "flag", matchesLinkedRef: false })), + connection.layer, + ); + yield* advance(2); + const exit = yield* Fiber.await(fiber); + expect(failureText(exit)).toContain("DbConfigConnectTempRoleError"); + expect(connection.attempts.count).toBe(3); + }).pipe(Effect.scoped, Effect.provide([BunServices.layer, BunCrypto.layer])); + }); + + it.effect("fails at once on an IP rejected by network restrictions and lists no bans", () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + const connection = failingConnect( + Number.POSITIVE_INFINITY, + () => + new DbConnectError({ + message: "failed to connect to postgres: Address not in tenant allow_list", + networkRestricted: true, + }), + ); + return Effect.gen(function* () { + yield* bannedRequests(requests, ["203.0.113.7"]); + const { fiber } = yield* forkResolve( + inputFor(linkedTarget(ref, { source: "flag", matchesLinkedRef: false })), + connection.layer, + ); + const exit = yield* Fiber.await(fiber); + const error = Exit.isFailure(exit) ? Cause.findErrorOption(exit.cause) : Option.none(); + expect(error).toEqual( + Option.some( + expect.objectContaining({ + _tag: "DbConfigConnectTempRoleError", + message: expect.stringContaining("Address not in tenant allow_list"), + suggestion: `${SUGGEST_NETWORK_RESTRICTIONS}\nhttps://supabase.com/dashboard/project/_/database/settings`, + }), + ), + ); + expect(connection.attempts.count).toBe(1); + expect(requests.some((request) => request.path.includes("network-bans"))).toBe(false); + }).pipe(Effect.scoped, Effect.provide([BunServices.layer, BunCrypto.layer])); + }); + + it.effect("retries a plain authentication failure to the end and lists no ban", () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + const connection = failingConnect(Number.POSITIVE_INFINITY); + return Effect.gen(function* () { + yield* bannedRequests(requests, []); + const { fiber, advance } = yield* forkResolve( + inputFor(linkedTarget(ref, { source: "flag", matchesLinkedRef: false })), + connection.layer, + ); + yield* advance(8); + const exit = yield* Fiber.await(fiber); + expect(failureText(exit)).toContain("DbConfigConnectTempRoleError"); + expect(connection.attempts.count).toBe(9); + expect(requests.some((request) => request.method === "DELETE")).toBe(false); + }).pipe(Effect.scoped, Effect.provide([BunServices.layer, BunCrypto.layer])); + }); + it.effect("connects without removing bans that are not blocking the caller", () => { const requests: Array<{ readonly method: string; readonly path: string }> = []; const connection = failingConnect(3); diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index f2151bd67e..99f4ee6c1c 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -33,7 +33,12 @@ import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { Tty } from "../shared/runtime/tty.service.ts"; import { Analytics } from "../shared/telemetry/analytics.service.ts"; import { TelemetryRuntime } from "../shared/telemetry/runtime.service.ts"; -import { type ConnectSuggestionContext, SUGGEST_ENV_VAR } from "./connect-errors.ts"; +import { + type ConnectSuggestionContext, + SUGGEST_CA_VERIFICATION, + SUGGEST_ENV_VAR, + suggestNetworkRestrictions, +} from "./connect-errors.ts"; import { DbConnection, type PgConnInput } from "./db-connection.service.ts"; import { IdentityStitch } from "./identity-stitch.ts"; import { @@ -57,6 +62,7 @@ import { mapHttpError } from "./http-errors.ts"; import { currentStackBackend } from "./stack-backend.ts"; import { StackApi, stackApiLayer } from "./stack-api.ts"; import { stackLocalDatabaseConn } from "./stack-local-database.ts"; +import { pinSupabaseTls } from "./supabase-tls.ts"; import { block, type OperationEntry } from "./command-permissions/model.ts"; import { renderDbUrlTarget, renderLocalTarget } from "./target/target-identity.ts"; import { isCheckoutLinked } from "../shared/config/link-snapshot.ts"; @@ -316,12 +322,17 @@ const NO_BANS: BanState = { listed: [], failure: Option.none() }; // Verify-connect with backoff while the pooler refreshes the temp password. From attempt 3 on, look // for network bans on the project: with approval they are removed and a failed removal is what the -// run reports if the retries run out; without it the last listing is reported instead. +// run reports if the retries run out; without it the last listing is reported instead. A rejected +// server certificate or an IP rejected by the project's network restrictions ends the loop at +// once: neither changes between attempts, and bans do not cause them. Every other failure keeps +// retrying, dial failures included, because a ban surfaces as a dial failure and only the retry +// path lists bans. const waitForTempRole = Effect.fn("DbConfig.waitForTempRole")(function* ( ref: string, conn: PgConnInput, dnsResolver: "native" | "https", removeNetworkBans: boolean, + dashboardUrl: string, ) { const dbConn = yield* DbConnection; const debug = yield* DebugLogger; @@ -334,6 +345,22 @@ const waitForTempRole = Effect.fn("DbConfig.waitForTempRole")(function* ( Effect.scoped(dbConn.connect(conn, { isLocal: false, dnsResolver }).pipe(Effect.asVoid)).pipe( Effect.ensuring(Effect.annotateCurrentSpan("retry.attempt_count", n)), Effect.catch((cause) => { + if (cause.certificateRejected === true) { + return Effect.fail( + new Errors.DbConfigConnectTempRoleError({ + message: `failed to connect as temp role: ${cause.message}`, + suggestion: SUGGEST_CA_VERIFICATION, + }), + ); + } + if (cause.networkRestricted === true) { + return Effect.fail( + new Errors.DbConfigConnectTempRoleError({ + message: `failed to connect as temp role: ${cause.message}`, + suggestion: suggestNetworkRestrictions(dashboardUrl), + }), + ); + } // 8 retries after the initial attempt allows 9 total attempts. `n` is 1-based, so give // up only after attempt 9 (`n > MAX_RETRIES`), not at attempt 8. if (n > MAX_RETRIES) { @@ -512,6 +539,7 @@ const fetchApiPoolerUrl = Effect.fn("DbConfig.fetchApiPoolerUrl")( const resolvePoolerConn = Effect.fn("DbConfig.resolvePoolerConn")(function* ( ref: string, workdir: string, + profile: string, poolerHost: string, dnsResolver: "native" | "https", password: string, @@ -524,6 +552,7 @@ const resolvePoolerConn = Effect.fn("DbConfig.resolvePoolerConn")(function* ( ignoreSavedUrl = false, resolveVaultSecrets = true, removeNetworkBans: boolean, + dashboardUrl: string, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -551,14 +580,14 @@ const resolvePoolerConn = Effect.fn("DbConfig.resolvePoolerConn")(function* ( } } if (Option.isNone(pooler)) return Option.none(); - const poolerConn = pooler.value; + const poolerConn = pinSupabaseTls(pooler.value, profile); if (password.length > 0) return Option.some({ ...poolerConn, password }); // Mint a temp role; preserve Supavisor's `.` tenant suffix. const originalUser = poolerConn.user; const withRole = yield* initLoginRole(ref, poolerConn); const finalUser = originalUser.endsWith(`.${ref}`) ? `${withRole.user}.${ref}` : withRole.user; const tempConn = { ...withRole, user: finalUser }; - yield* waitForTempRole(ref, tempConn, dnsResolver, removeNetworkBans); + yield* waitForTempRole(ref, tempConn, dnsResolver, removeNetworkBans, dashboardUrl); return Option.some(tempConn); }); @@ -578,6 +607,7 @@ const resolvePoolerConn = Effect.fn("DbConfig.resolvePoolerConn")(function* ( export const resolveLinkedConn = Effect.fn("DbConfig.resolveLinkedConn")(function* ( ref: string, workdir: string, + profile: string, projectHost: string, poolerHost: string, dnsResolver: "native" | "https", @@ -585,6 +615,8 @@ export const resolveLinkedConn = Effect.fn("DbConfig.resolveLinkedConn")(functio options: { /** Whether the temp-role retry may clear listed bans; only a `--yes` approval sets it. */ readonly removeNetworkBans: boolean; + /** The active profile's dashboard, linked from the network restrictions hint. */ + readonly dashboardUrl: string; readonly adHocProjectRef?: boolean; readonly resolveVaultSecrets?: boolean; /** The pooler URL the link recorded for `ref`, used when the direct host is unreachable. */ @@ -605,6 +637,7 @@ export const resolveLinkedConn = Effect.fn("DbConfig.resolveLinkedConn")(functio fetchPoolerFromApi = false, hasPasswordFlag = false, removeNetworkBans, + dashboardUrl, poolerUrl = Option.none(), } = options; const dbPassword = yield* resolveLinkedPassword(ref, workdir, explicitPassword, hasPasswordFlag); @@ -621,24 +654,28 @@ export const resolveLinkedConn = Effect.fn("DbConfig.resolveLinkedConn")(functio yield* Effect.annotateCurrentSpan("db.direct_reachable", reachable); if (reachable) { /* Same libpq TLS and timeout precedence as the pooler URL; host, port, role and database stay - pinned. The empty `passfile=` skips a `.pgpass` read for a password that is discarded. */ + pinned. The empty `passfile=` skips a `.pgpass` read for a password that is discarded. Absent an + explicit `sslmode`/`sslrootcert`, the Supabase CA is pinned, as on the pooler path. */ const parsed = yield* parseConnectionString( `postgresql://postgres@${host}:${DIRECT_PORT}/postgres?passfile=`, ); if (parsed === undefined) { return yield* new Errors.DbConfigLibpqEnvError({ message: LIBPQ_ENV_MESSAGE }); } - const direct = { - ...base, - ...Struct.pick(parsed, [ - "sslmode", - "sslrootcert", - "sslcert", - "sslkey", - "sslpassword", - "connectTimeoutSeconds", - ]), - }; + const direct = pinSupabaseTls( + { + ...base, + ...Struct.pick(parsed, [ + "sslmode", + "sslrootcert", + "sslcert", + "sslkey", + "sslpassword", + "connectTimeoutSeconds", + ]), + }, + profile, + ); if (direct.password.length > 0) return direct; return yield* initLoginRole(ref, direct); } @@ -649,6 +686,7 @@ export const resolveLinkedConn = Effect.fn("DbConfig.resolveLinkedConn")(functio const poolerConn = yield* resolvePoolerConn( ref, workdir, + profile, poolerHost, dnsResolver, base.password, @@ -657,6 +695,7 @@ export const resolveLinkedConn = Effect.fn("DbConfig.resolveLinkedConn")(functio adHocProjectRef, resolveVaultSecrets, removeNetworkBans, + dashboardUrl, ); if (Option.isNone(poolerConn)) { return yield* new Errors.DbConfigIpv6Error({ @@ -877,6 +916,7 @@ export const dbConfigResolverLayer = Layer.effect( return yield* resolveLinkedConn( ref, cliSettings.workdir, + cliSettings.profile, cliSettings.projectHost, cliSettings.poolerHost, options.dnsResolver, @@ -888,6 +928,7 @@ export const dbConfigResolverLayer = Layer.effect( hasPasswordFlag: options.hasPasswordFlag, removeNetworkBans: options.removeNetworkBans, poolerUrl: options.poolerUrl, + dashboardUrl: cliSettings.dashboardUrl, }, ); }); @@ -977,6 +1018,7 @@ export const dbConfigResolverLayer = Layer.effect( return yield* resolvePoolerConn( target.ref, cliSettings.workdir, + cliSettings.profile, cliSettings.poolerHost, input.dnsResolver, password, @@ -985,6 +1027,7 @@ export const dbConfigResolverLayer = Layer.effect( !target.matchesLinkedRef, input.resolveVaultSecrets ?? true, canRemoveNetworkBans(input), + cliSettings.dashboardUrl, ); }).pipe( Effect.provide( diff --git a/apps/cli/src/command-internal/db-connection.errors.ts b/apps/cli/src/command-internal/db-connection.errors.ts index fc4391d2ac..ca9b241fff 100644 --- a/apps/cli/src/command-internal/db-connection.errors.ts +++ b/apps/cli/src/command-internal/db-connection.errors.ts @@ -17,6 +17,16 @@ export class DbConnectError extends Data.TaggedError("DbConnectError")<{ * fresh-db bootstrap's connect retry keys off this field. */ readonly retryable?: boolean; + /** + * True when the server certificate failed verification; the temp-role probe keys off this + * field to fail fast, since no retry can change the certificate. + */ + readonly certificateRejected?: boolean; + /** + * True when the pooler rejected the caller's IP for project network restrictions; the + * temp-role probe keys off this field to fail fast, since no retry can change the allow list. + */ + readonly networkRestricted?: boolean; /** * True when the failure is an IPv6 dial failure that an IPv4 pooler retry can recover; * `gen types`' pooler fallback keys off this field. diff --git a/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts b/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts index 756bb30f62..07664e9461 100644 --- a/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts +++ b/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts @@ -10,6 +10,8 @@ import { to as pgCopyTo } from "pg-copy-streams"; import { connectFailureMessage, connectSuggestion, + isCertificateVerificationFailure, + isNetworkRestrictionFailure, isDialFailure, isIPv6ConnectivityErrorCause, isSqlState, @@ -660,6 +662,8 @@ export const toConnectError = ( }${connectFailureMessage(cfg, error)}`, ...(suggestion === undefined ? {} : { suggestion }), ...(isDialFailure(error) ? { retryable: true } : {}), + ...(isCertificateVerificationFailure(error) ? { certificateRejected: true } : {}), + ...(isNetworkRestrictionFailure(error) ? { networkRestricted: true } : {}), ...(isIPv6ConnectivityErrorCause(error) ? { ipv6Unreachable: true } : {}), }); }; diff --git a/apps/cli/src/command-internal/pgdelta-ssl.ts b/apps/cli/src/command-internal/pgdelta-ssl.ts deleted file mode 100644 index 9d981842c0..0000000000 --- a/apps/cli/src/command-internal/pgdelta-ssl.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * The Supabase CA bundle for TLS-requiring remote Postgres endpoints (staging + production + - * snapshot roots, concatenated). The migra engine passes it to its edge-runtime script as - * `SSL_CA` when the SSL probe reports the server requires TLS. - */ -export const PG_DELTA_CA_BUNDLE = - "-----BEGIN CERTIFICATE-----\nMIID1DCCArygAwIBAgIUbYRdq/8/uNq8G9stMCdOFSBgA2MwDQYJKoZIhvcNAQEL\nBQAwczELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5l\ndyBDYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEmMCQGA1UEAwwdU3VwYWJh\nc2UgU3RhZ2luZyBSb290IDIwMjEgQ0EwHhcNMjEwNDI4MTAzNjEzWhcNMzEwNDI2\nMTAzNjEzWjBzMQswCQYDVQQGEwJVUzEQMA4GA1UECAwHRGVsd2FyZTETMBEGA1UE\nBwwKTmV3IENhc3RsZTEVMBMGA1UECgwMU3VwYWJhc2UgSW5jMSYwJAYDVQQDDB1T\ndXBhYmFzZSBTdGFnaW5nIFJvb3QgMjAyMSBDQTCCASIwDQYJKoZIhvcNAQEBBQAD\nggEPADCCAQoCggEBAN0AKRE8a56O8LaZxiOAcHFUFnwiKUvPoXPq26Ifw+Nv+7zg\nN2V5WnMZbbw24q61Os60ZUn0XmbVtuIeJ+stPHsO7qxxuL+bmPR+qU5tkDrIOyEe\nYD/2u8/q6ssVv42k4XcXbhM6RVz7CkCDY0TiBm1bMtRZso3xB6E9wAjxDf43XfV5\nPAGs3JI+Zo/vyqCDlN0hHOrB/aBl01JXqQWI84Gia5ooucq4SjA1CyawBcQ2IAvG\nrXuy1BouY+xM3zRuNvtfFP6rb5Mta+jCYEMh1AZ8yP8sYUWAyhxX6k9EbOb009wQ\naZljbUCh/UglGWuBxdzePavx+zPjzWXB1NyVkpkCAwEAAaNgMF4wCwYDVR0PBAQD\nAgEGMB0GA1UdDgQWBBQFx+PHLf27iIo/PMfIfGqXF7Zb+DAfBgNVHSMEGDAWgBQF\nx+PHLf27iIo/PMfIfGqXF7Zb+DAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEB\nCwUAA4IBAQB/xIiz5dDqzGXjqYqXZYx4iSfSxsVayeOPDMfmaiCfSMJEUG4cUiwG\nOvMPGztaUEYeip5SCvSKuAAjVkXyP7ahKR7t7lZ9mErVXyxSZoVLbOd578CuYiZk\nOgT17UjPv66WMzEKEr8wGpomTYWWfEkuqt8ENdiM1Z4LNFahdKj36+jm6/a+9R8K\n25VIL68DTaQpBxFWG6ixC1HRMHJ12lDhKsshIi099BVpkGibESlxPrQOdKKqBB/J\nvIX+/Hb+mS4H5zYMeK2wX0onp+GBcD6X9L1UJuXMVd+BRan8RFidXL5s3++xXjQq\nNzbc6lnA69urKffvcT07YwMsY/OmHzVa\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIDxDCCAqygAwIBAgIUbLxMod62P2ktCiAkxnKJwtE9VPYwDQYJKoZIhvcNAQEL\nBQAwazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5l\ndyBDYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJh\nc2UgUm9vdCAyMDIxIENBMB4XDTIxMDQyODEwNTY1M1oXDTMxMDQyNjEwNTY1M1ow\nazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5ldyBD\nYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJhc2Ug\nUm9vdCAyMDIxIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqQXW\nQyHOB+qR2GJobCq/CBmQ40G0oDmCC3mzVnn8sv4XNeWtE5XcEL0uVih7Jo4Dkx1Q\nDmGHBH1zDfgs2qXiLb6xpw/CKQPypZW1JssOTMIfQppNQ87K75Ya0p25Y3ePS2t2\nGtvHxNjUV6kjOZjEn2yWEcBdpOVCUYBVFBNMB4YBHkNRDa/+S4uywAoaTWnCJLUi\ncvTlHmMw6xSQQn1UfRQHk50DMCEJ7Cy1RxrZJrkXXRP3LqQL2ijJ6F4yMfh+Gyb4\nO4XajoVj/+R4GwywKYrrS8PrSNtwxr5StlQO8zIQUSMiq26wM8mgELFlS/32Uclt\nNaQ1xBRizkzpZct9DwIDAQABo2AwXjALBgNVHQ8EBAMCAQYwHQYDVR0OBBYEFKjX\nuXY32CztkhImng4yJNUtaUYsMB8GA1UdIwQYMBaAFKjXuXY32CztkhImng4yJNUt\naUYsMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAB8spzNn+4VU\ntVxbdMaX+39Z50sc7uATmus16jmmHjhIHz+l/9GlJ5KqAMOx26mPZgfzG7oneL2b\nVW+WgYUkTT3XEPFWnTp2RJwQao8/tYPXWEJDc0WVQHrpmnWOFKU/d3MqBgBm5y+6\njB81TU/RG2rVerPDWP+1MMcNNy0491CTL5XQZ7JfDJJ9CCmXSdtTl4uUQnSuv/Qx\nCea13BX2ZgJc7Au30vihLhub52De4P/4gonKsNHYdbWjg7OWKwNv/zitGDVDB9Y2\nCMTyZKG3XEu5Ghl1LEnI3QmEKsqaCLv12BnVjbkSeZsMnevJPs1Ye6TjjJwdik5P\no/bKiIz+Fq8=\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIDxzCCAq+gAwIBAgIUeX+gpfmsRW9asFkRvjyXjHxbfgcwDQYJKoZIhvcNAQEL\nBQAwazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5l\ndyBDYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJh\nc2UgUm9vdCAyMDIxIENBMB4XDTI1MDkwMzA4MDEyNVoXDTM1MDkwMTA4MDEyNVow\nazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5ldyBD\nYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJhc2Ug\nUm9vdCAyMDIxIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5Ve7\ni9UAmc7luUilELPtqzEk8nGHxg7nY0aCStr625M7+K4OPO6RUllTsHh47k1jWyzm\nLXLlyYwCsYCjQp+3vn06H+F/HRUxBt6CK2B7bNng230exTunk0xFvfkX6YgHR7B3\n1B7L25Rq3PhuRFPV4hnGYRam2XBZC4UNPqoAgrhV0HOYzXXAVoTr2yaBTMnB331Z\nRwOmINh7eqTCk/JRZbb6vfZOhZRAVAe9AoRLoG8aKwmeoLGwlu0UuFx6z3E+6bmA\nfSNa8Lx02GEoCdPLw9IRKUFq/SgBpQUKm44H1fDwTjH2CMM0N4p0mL/6wXnNeHvt\nC40MmKZ0RcVmHE5wBwIDAQABo2MwYTAdBgNVHQ4EFgQUjvEE541toZcwtXQlZlcB\nYOBRTnowHwYDVR0jBBgwFoAUjvEE541toZcwtXQlZlcBYOBRTnowDwYDVR0TAQH/\nBAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZIhvcNAQELBQADggEBACD5IcGP\nXKvS9qg0CgEQPFqYavt5c7P+0xxFgiZe+xoG8fUw58yNeK2APtgGPRpxEOGfAlNx\nz9HDt4gcyHEE00B3qAVDm49pqNxioFWzNqU2LGfM/HL1QmN6urR7hCOkVCJddvOc\nFhFX4nZDuRfaBboDvS5HlK3Pzxddp9hvrJi2bemr8HLqYc3HzmVckgPGSLML6t+h\n4LRCXSlQsDgQ1LZ4KHsl4cq7K51N6FOXQBLB5q4lMKhs0VUhCT8Pdsj12+84laCV\nc22q6p2mdT9SaernCSRnWazXWisgpjv3H7Ex4S1DCYjJIwn3PUToGFv1r8YRN2/S\nO19yVSxxCIf64Sg=\n-----END CERTIFICATE-----\n"; diff --git a/apps/cli/src/command-internal/pgdelta-ssl.unit.test.ts b/apps/cli/src/command-internal/pgdelta-ssl.unit.test.ts deleted file mode 100644 index 68844c665d..0000000000 --- a/apps/cli/src/command-internal/pgdelta-ssl.unit.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { PG_DELTA_CA_BUNDLE } from "./pgdelta-ssl.ts"; - -describe("PG_DELTA_CA_BUNDLE", () => { - it("concatenates the three Supabase CA certificates", () => { - expect(PG_DELTA_CA_BUNDLE.match(/BEGIN CERTIFICATE/g)).toHaveLength(3); - }); -}); diff --git a/apps/cli/src/command-internal/storage-gateway.unit.test.ts b/apps/cli/src/command-internal/storage-gateway.unit.test.ts index 0ae48a9be2..48fb95bd7b 100644 --- a/apps/cli/src/command-internal/storage-gateway.unit.test.ts +++ b/apps/cli/src/command-internal/storage-gateway.unit.test.ts @@ -7,7 +7,7 @@ import * as HttpClientResponse from "effect/http/HttpClientResponse"; import type * as HttpClientRequest from "effect/http/HttpClientRequest"; import { withEnvVar } from "../../tests/helpers/command-mocks.ts"; -import caProd2021 from "../commands/gen/types/templates/prod-ca-2021.ts"; +import caProd2021 from "./supabase-ca-templates/prod-ca-2021.ts"; import { KONG_LOCAL_TLS_CERT, KONG_LOCAL_TLS_KEY, diff --git a/apps/cli/src/commands/gen/types/templates/prod-ca-2021.ts b/apps/cli/src/command-internal/supabase-ca-templates/prod-ca-2021.ts similarity index 100% rename from apps/cli/src/commands/gen/types/templates/prod-ca-2021.ts rename to apps/cli/src/command-internal/supabase-ca-templates/prod-ca-2021.ts diff --git a/apps/cli/src/commands/gen/types/templates/prod-ca-2025.ts b/apps/cli/src/command-internal/supabase-ca-templates/prod-ca-2025.ts similarity index 100% rename from apps/cli/src/commands/gen/types/templates/prod-ca-2025.ts rename to apps/cli/src/command-internal/supabase-ca-templates/prod-ca-2025.ts diff --git a/apps/cli/src/commands/gen/types/templates/staging-ca-2021.ts b/apps/cli/src/command-internal/supabase-ca-templates/staging-ca-2021.ts similarity index 100% rename from apps/cli/src/commands/gen/types/templates/staging-ca-2021.ts rename to apps/cli/src/command-internal/supabase-ca-templates/staging-ca-2021.ts diff --git a/apps/cli/src/command-internal/supabase-tls.ts b/apps/cli/src/command-internal/supabase-tls.ts new file mode 100644 index 0000000000..138bdd995d --- /dev/null +++ b/apps/cli/src/command-internal/supabase-tls.ts @@ -0,0 +1,36 @@ +import { getDomain } from "tldts"; + +import type { PgConnInput } from "./db-connection.service.ts"; +import { isDirectDbHost } from "./pooler-fallback.ts"; +import { poolerHost, projectHost } from "./profile.ts"; +import caProd2021 from "./supabase-ca-templates/prod-ca-2021.ts"; +import caProd2025 from "./supabase-ca-templates/prod-ca-2025.ts"; +import caStaging2021 from "./supabase-ca-templates/staging-ca-2021.ts"; + +export function rootCaBundle() { + return `${caStaging2021}${caProd2021}${caProd2025}`; +} + +/** Built-in profiles whose database servers chain to {@link rootCaBundle}. */ +const PINNED_PROFILES: ReadonlySet = new Set(["supabase", "supabase-staging", "snap"]); + +/** + * Pins the bundled Supabase CA (`require` plus an inline root CA, which the driver promotes to + * `verify-ca`) on a connection to a hosted Supabase database of the active built-in profile. A + * root CA set without `sslmode` replaces the bundle but keeps `require`, since the unset default + * (`prefer`) would ignore it. A connection that sets `sslmode` from any source, a host outside + * the profile's project and pooler domains, and every other profile (`supabase-local`, YAML) are + * returned unchanged. + */ +export function pinSupabaseTls(conn: PgConnInput, profile: string): PgConnInput { + if (!PINNED_PROFILES.has(profile) || conn.sslmode !== undefined) return conn; + const host = conn.host.toLowerCase().replace(/\.$/, ""); + const domain = getDomain(host); + const hosted = + isDirectDbHost(host, projectHost(profile)) || + (domain !== null && domain.toLowerCase() === poolerHost(profile)); + if (!hosted) return conn; + return conn.sslrootcert !== undefined || conn.sslrootcertInline !== undefined + ? { ...conn, sslmode: "require" } + : { ...conn, sslmode: "require", sslrootcertInline: rootCaBundle() }; +} diff --git a/apps/cli/src/command-internal/supabase-tls.unit.test.ts b/apps/cli/src/command-internal/supabase-tls.unit.test.ts new file mode 100644 index 0000000000..c3bec69a6e --- /dev/null +++ b/apps/cli/src/command-internal/supabase-tls.unit.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import type { PgConnInput } from "./db-connection.service.ts"; +import { pinSupabaseTls, rootCaBundle } from "./supabase-tls.ts"; + +const conn = (host: string, overrides: Partial = {}): PgConnInput => ({ + host, + port: 5432, + user: "postgres", + password: "pw", + database: "postgres", + ...overrides, +}); + +describe("pinSupabaseTls host matching", () => { + it.each([ + "db.ref.supabase.co", + "DB.REF.SUPABASE.CO", + "db.ref.supabase.co.", + "aws-0-us-east-1.pooler.supabase.com", + "AWS-0-US-EAST-1.POOLER.SUPABASE.COM.", + ])("pins %s", (host) => { + expect(pinSupabaseTls(conn(host), "supabase")).toMatchObject({ + sslmode: "require", + sslrootcertInline: rootCaBundle(), + }); + }); + + it.each([ + "db.x.evilsupabase.co", + "db.ref.supabase.co.evil.com", + "supabase.co", + "localhost", + "127.0.0.1", + "::1", + "2600:1f18::1", + ])("leaves %s unpinned", (host) => { + const input = conn(host); + expect(pinSupabaseTls(input, "supabase")).toBe(input); + }); + + it("leaves a connection that chose its own sslmode unchanged", () => { + const input = conn("db.ref.supabase.co", { sslmode: "disable" }); + expect(pinSupabaseTls(input, "supabase")).toBe(input); + }); + + it("requires TLS verified against a root CA set without sslmode", () => { + const pinned = pinSupabaseTls( + conn("db.ref.supabase.co", { sslrootcert: "/ca.pem" }), + "supabase", + ); + expect(pinned).toMatchObject({ sslmode: "require", sslrootcert: "/ca.pem" }); + expect(pinned.sslrootcertInline).toBeUndefined(); + }); +}); diff --git a/apps/cli/src/commands/bootstrap/bootstrap.handler.ts b/apps/cli/src/commands/bootstrap/bootstrap.handler.ts index 08aed02089..32b2f96d0e 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.handler.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.handler.ts @@ -25,6 +25,7 @@ import { getProjectApiKeys } from "../../command-internal/get-api-keys.ts"; import { sanitizeErrorBody } from "../../command-internal/http-errors.ts"; import type { ConnectSuggestionContext } from "../../command-internal/connect-errors.ts"; import { resolveLinkedConn } from "../../command-internal/db-config.layer.ts"; +import { pinSupabaseTls } from "../../command-internal/supabase-tls.ts"; import { checkDbConfig } from "../../command-internal/db-config.read.ts"; import { dbPushCore } from "../../command-internal/db-push-core.ts"; import { @@ -372,6 +373,7 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( const resolvedConn = yield* resolveLinkedConn( projectRef, workdir, + cliSettings.profile, cliSettings.projectHost, cliSettings.poolerHost, dnsResolver, @@ -379,10 +381,13 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( { removeNetworkBans: Option.isSome(yield* hostedMutationPreapproval), poolerUrl: linkSnapshot.poolerUrl, + dashboardUrl: cliSettings.dashboardUrl, }, ).pipe( Effect.catchTag("DbConfigIpv6Error", (error) => - output.raw(`${error.message}\n`, "stderr").pipe(Effect.as(dbConfig)), + output + .raw(`${error.message}\n`, "stderr") + .pipe(Effect.as(pinSupabaseTls(dbConfig, cliSettings.profile))), ), ); const conn = { ...resolvedConn, suggestionContext }; diff --git a/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts b/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts index 31c193bd24..c48366681d 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts @@ -58,6 +58,7 @@ import { import { LinkedProjectCache } from "../../telemetry/linked-project-cache.service.ts"; import { DbConnectError } from "../../command-internal/db-connection.errors.ts"; import { DbConnection, type PgConnInput } from "../../command-internal/db-connection.service.ts"; +import { rootCaBundle } from "../../command-internal/supabase-tls.ts"; import { debugLoggerLayer } from "../../command-internal/debug-logger.layer.ts"; import { TargetPolicy } from "../../command-internal/target/target-policy.ts"; import { TemplateService, type StarterTemplate } from "./bootstrap.templates.ts"; @@ -768,6 +769,10 @@ describe("bootstrap integration", () => { expect(s.pushConnectCalls[0]?.user).toBe("postgres"); expect(s.pushConnectCalls[0]?.database).toBe("postgres"); expect(s.pushConnectCalls[0]?.password).toBe("s3cret"); + expect(s.pushConnectCalls[0]).toMatchObject({ + sslmode: "require", + sslrootcertInline: rootCaBundle(), + }); expect(s.out.stdoutText).toContain("Remote database is up to date."); }).pipe(Effect.provide(BunServices.layer)), ); diff --git a/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md b/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md index 8fd54cf5cd..60a2e61772 100644 --- a/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md @@ -112,7 +112,9 @@ shell inherits the suppressing variables and is missed. - **TLS settings in `pg_dump`.** A remote target (a remote project or branch, or `--remote=`) gets the resolved `sslmode` and connect timeout, the root cert only under `require`/`verify-ca`/`verify-full`, and the client cert and key unless the key needs - `sslpassword` (libpq reads no passphrase from the environment). `system` pins + `sslpassword` (libpq reads no passphrase from the environment). The Supabase CA pin that + `db push` describes reaches `pg_dump` as `PGSSLMODE=require` only: the inline CA is not + forwarded, so `pg_dump` requires TLS without verifying the server. `system` pins `verify-full`. Verifying without a root cert, or with `system`, uses the image's trust store in a container and the host's CA bundle for a native `pg_dump`, which keeps libpq's `~/.postgresql/root.crt` when no root cert is set, keeps `system` on libpq 16+ diff --git a/apps/cli/src/commands/db/push/SIDE_EFFECTS.md b/apps/cli/src/commands/db/push/SIDE_EFFECTS.md index 6b74b50f73..01b28f9cfd 100644 --- a/apps/cli/src/commands/db/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/push/SIDE_EFFECTS.md @@ -144,11 +144,23 @@ project ref and its environment. adopted into TS in PR supabase/cli#5671 (landed on develop as `b48fad60`). - **Remote-project libpq env.** On every remote-project database connection, `PGSSLMODE`, `PGSSLROOTCERT`, `PGSSLCERT`/`PGSSLKEY`/`PGSSLPASSWORD`, `PGCONNECT_TIMEOUT`, and `PGSERVICE`/`PGSERVICEFILE` are - read from the shell only, never the project `.env*` (`gen types` keeps its pinned mode and CA, - except on the pooler temp-role probe), on the direct host and the IPv4 pooler alike; host, port, - role, and database stay pinned. As in libpq, a `PGSERVICE` entry's settings outrank these vars. + read from the shell only, never the project `.env*`, on the direct host and the IPv4 pooler + alike; host, port, role, and database stay pinned. As in libpq, a `PGSERVICE` entry's settings outrank these vars. An invalid mode or `PGCONNECT_TIMEOUT`, an unknown `PGSERVICE`, or a lone `PGSSLCERT`/`PGSSLKEY` outside `sslmode=disable` fails with `invalid libpq environment` on either route. + **Supabase CA pin (every remote-project connection, shared by all commands that resolve a linked + database):** when `sslmode` is not set (query, `pg_service.conf` or `PGSSLMODE`) and the host + is a `db..` direct host or a + pooler host of the `supabase`, `supabase-staging` or `snap` built-in profile, the connection uses + `sslmode=require` with the bundled Supabase root CA, which the driver verifies as `verify-ca` + (chain checked, hostname not). A root cert set without `sslmode` (`PGSSLROOTCERT`, query or + service) replaces the bundled CA but keeps `sslmode=require`, so it is verified the same way. This covers the temp-role probe through the pooler and the + container pooler fallback. A server certificate that fails verification ends the temp-role probe + at once, without its retries, and suggests setting `PGSSLROOTCERT` or `PGSSLMODE=require`. So + does a pooler rejection of the caller's IP (`Address not in tenant allow_list`), which suggests + checking Network Restrictions and Network Bans and links the dashboard's database settings. Any + explicit `sslmode`, `disable` and `verify-full` included, is passed through unchanged. `supabase-local` and YAML profiles are + never pinned. `sslmode=disable` dials plaintext and reads no CA or client-cert file; otherwise those PEM files are read when set and a missing one fails the connection, and a `pg_service.conf` is read whenever `PGSERVICE` names a service. The pooler route also reads `.pgpass` (`PGPASSFILE`, else diff --git a/apps/cli/src/commands/db/shared/migra.ts b/apps/cli/src/commands/db/shared/migra.ts index 9f07c37a8a..c9e43caa0f 100644 --- a/apps/cli/src/commands/db/shared/migra.ts +++ b/apps/cli/src/commands/db/shared/migra.ts @@ -16,7 +16,7 @@ import { import { parseConnectionString } from "../../../command-internal/db-config.parse.ts"; import { DockerRun } from "../../../command-internal/docker-run.service.ts"; import { EdgeRuntimeScript } from "../../../command-internal/edge-runtime-script.service.ts"; -import { PG_DELTA_CA_BUNDLE } from "../../../command-internal/pgdelta-ssl.ts"; +import { rootCaBundle } from "../../../command-internal/supabase-tls.ts"; import { PgDeltaSslProbe } from "../../../command-internal/pgdelta-ssl-probe.service.ts"; import { rewriteDumpHostForToolContainer, @@ -148,7 +148,7 @@ const buildMigraEnv = Effect.fn("Migra.buildEnv")(function* (params: { if (yield* isSslDebugEnabled) env["SUPABASE_SSL_DEBUG"] = "true"; // Probe the target for TLS; if it speaks TLS, inject the embedded CA bundle as SSL_CA. const requireSsl = yield* probe.requireSsl(params.target); - if (requireSsl) env["SSL_CA"] = PG_DELTA_CA_BUNDLE; + if (requireSsl) env["SSL_CA"] = rootCaBundle(); if (params.schema.length > 0) { env["INCLUDED_SCHEMAS"] = params.schema.join(","); } else { diff --git a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md index d65dc1def0..c82eb7e2da 100644 --- a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md @@ -30,7 +30,7 @@ instead of inspecting `supabase_db_*`. | `~/.pg_service.conf` (or a `PGSERVICEFILE`/DSN `servicefile=` override) | libpq service file | `--remote=`, only when the DSN or `PGSERVICE` names a `service` — its settings fill in for connection fields the DSN itself omits | | `Package.resolved` (nearest) | JSON | `--lang swift` without `SUPABASE_TYPEGEN`, on macOS or Linux x86_64: the first one found walking up from the working directory, including the copies inside an `.xcodeproj` or `.xcworkspace`; the supabase-swift version it pins picks the `supabase-typegen` release | | `/supabase/.temp/rest-version` | plain text | the local target, when `db.major_version > 14` — forces v9 compat if the tag contains `v9`; and `--lang=typescript` on the linked project, where it is required for direct generation and, without its leading `v`, becomes `__InternalSupabase.PostgrestVersion` | -| `$PGSSLROOTCERT`, `$PGSSLCERT` / `$PGSSLKEY`, `pg_service.conf` | PEM, libpq | a remote project (any `--lang`), when `PGSSLCERT`/`PGSSLKEY` or `PGSERVICE` are set; `$PGSSLROOTCERT` only on the pooler temp-role probe; the PEM files are skipped under `PGSSLMODE=disable` | +| `$PGSSLROOTCERT`, `$PGSSLCERT` / `$PGSSLKEY`, `pg_service.conf` | PEM, libpq | a remote project (any `--lang`), when `PGSSLCERT`/`PGSSLKEY` or `PGSERVICE` are set; `$PGSSLROOTCERT` also replaces the pinned Supabase CA; the PEM files are skipped under `PGSSLMODE=disable` | ## Files Written @@ -114,7 +114,7 @@ way `--remote=` does. | `SUPABASE_TYPEGEN` | path of the `supabase-typegen` binary `--lang swift` runs, skipping the version lookup and download | no | | `XDG_CACHE_HOME`, `HOME` | locate the cache directory of the `--lang swift` release binary | no | | `PATH`, `PATHEXT`, `ComSpec` | find the tool of an out-of-process `--lang`; `PATHEXT` and `ComSpec` matter on Windows only | no (`PATHEXT` defaults to `.COM;.EXE;.BAT;.CMD`, `ComSpec` to `cmd.exe`; without `PATH` the tool is reported as not installed) | -| `PGSSLMODE`, `PGSSLROOTCERT`, `PGSSLCERT`, `PGSSLKEY`, `PGSSLPASSWORD`, `PGSERVICE`, `PGSERVICEFILE` | a remote project (any `--lang`): client-cert pair outside `sslmode=disable`; invalid values fail (TypeScript falls back to the Management API instead); mode and CA stay pinned except on the pooler temp-role probe (see Notes) | no | +| `PGSSLMODE`, `PGSSLROOTCERT`, `PGSSLCERT`, `PGSSLKEY`, `PGSSLPASSWORD`, `PGSERVICE`, `PGSERVICEFILE` | a remote project (any `--lang`): client-cert pair outside `sslmode=disable`; invalid values fail (TypeScript falls back to the Management API instead); an explicit `sslmode`/`sslrootcert` replaces the Supabase CA pin (see Notes) | no | | `PGCONNECT_TIMEOUT` | same path: the 15s query-timeout default pins it on the project database; the pooler temp-role probe always uses it | no | ## Exit Codes @@ -226,14 +226,20 @@ removed in the next major version.` stdout carries only the types. PostgreSQL connection, and there is no container to join a Docker network from. Use a host-reachable `--remote=` instead. - **TLS.** There is no SSL probe on any path. - - A remote project (including a resolved preview branch) connects with `sslmode=require` and the bundled Supabase CA pinned - (promoted to `verify-ca`), matching prior behavior. On a project ref the `PG*` env - (see `db push`) is validated and supplies the client-cert pair, and the 15s query timeout - sets the connect timeout; the pooler temp-role probe runs before the pin and takes the shell - `PG*` TLS and timeout settings; a preview branch's direct host takes no `PG*` setting. + - A remote project (including a resolved preview branch) on a `supabase`, `supabase-staging` or + `snap` profile host connects with `sslmode=require` and the bundled Supabase CA pinned (promoted to + `verify-ca`) unless `sslmode` is set from any source, which is then used as is. A root cert + set without `sslmode` replaces the bundled CA and keeps `require`. The connection resolver applies the pin for a project ref, its direct host and pooler + alike, including the pooler temp-role probe (see `db push`); this command applies it itself + only to a preview branch. On a project ref the `PG*` env (see `db push`) is validated and + supplies the client-cert pair, and the 15s query timeout sets the connect timeout. A preview + branch's direct host takes no `PG*` setting and is always pinned; its pooler fallback parses + the pooler URL with the shell `PG*` env, so `PGSSLMODE`, `PGSSLROOTCERT`, the client-cert pair + and `PGCONNECT_TIMEOUT` apply there, and a set `PGSSLMODE` or `PGSSLROOTCERT` replaces the pin. - `--remote=` honors the DSN's own `sslmode`/`sslrootcert` when either is set, even against a target classified local (e.g. a TLS tunnel on the loopback stack); - otherwise, a known Supabase host gets the Supabase CA pinned the same way, and any + otherwise, a known Supabase host on a `supabase`, `supabase-staging` or `snap` profile gets + the Supabase CA pinned the same way, and any other host — including a loopback target where the DSN sets neither — uses the connection resolver's default: plaintext for a local target, otherwise libpq `prefer` (TLS first, then plaintext when the server declines TLS, the handshake fails, or `pg_hba` rejects diff --git a/apps/cli/src/commands/gen/types/types.handler.ts b/apps/cli/src/commands/gen/types/types.handler.ts index 547e506743..22a534977a 100644 --- a/apps/cli/src/commands/gen/types/types.handler.ts +++ b/apps/cli/src/commands/gen/types/types.handler.ts @@ -1,6 +1,5 @@ import { ChildProcessSpawner } from "effect/process"; import { Effect, FileSystem, Match, Option, Path, Predicate, Result, Stdio, Stream } from "effect"; -import { getDomain } from "tldts"; import { DnsResolverFlag } from "../../../command-internal/global-flags.ts"; import { announceTarget } from "../../../shared/output/command-target.service.ts"; import { Output } from "../../../shared/output/output.service.ts"; @@ -51,6 +50,7 @@ import { isDirectDbHost, runWithPoolerFallback, } from "../../../command-internal/pooler-fallback.ts"; +import { pinSupabaseTls } from "../../../command-internal/supabase-tls.ts"; import type { GenTypesFlags } from "./types.command.ts"; import { apiFallbackWarning, failedBeforeIntrospection } from "./types.api-fallback.ts"; import { @@ -74,12 +74,7 @@ import { import { type GenTypesGenerateError, GenTypesGenerator } from "./types.generator.service.ts"; import { currentStackBackend } from "../../../command-internal/stack-backend.ts"; import { CommandPlatformApiFactory } from "../../../auth/command-platform-api-factory.service.ts"; -import { - defaultSchemas, - localDbContainerId, - parseQueryTimeoutMillis, - rootCaBundle, -} from "./types.shared.ts"; +import { defaultSchemas, localDbContainerId, parseQueryTimeoutMillis } from "./types.shared.ts"; const mapProjectTypesError = mapHttpError({ networkError: GenTypesNetworkError, @@ -109,18 +104,6 @@ function isProjectNotFound(cause: unknown) { return cause instanceof GenTypesUnexpectedStatusError && cause.status === 404; } -/** Pins the Supabase CA on a known-Supabase target, promoting `require` to `verify-ca`. */ -function pinSupabaseTls(conn: PgConnInput): PgConnInput { - return { ...conn, sslmode: "require", sslrootcertInline: rootCaBundle() }; -} - -/** Whether `host`'s registrable domain matches the active profile's pooler domain. */ -function isPoolerHost(host: string, poolerHost: string): boolean { - if (poolerHost.length === 0) return false; - const domain = getDomain(host); - return domain !== null && domain.toLowerCase() === poolerHost.toLowerCase(); -} - const GEN_TYPES_COMMAND_PATH = ["gen", "types"] as const; // A hosted project target excludes these flags; only the first passed, in this order, is reported. @@ -473,7 +456,9 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) cliSettings.poolerHost, ); return Predicate.isTagged(parsed, "ok") - ? Option.some(pinSupabaseTls({ ...parsed.conn, password: branchPassword })) + ? Option.some( + pinSupabaseTls({ ...parsed.conn, password: branchPassword }, cliSettings.profile), + ) : Option.none(); }), ), @@ -483,13 +468,16 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) ); yield* runGenerate({ - conn: pinSupabaseTls({ - host: branch.db_host, - port: branch.db_port, - user: branchUser, - password: branchPassword, - database: "postgres", - }), + conn: pinSupabaseTls( + { + host: branch.db_host, + port: branch.db_port, + user: branchUser, + password: branchPassword, + database: "postgres", + }, + cliSettings.profile, + ), isLocal: false, includedSchemas, extraSearchPath, @@ -574,20 +562,18 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) const connectionInput = yield* connectionInputFor(target); const resolved = yield* dbConfig.resolveConnection(connectionInput); - const conn = pinSupabaseTls(resolved.conn); yield* runGenerate({ - conn, + conn: resolved.conn, isLocal: resolved.isLocal, includedSchemas, extraSearchPath, detectOneToOneRelationships: !flags.postgrestV9Compat, postgrestVersion: Option.getOrUndefined(postgrestVersion), poolerFallback: { - directHost: conn.host, - eligible: !resolved.isLocal && isDirectDbHost(conn.host, cliSettings.projectHost), - resolve: dbConfig - .resolvePoolerFallback(connectionInput) - .pipe(Effect.map(Option.map(pinSupabaseTls))), + directHost: resolved.conn.host, + eligible: + !resolved.isLocal && isDirectDbHost(resolved.conn.host, cliSettings.projectHost), + resolve: dbConfig.resolvePoolerFallback(connectionInput), }, }); }); @@ -794,16 +780,9 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) const extraSearchPath = yield* resolveExtraSearchPath(); const resolved = yield* connectTo(target); - // A DSN's own `sslmode`/`sslrootcert` is honored as-is; only a known Supabase host with - // neither set gets the CA pinned, matching the project-ref/branch paths. - const conn = - !resolved.isLocal && - resolved.conn.sslmode === undefined && - resolved.conn.sslrootcert === undefined && - (isDirectDbHost(resolved.conn.host, cliSettings.projectHost) || - isPoolerHost(resolved.conn.host, cliSettings.poolerHost)) - ? pinSupabaseTls(resolved.conn) - : resolved.conn; + const conn = resolved.isLocal + ? resolved.conn + : pinSupabaseTls(resolved.conn, cliSettings.profile); yield* runGenerate({ conn, diff --git a/apps/cli/src/commands/gen/types/types.integration.test.ts b/apps/cli/src/commands/gen/types/types.integration.test.ts index 89305d13ba..19759865f0 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -95,7 +95,8 @@ import type { import type { GenTypesFlags } from "./types.command.ts"; import { GenTypesLocalDbInspectError, GenTypesLocalDbNotRunningError } from "./types.errors.ts"; import { genTypes } from "./types.handler.ts"; -import { localDbContainerId, parseQueryTimeoutMillis, rootCaBundle } from "./types.shared.ts"; +import { localDbContainerId, parseQueryTimeoutMillis } from "./types.shared.ts"; +import { rootCaBundle } from "../../../command-internal/supabase-tls.ts"; import { stackBackendLayer } from "../../../command-internal/stack-backend.ts"; import { projectStateModeLayer } from "../../../shared/config/project-state-mode.ts"; import { @@ -1982,9 +1983,6 @@ describe("gen types", () => { expect(call?.lang).toBe(scenario.lang); expect(call?.includedSchemas).toEqual(["public"]); expect(call?.isLocal).toBe(false); - // project-ref generation always pins the Supabase CA, promoting sslmode to verify-ca. - expect(call?.conn.sslmode).toBe("require"); - expect(call?.conn.sslrootcertInline).toBe(rootCaBundle()); expect(linkedProjectCache.cached).toBe(true); }).pipe(Effect.provide(BunServices.layer)), ); @@ -2153,56 +2151,65 @@ describe("gen types", () => { }); describe("Preview-branch fallback", () => { - it.effect("falls back to preview branch config for non-TypeScript project refs", () => - Effect.gen(function* () { - const { layer, api, dbConfig, generator } = yield* setup({ - args: ["gen", "types", "--lang", "python", `--remote=${VALID_REF}`], - generatorOutput: "class PublicMovies(BaseModel):", - getProject: () => - Effect.fail(statusApiError(404, `{"message":"Preview branch not found"}`)), - getABranchConfig: ({ branch_id_or_ref }) => - Effect.succeed({ - ref: branch_id_or_ref, - postgres_version: "15.1", - postgres_engine: "15", - release_channel: "ga", - status: "ACTIVE_HEALTHY", - db_host: "127.0.0.1", - db_port: 5432, - db_user: "branch_user", - db_pass: "branch-password", - jwt_secret: "secret", - }), - createLoginRole: ({ ref }) => - Effect.fail( - new ApiCallFailure({ message: `unexpected login role creation for ${ref}` }), - ), - }); - yield* genTypes( - defaultFlags({ - remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), - lang: "python", - }), - ).pipe(Effect.provide(layer)); + for (const { dbHost, pinned } of [ + { dbHost: `db.${VALID_REF}.supabase.co`, pinned: true }, + { dbHost: "127.0.0.1", pinned: false }, + ]) { + it.effect( + `falls back to preview branch config for non-TypeScript project refs (${pinned ? "Supabase" : "other"} host)`, + () => + Effect.gen(function* () { + const { layer, api, dbConfig, generator } = yield* setup({ + args: ["gen", "types", "--lang", "python", `--remote=${VALID_REF}`], + generatorOutput: "class PublicMovies(BaseModel):", + getProject: () => + Effect.fail(statusApiError(404, `{"message":"Preview branch not found"}`)), + getABranchConfig: ({ branch_id_or_ref }) => + Effect.succeed({ + ref: branch_id_or_ref, + postgres_version: "15.1", + postgres_engine: "15", + release_channel: "ga", + status: "ACTIVE_HEALTHY", + db_host: dbHost, + db_port: 5432, + db_user: "branch_user", + db_pass: "branch-password", + jwt_secret: "secret", + }), + createLoginRole: ({ ref }) => + Effect.fail( + new ApiCallFailure({ message: `unexpected login role creation for ${ref}` }), + ), + }); + yield* genTypes( + defaultFlags({ + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), + lang: "python", + }), + ).pipe(Effect.provide(layer)); - expect(api.requests).toContainEqual({ method: "getProject", input: { ref: VALID_REF } }); - expect(api.requests).toContainEqual({ - method: "getABranchConfig", - input: { branch_id_or_ref: VALID_REF }, - }); - expect(api.requests).not.toContainEqual( - expect.objectContaining({ method: "createLoginRole" }), - ); - expect(dbConfig.resolves).toHaveLength(0); - const call = generator.calls[0]; - expect(call?.conn.host).toBe("127.0.0.1"); - expect(call?.conn.user).toBe("branch_user"); - expect(call?.conn.password).toBe("branch-password"); - // Preview-branch generation pins the Supabase CA the same as any other remote target. - expect(call?.conn.sslmode).toBe("require"); - expect(call?.conn.sslrootcertInline).toBe(rootCaBundle()); - }).pipe(Effect.provide(BunServices.layer)), - ); + expect(api.requests).toContainEqual({ + method: "getProject", + input: { ref: VALID_REF }, + }); + expect(api.requests).toContainEqual({ + method: "getABranchConfig", + input: { branch_id_or_ref: VALID_REF }, + }); + expect(api.requests).not.toContainEqual( + expect.objectContaining({ method: "createLoginRole" }), + ); + expect(dbConfig.resolves).toHaveLength(0); + const call = generator.calls[0]; + expect(call?.conn.host).toBe(dbHost); + expect(call?.conn.user).toBe("branch_user"); + expect(call?.conn.password).toBe("branch-password"); + expect(call?.conn.sslmode).toBe(pinned ? "require" : undefined); + expect(call?.conn.sslrootcertInline).toBe(pinned ? rootCaBundle() : undefined); + }).pipe(Effect.provide(BunServices.layer)), + ); + } it.effect("falls back to preview branch config for any project 404 body", () => Effect.gen(function* () { @@ -3140,6 +3147,10 @@ describe("gen types", () => { expect(generator.calls).toHaveLength(2); expect(generator.calls[1]?.conn.host).toBe(poolerHost); expect(generator.calls[1]?.conn.password).toBe("branch-password"); + for (const call of generator.calls) { + expect(call.conn.sslmode).toBe("require"); + expect(call.conn.sslrootcertInline).toBe(rootCaBundle()); + } }).pipe(Effect.provide(BunServices.layer)), ); diff --git a/apps/cli/src/commands/gen/types/types.shared.ts b/apps/cli/src/commands/gen/types/types.shared.ts index f493bb62d2..14621d07d4 100644 --- a/apps/cli/src/commands/gen/types/types.shared.ts +++ b/apps/cli/src/commands/gen/types/types.shared.ts @@ -1,8 +1,5 @@ import { Effect, Schema } from "effect"; import { InvalidGenTypesDurationError } from "./types.errors.ts"; -import caProd2021 from "./templates/prod-ca-2021.ts"; -import caProd2025 from "./templates/prod-ca-2025.ts"; -import caStaging2021 from "./templates/staging-ca-2021.ts"; // Local Docker resource ids are hoisted to `command-internal` so the declarative seam // can derive the same `supabase_db_` name when checking the local stack. @@ -79,7 +76,3 @@ export function parseQueryTimeoutMillis( return totalMillis; }); } - -export function rootCaBundle() { - return `${caStaging2021}${caProd2021}${caProd2025}`; -} diff --git a/apps/cli/src/commands/gen/types/types.unit.test.ts b/apps/cli/src/commands/gen/types/types.unit.test.ts index 4658239bbb..cda6749794 100644 --- a/apps/cli/src/commands/gen/types/types.unit.test.ts +++ b/apps/cli/src/commands/gen/types/types.unit.test.ts @@ -3,7 +3,6 @@ import { Effect, Exit } from "effect"; import { parseSchemaFlags } from "../../../command-internal/schema-flags.ts"; import { defaultSchemas, - rootCaBundle, localDbContainerId, localNetworkId, parseQueryTimeoutMillis, @@ -86,8 +85,4 @@ describe("schema and id helpers", () => { const longId = "a".repeat(60); expect(localDbContainerId(longId)).toBe(`supabase_db_${"a".repeat(40)}`); }); - - it("bundles the staging and production CA certificates", () => { - expect(rootCaBundle().length).toBeGreaterThan(0); - }); }); diff --git a/apps/cli/tests/helpers/live.ts b/apps/cli/tests/helpers/live.ts index a9c4e7ec99..0d9c0e9ce1 100644 --- a/apps/cli/tests/helpers/live.ts +++ b/apps/cli/tests/helpers/live.ts @@ -4,7 +4,7 @@ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http"; import pg from "pg"; import { expect, inject, test as vitestTest } from "vitest"; -import { rootCaBundle } from "../../src/commands/gen/types/types.shared.ts"; +import { rootCaBundle } from "../../src/command-internal/supabase-tls.ts"; import { type CliRunError, requireCliSuccess, diff --git a/apps/cli/tests/helpers/refuse-invalid-db-hosts.ts b/apps/cli/tests/helpers/refuse-invalid-db-hosts.ts index a989c82d89..ee8e1465e0 100644 --- a/apps/cli/tests/helpers/refuse-invalid-db-hosts.ts +++ b/apps/cli/tests/helpers/refuse-invalid-db-hosts.ts @@ -6,11 +6,14 @@ export interface DirectHostToggle { directHostReachable: boolean; } +const FIXTURE_DB_HOST_SUFFIXES = [".invalid", ".supabase.co", ".supabase.red", ".snapcloud.dev"]; + /** - * Wraps `node:net` so a connection to a `db..invalid` fixture host fails at once instead of - * waiting on ambient DNS or TCP timing. Use it from a test file's `vi.mock("node:net", ...)` - * factory; other hosts connect normally. While the toggle passed in is on, the same hosts report a - * successful connect instead, which fakes the TCP reachability probe only (nothing is dialed). + * Wraps `node:net` so a connection to a `db.` fixture host on one of + * `FIXTURE_DB_HOST_SUFFIXES` fails at once instead of waiting on ambient DNS or TCP timing. Use it + * from a test file's `vi.mock("node:net", ...)` factory; other hosts connect normally. While the + * toggle passed in is on, the same hosts report a successful connect instead, which fakes the TCP + * reachability probe only (nothing is dialed). */ export function refuseInvalidDbHosts( net: typeof NodeNet, @@ -20,7 +23,11 @@ export function refuseInvalidDbHosts( ...net, connect: (connectOptions: NodeNet.NetConnectOpts) => { const host = "host" in connectOptions ? connectOptions.host : undefined; - if (typeof host !== "string" || !host.startsWith("db.") || !host.endsWith(".invalid")) { + if ( + typeof host !== "string" || + !host.startsWith("db.") || + !FIXTURE_DB_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix)) + ) { return net.connect(connectOptions); } const socket = new net.Socket();