From d8fb09e59127b6888d1b618cc63f3b90f2b3acd8 Mon Sep 17 00:00:00 2001 From: Anna Baker Date: Tue, 28 Jul 2026 13:06:46 -0400 Subject: [PATCH 1/4] fix(cli): distinguish local vs. remote connection-refused hints (CLI-1995) --- .../legacy/shared/legacy-connect-errors.ts | 27 ++++++++++++++----- .../shared/legacy-connect-errors.unit.test.ts | 9 +++++++ ...y-db-connection.sql-pg.integration.test.ts | 24 +++++++++++++---- .../legacy-db-connection.sql-pg.layer.ts | 2 +- 4 files changed, 49 insertions(+), 13 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-connect-errors.ts b/apps/cli/src/legacy/shared/legacy-connect-errors.ts index 58e6338538..ed98027fcd 100644 --- a/apps/cli/src/legacy/shared/legacy-connect-errors.ts +++ b/apps/cli/src/legacy/shared/legacy-connect-errors.ts @@ -64,6 +64,15 @@ export function legacyIsIPv6ConnectivityError(message: string): boolean { export const LEGACY_SUGGEST_ENV_VAR = "Connect to your database by setting the env var correctly: SUPABASE_DB_PASSWORD"; +/** + * TS-only addition — Go's `SetConnectSuggestion` has no local/remote distinction, + * so a refused `--local` connection (Docker/Postgres not running) got the same + * remote-only "Network Restrictions" dashboard hint as an actual network-restricted + * connection, which is a dead end locally. Shown instead of that hint when + * `ctx.isLocal` is true — see `legacyConnectSuggestion`. + */ +export const LEGACY_SUGGEST_LOCAL_STACK = "Make sure Docker is running, then run: supabase start"; + /** Context the connect-suggestion needs but cannot derive from the error alone. */ export interface LegacyConnectSuggestionContext { /** Active profile's dashboard URL (Go's `CurrentProfile.DashboardURL`). */ @@ -355,17 +364,21 @@ function legacyHasIPv6DialCause(error: unknown, depth = 0): boolean { */ export function legacyConnectSuggestion( error: unknown, - ctx: LegacyConnectSuggestionContext, + ctx: LegacyConnectSuggestionContext & { readonly isLocal: boolean }, ): string | undefined { const text = legacyCollectConnectErrorText(error); - // connect: connection refused / Address not in tenant allow_list → network restrictions. - if ( - text.includes("ECONNREFUSED") || - text.includes("connection refused") || - text.includes("Address not in tenant allow_list") - ) { + // connect: connection refused - "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")) { return `Make sure your local IP is allowed in Network Restrictions and Network Bans.\n${ctx.dashboardUrl}/project/_/database/settings`; } + // connect: connection refused — don't send the user to the + // dashboard's Network Restrictions page for a --local connection. + if (text.includes("ECONNREFUSED") || text.includes("connection refused")) { + return ctx.isLocal + ? LEGACY_SUGGEST_LOCAL_STACK + : `Make sure your local IP is allowed in Network Restrictions and Network Bans.\n${ctx.dashboardUrl}/project/_/database/settings`; + } // SSL connection is required (only under --debug, which disables TLS). if (text.includes("SSL connection is required") && ctx.debug) { return "SSL connection is not supported with --debug flag"; diff --git a/apps/cli/src/legacy/shared/legacy-connect-errors.unit.test.ts b/apps/cli/src/legacy/shared/legacy-connect-errors.unit.test.ts index 41867f4df8..bb473a163f 100644 --- a/apps/cli/src/legacy/shared/legacy-connect-errors.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-connect-errors.unit.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import { LEGACY_SUGGEST_ENV_VAR, + LEGACY_SUGGEST_LOCAL_STACK, legacyConnectFailureMessage, legacyConnectSuggestion, legacyIpv6Suggestion, @@ -277,6 +278,7 @@ describe("legacyConnectSuggestion", () => { dashboardUrl: "https://supabase.com/dashboard", profileName: "supabase", debug: false, + isLocal: false, } as const; // The @effect/sql SqlError wraps the node driver error on `.cause`; a multi-address @@ -293,6 +295,13 @@ describe("legacyConnectSuggestion", () => { ); }); + it("maps a refused local connection to the local stack hint", () => { + const err = sqlError(systemError("connect ECONNREFUSED 127.0.0.1:54322", "ECONNREFUSED")); + expect(legacyConnectSuggestion(err, { ...ctx, isLocal: true })).toBe( + LEGACY_SUGGEST_LOCAL_STACK, + ); + }); + it("maps an AggregateError of refused dials to the network-restrictions hint", () => { const err = sqlError( Object.assign(new AggregateError([], "all attempts failed"), { diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts index c6c29bbc96..4d1cdbc6e1 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts @@ -10,7 +10,7 @@ import * as net from "node:net"; import { describe, expect, it } from "@effect/vitest"; import { Effect } from "effect"; -import { LEGACY_SUGGEST_ENV_VAR } from "./legacy-connect-errors.ts"; +import { LEGACY_SUGGEST_ENV_VAR, LEGACY_SUGGEST_LOCAL_STACK } from "./legacy-connect-errors.ts"; import type { LegacyDbConnectError, LegacyDbExecError } from "./legacy-db-connection.errors.ts"; import { type LegacyPgConnInput, LegacyDbConnection } from "./legacy-db-connection.service.ts"; import { legacyDbConnectionSqlPgLayer } from "./legacy-db-connection.sql-pg.layer.ts"; @@ -26,9 +26,13 @@ const SUGGESTION_CONTEXT = { // host/user/database — never the password). const SENTINEL_PASSWORD = "s3cr3t-pw-do-not-leak"; -/** Connect through the real layer and flip the expected failure into the value. */ +/** + * Connect through the real layer and flip the expected failure into the value. + * `isLocal` defaults to `true`, pass `false` to drive the remote/`--linked` suggestion branch instead. + */ const connectFailure = ( cfg: Partial & { readonly port: number }, + isLocal = true, ): Effect.Effect => Effect.gen(function* () { const conn = yield* LegacyDbConnection; @@ -42,7 +46,7 @@ const connectFailure = ( suggestionContext: SUGGESTION_CONTEXT, ...cfg, }, - { isLocal: true, dnsResolver: "native" }, + { isLocal, dnsResolver: "native" }, ) .pipe( Effect.scoped, @@ -176,11 +180,11 @@ const fakeQueryServer = ( describe("legacyDbConnectionSqlPgLayer connect failures", () => { it.live( - "surfaces host, user, database, and the driver cause when the connection is refused", + "surfaces host, user, database, and the driver cause when a remote (--linked) connection is refused", () => Effect.gen(function* () { const port = yield* Effect.promise(acquireClosedPort); - const error = yield* connectFailure({ port }); + const error = yield* connectFailure({ port }, false); expect(error._tag).toBe("LegacyDbConnectError"); expect(error.message).toBe( "failed to connect to postgres: failed to connect to `host=127.0.0.1 user=postgres database=postgres`: " + @@ -194,6 +198,16 @@ describe("legacyDbConnectionSqlPgLayer connect failures", () => { }), ); + it.live( + "surfaces the local-stack hint when a local connection is refused", + () => + Effect.gen(function* () { + const port = yield* Effect.promise(acquireClosedPort); + const error = yield* connectFailure({ port }); + expect(error.suggestion).toBe(LEGACY_SUGGEST_LOCAL_STACK); + }), + ); + it.live( "reproduces pgconn's server-error rendering for an auth failure and suggests SUPABASE_DB_PASSWORD", () => diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts index e779973395..a05da9fd73 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts @@ -661,7 +661,7 @@ const connect = ( const suggestion = cfg.suggestionContext === undefined ? undefined - : legacyConnectSuggestion(error, cfg.suggestionContext); + : legacyConnectSuggestion(error, { ...cfg.suggestionContext, isLocal }); return new LegacyDbConnectError({ message: `failed to connect to postgres: ${legacyConnectFailureMessage(cfg, error)}`, ...(suggestion === undefined ? {} : { suggestion }), From 9039d2e099aab33d935d56753a622c4c48826a9f Mon Sep 17 00:00:00 2001 From: Anna Baker Date: Tue, 28 Jul 2026 13:47:42 -0400 Subject: [PATCH 2/4] style(cli): apply oxfmt formatting to connect-refused integration test --- ...legacy-db-connection.sql-pg.integration.test.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts index 4d1cdbc6e1..0af60b46c9 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts @@ -198,14 +198,12 @@ describe("legacyDbConnectionSqlPgLayer connect failures", () => { }), ); - it.live( - "surfaces the local-stack hint when a local connection is refused", - () => - Effect.gen(function* () { - const port = yield* Effect.promise(acquireClosedPort); - const error = yield* connectFailure({ port }); - expect(error.suggestion).toBe(LEGACY_SUGGEST_LOCAL_STACK); - }), + it.live("surfaces the local-stack hint when a local connection is refused", () => + Effect.gen(function* () { + const port = yield* Effect.promise(acquireClosedPort); + const error = yield* connectFailure({ port }); + expect(error.suggestion).toBe(LEGACY_SUGGEST_LOCAL_STACK); + }), ); it.live( From 0643fc930ae81065ed00893ad02b026b49e6e605 Mon Sep 17 00:00:00 2001 From: Anna Baker Date: Tue, 28 Jul 2026 14:59:58 -0400 Subject: [PATCH 3/4] refactor(cli): consolidate duplicated network-restrictions hint --- .../src/legacy/shared/legacy-connect-errors.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-connect-errors.ts b/apps/cli/src/legacy/shared/legacy-connect-errors.ts index ed98027fcd..7dc4313aaa 100644 --- a/apps/cli/src/legacy/shared/legacy-connect-errors.ts +++ b/apps/cli/src/legacy/shared/legacy-connect-errors.ts @@ -73,6 +73,17 @@ export const LEGACY_SUGGEST_ENV_VAR = */ export const LEGACY_SUGGEST_LOCAL_STACK = "Make sure Docker is running, then run: supabase start"; +/** + * Go's `SetConnectSuggestion` remote-only "Network Restrictions" hint + * (`internal/utils/connect.go:319-321`), shown for a connection refused/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 `legacyConnectSuggestion`. + */ +function legacySuggestNetworkRestrictions(dashboardUrl: string): string { + return `Make sure your local IP is allowed in Network Restrictions and Network Bans.\n${dashboardUrl}/project/_/database/settings`; +} + /** Context the connect-suggestion needs but cannot derive from the error alone. */ export interface LegacyConnectSuggestionContext { /** Active profile's dashboard URL (Go's `CurrentProfile.DashboardURL`). */ @@ -370,14 +381,14 @@ export function legacyConnectSuggestion( // connect: connection refused - "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")) { - return `Make sure your local IP is allowed in Network Restrictions and Network Bans.\n${ctx.dashboardUrl}/project/_/database/settings`; + return legacySuggestNetworkRestrictions(ctx.dashboardUrl); } // connect: connection refused — don't send the user to the // dashboard's Network Restrictions page for a --local connection. if (text.includes("ECONNREFUSED") || text.includes("connection refused")) { return ctx.isLocal ? LEGACY_SUGGEST_LOCAL_STACK - : `Make sure your local IP is allowed in Network Restrictions and Network Bans.\n${ctx.dashboardUrl}/project/_/database/settings`; + : legacySuggestNetworkRestrictions(ctx.dashboardUrl); } // SSL connection is required (only under --debug, which disables TLS). if (text.includes("SSL connection is required") && ctx.debug) { From 60aab2359a94206c1c5d1c2b90b7a45c0a388cf1 Mon Sep 17 00:00:00 2001 From: Anna Baker Date: Tue, 28 Jul 2026 15:20:59 -0400 Subject: [PATCH 4/4] test(cli): normalize known --local stderr divergence in parity tests (CLI-1995) --- apps/cli-e2e/src/tests/database-core.e2e.test.ts | 12 +++++++++++- apps/cli-e2e/src/tests/migrations.e2e.test.ts | 2 ++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/cli-e2e/src/tests/database-core.e2e.test.ts b/apps/cli-e2e/src/tests/database-core.e2e.test.ts index 4ba33c1f79..a0e5c21509 100644 --- a/apps/cli-e2e/src/tests/database-core.e2e.test.ts +++ b/apps/cli-e2e/src/tests/database-core.e2e.test.ts @@ -252,7 +252,17 @@ describe("db push", () => { expect(result.stderr).toContain("connect"); }); - testParity(["db", "push", "--local"]); + // Known parity divergence: Go never emits the Docker-start hint for --local (CLI-1995). + testParity(["db", "push", "--local"], { + normalize: { + stderr: { + stripPatterns: [ + /\nMake sure your local IP is allowed in Network Restrictions and Network Bans\.\n[^\n]*/g, // Expected from Go + /\nMake sure Docker is running, then run: supabase start/g, // Expected from TS + ], + }, + }, + }); }); describe("db push:linked", () => { diff --git a/apps/cli-e2e/src/tests/migrations.e2e.test.ts b/apps/cli-e2e/src/tests/migrations.e2e.test.ts index 45b02ef3bc..12069b0231 100644 --- a/apps/cli-e2e/src/tests/migrations.e2e.test.ts +++ b/apps/cli-e2e/src/tests/migrations.e2e.test.ts @@ -24,6 +24,8 @@ const CONNECT_REFUSED_STDERR_STRIP: readonly RegExp[] = [ /(?<=failed to connect to postgres:).*/g, // Go's SetConnectSuggestion: Network-Restrictions hint + dashboard URL line. /\nMake sure your local IP is allowed in Network Restrictions and Network Bans\.\n[^\n]*/g, + // TS's local-only Docker hint (CLI-1995); Go never emits this for --local. + /\nMake sure Docker is running, then run: supabase start/g, // TS's generic --debug suggestion. /\nTry rerunning the command with --debug to troubleshoot the error\./g, ];