From d51830d77cea2a33ca5794abc140346d9a75ca7b Mon Sep 17 00:00:00 2001 From: 0xDevNinja Date: Mon, 18 May 2026 17:46:01 +0530 Subject: [PATCH] fix(supabase-provision): rewrite transaction/6543 -> session/5432 for new projects - Single-object pooler API responses default to transaction-mode at 6543, but the shared pooler tenant on new projects only listens on session/5432 - Add a `pool_mode == transaction && db_port == 6543` rewrite + stderr note - Escape hatch via `GSTACK_SUPABASE_TRUST_API_PORT=1` for forward-compat - 5 new tests covering rewrite, no-op shapes, env opt-out, array path Fixes #1301. --- bin/gstack-gbrain-supabase-provision | 18 +++++- test/gbrain-supabase-provision.test.ts | 83 ++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/bin/gstack-gbrain-supabase-provision b/bin/gstack-gbrain-supabase-provision index 3f3128e9b3..2bec5384ca 100755 --- a/bin/gstack-gbrain-supabase-provision +++ b/bin/gstack-gbrain-supabase-provision @@ -339,7 +339,7 @@ cmd_pooler_url() { # Prefer the singular Session Pooler config when Supabase returns an # array (response shape can vary by project state). Fall back to the # first PRIMARY entry if no "session" pool_mode is present. - local db_user db_host db_port db_name + local db_user db_host db_port db_name pool_mode local first_or_session if printf '%s' "$resp" | jq -e 'type == "array"' >/dev/null 2>&1; then first_or_session=$(printf '%s' "$resp" | jq '[.[] | select(.pool_mode == "session")][0] // .[0]') @@ -351,11 +351,27 @@ cmd_pooler_url() { db_host=$(printf '%s' "$first_or_session" | jq -r '.db_host // empty') db_port=$(printf '%s' "$first_or_session" | jq -r '.db_port // empty') db_name=$(printf '%s' "$first_or_session" | jq -r '.db_name // empty') + pool_mode=$(printf '%s' "$first_or_session" | jq -r '.pool_mode // empty') if [ -z "$db_user" ] || [ -z "$db_host" ] || [ -z "$db_port" ] || [ -z "$db_name" ]; then die "pooler-url: missing pooler config fields (db_user/db_host/db_port/db_name); re-poll or check project state" fi + # Issue #1301: New Supabase projects' Management API returns a single + # transaction-mode pooler at port 6543, but the shared pooler tenant + # for fresh projects only listens on the session port 5432. Trusting + # db_port verbatim makes `gbrain init` hang to TCP timeout (transaction + # port unreachable) before falling into "tenant not found"-style errors + # that look like auth bugs. Rewrite transaction/6543 -> session/5432. + # Override with GSTACK_SUPABASE_TRUST_API_PORT=1 if a future API version + # starts returning a working transaction port and this rewrite is wrong. + if [ "${GSTACK_SUPABASE_TRUST_API_PORT:-0}" != "1" ] \ + && [ "$pool_mode" = "transaction" ] && [ "$db_port" = "6543" ]; then + echo "pooler-url: API returned transaction pooler (port 6543); shared pooler for new projects listens on session port 5432 — rewriting (set GSTACK_SUPABASE_TRUST_API_PORT=1 to disable)" >&2 + db_port=5432 + pool_mode="session" + fi + local url="postgresql://${db_user}:${DB_PASS}@${db_host}:${db_port}/${db_name}" if $json_mode; then diff --git a/test/gbrain-supabase-provision.test.ts b/test/gbrain-supabase-provision.test.ts index 917ebde55e..4e3138c04e 100644 --- a/test/gbrain-supabase-provision.test.ts +++ b/test/gbrain-supabase-provision.test.ts @@ -410,6 +410,89 @@ describe('pooler-url', () => { expect(r.status).toBe(2); expect(r.stderr).toContain('DB_PASS env var is required'); }); + + // --- Issue #1301: New Supabase projects' API returns transaction/6543 but + // the shared pooler tenant only listens on session/5432. Rewrite that + // single combination, leave every other shape alone. --- + + test('rewrites single transaction/6543 response to session/5432 (issue #1301)', async () => { + mock = startMock({ + [`GET /v1/projects/${REF}/config/database/pooler`]: () => + jsonResp({ ...POOLER_OK, pool_mode: 'transaction', db_port: 6543 }), + }); + const r = await runBin(['pooler-url', REF, '--json'], { + SUPABASE_ACCESS_TOKEN: 'sbp_test', + DB_PASS: 'pw', + SUPABASE_API_BASE: mock.url, + }); + expect(r.status).toBe(0); + expect(JSON.parse(r.stdout).pooler_url).toContain(':5432/postgres'); + expect(r.stderr).toContain('rewriting'); + }); + + test('leaves session/6543 alone (some regions genuinely serve session on 6543)', async () => { + mock = startMock({ + [`GET /v1/projects/${REF}/config/database/pooler`]: () => + jsonResp({ ...POOLER_OK, pool_mode: 'session', db_port: 6543 }), + }); + const r = await runBin(['pooler-url', REF, '--json'], { + SUPABASE_ACCESS_TOKEN: 'sbp_test', + DB_PASS: 'pw', + SUPABASE_API_BASE: mock.url, + }); + expect(r.status).toBe(0); + expect(JSON.parse(r.stdout).pooler_url).toContain(':6543/postgres'); + expect(r.stderr).not.toContain('rewriting'); + }); + + test('leaves transaction/5432 alone (only the 6543 case is the known footgun)', async () => { + mock = startMock({ + [`GET /v1/projects/${REF}/config/database/pooler`]: () => + jsonResp({ ...POOLER_OK, pool_mode: 'transaction', db_port: 5432 }), + }); + const r = await runBin(['pooler-url', REF, '--json'], { + SUPABASE_ACCESS_TOKEN: 'sbp_test', + DB_PASS: 'pw', + SUPABASE_API_BASE: mock.url, + }); + expect(r.status).toBe(0); + expect(JSON.parse(r.stdout).pooler_url).toContain(':5432/postgres'); + expect(r.stderr).not.toContain('rewriting'); + }); + + test('GSTACK_SUPABASE_TRUST_API_PORT=1 disables the rewrite', async () => { + mock = startMock({ + [`GET /v1/projects/${REF}/config/database/pooler`]: () => + jsonResp({ ...POOLER_OK, pool_mode: 'transaction', db_port: 6543 }), + }); + const r = await runBin(['pooler-url', REF, '--json'], { + SUPABASE_ACCESS_TOKEN: 'sbp_test', + DB_PASS: 'pw', + SUPABASE_API_BASE: mock.url, + GSTACK_SUPABASE_TRUST_API_PORT: '1', + }); + expect(r.status).toBe(0); + expect(JSON.parse(r.stdout).pooler_url).toContain(':6543/postgres'); + expect(r.stderr).not.toContain('rewriting'); + }); + + test('array response with explicit session entry on 5432 is unaffected (existing behavior)', async () => { + mock = startMock({ + [`GET /v1/projects/${REF}/config/database/pooler`]: () => + jsonResp([ + { ...POOLER_OK, pool_mode: 'transaction', db_port: 6543 }, + { ...POOLER_OK, pool_mode: 'session', db_port: 5432 }, + ]), + }); + const r = await runBin(['pooler-url', REF, '--json'], { + SUPABASE_ACCESS_TOKEN: 'sbp_test', + DB_PASS: 'pw', + SUPABASE_API_BASE: mock.url, + }); + expect(r.status).toBe(0); + expect(JSON.parse(r.stdout).pooler_url).toContain(':5432/postgres'); + expect(r.stderr).not.toContain('rewriting'); + }); }); describe('list-orphans (D20)', () => {