mirror of
https://github.com/supabase/server.git
synced 2026-09-14 15:28:52 +08:00
docs/postgres-pooling-troubleshooting
3 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
77656bae3b |
feat(middleware): ship withPostgresClient and withPostgresAdminClient (#115)
* refactor(middleware): rename withPostgres to withPostgresClient and harden it
Renames the export to sit alongside withSupabaseClient /
withSupabaseAdminClient, and extracts the pool into a shared core module so
the service-role companion can reuse it. Safe to rename now: the old name
exists only on 1.5.0-rc.* / beta, never on a stable release.
Three correctness fixes alongside it:
- The pool cache was keyed on nothing, so a second connectionString in the
same process silently queried the first database. Now keyed per string.
- The missing-connection-string 500 returned { error }, not the package's
standard { message, code }.
- An unguarded rollback in the catch could replace the caller's real error
with a connection error.
Adds unit coverage for each, plus a type-level check that composing without
an upstream jwtClaims stays a compile-time error.
* feat(middleware): add withPostgresAdminClient
Contributes ctx.postgresAdmin — a pg client that bypasses RLS, exported from
./middleware/postgres-admin. Queries run as-is under the connection-string
role: no claim injection, no role switch, no wrapping transaction.
Declares no upstream prerequisite, so unlike withPostgresClient it composes
under auth: 'secret' and auth: 'none'. Shares the pool cache with the scoped
half — same connection string, one pool. That is safe because everything the
scoped half sets is transaction-local, so a connection always returns clean.
Kept as a second middleware rather than a property on ctx.postgres:
defineMiddleware contributes exactly one ctx key, and the split keeps the RLS
bypass visible at the composition site.
* test(e2e): cover both postgres middleware against a real database
Adds /my-notes-pg and /all-notes-pg to the core Node app and the Deno edge
function, both running the identical unfiltered SELECT — one through
ctx.postgres, one through ctx.postgresAdmin. user2 sees none of user1's rows
through the scoped client and sees them through the admin one, which proves
claim injection, the role drop, and the bypass in a single contrast.
The edge function passes connectionString explicitly from E2E_DB_URL: the CLI
injects a SUPABASE_DB_URL addressing the database by container name, and
Deno's DNS resolver rejects the underscores in it. The Node app still covers
the SUPABASE_DB_URL default path.
* docs: document the postgres middleware pair
Adds docs/postgres.md covering both halves, the SQL each query runs, the two
composition paths, table grants, the RLS bypass and why it is a separate
middleware, and guidance to write policies with the auth.* helpers rather
than reading request.jwt.claim.* directly.
Wires both subpaths into typedoc entryPoints — without which neither export
reached api-docs/ — and adds README sections, Exports and env-var rows, and
api-reference entries.
* fix(middleware): discard the connection when a rollback fails
pg-pool only removes a client when release() is given a truthy argument, so
the previous release() returned a connection whose transaction could not be
unwound straight back to the pool — potentially still inside the caller's
transaction with their role set.
That was survivable while the pool served one middleware. It is not now that
withPostgresAdminClient shares it: that middleware begins no transaction and
sets up no session state, so it would silently inherit the leftover role on
the next checkout.
* fix(middleware): refuse unsupported roles instead of downgrading to anon
withPostgresClient silently mapped every role that was not 'authenticated'
to 'anon'. For a forged service_role that was the intent, but Supabase also
supports custom roles via the role claim, and RLS applies to those normally —
so a legitimate `role: manager` token was being answered with zero rows and
no indication that the role was the reason.
Now only 'authenticated' and 'anon' are assumed, and anything else
short-circuits with a 500 and code UNSUPPORTED_ROLE before the handler runs
or a connection is checked out. service_role gets a message pointing at
withPostgresAdminClient; other roles are named in the error.
Custom roles remain unsupported — the reason is that PostgREST connects as the
unprivileged authenticator, where `grant <role> to authenticator` is itself the
authorization, while we connect as postgres and have no such boundary to lean
on. Documented, and tracked separately.
Also hoists the per-request claims serialization out of the per-query path.
* docs: list every subpath in the README exports table
The table covered 8 of 13 entry points. Adding the postgres pair made the
omission look deliberate rather than incidental — a reader could reasonably
conclude withClaims has no subpath, which matters because it is the documented
prerequisite for composing withPostgresClient standalone.
* feat(middleware): make query a tagged template, add queryRaw and ident
`query` now takes a tagged template only, so every interpolation becomes a
bind parameter and can never alter the shape of the statement. `queryRaw(text,
params)` keeps the string form — it is fully safe with params, and it is the
only path that works for query builders and codegen emitting `{ sql,
parameters }`, or for SQL that has to interpolate an identifier.
Passing a plain string to `query` throws, naming `queryRaw`. The two calls
differ only in their brackets, so refusing beats reinterpreting: the string's
first character would otherwise be read as the whole template and a
one-character query would be sent.
`ident()` quotes identifiers, which can never be bind parameters — `select $1
from notes` selects a literal, not a column. It is implemented directly rather
than wrapping `pg.escapeIdentifier`: that top-level export only exists from
pg 8.11, while the peer range is `^8.0.0`, so a wrapper would be a runtime
TypeError on 8.0-8.5. It also rejects empty names and NUL bytes, which pg
passes straight through to a confusing server-side error.
`set local role` now quotes the role via `ident()`. The role is already
constrained to the SUPPORTED_ROLES allowlist, so this changes nothing today —
it keeps the interpolation safe if that list widens to the custom roles the
docstring promises.
Follows the prior art: Prisma shipped the dual overload and reversed it,
Slonik refuses plain strings outright, and postgres.js requires the tag with
`sql.unsafe` as the named escape hatch.
The e2e edge function built its query by interpolating a column list. As a
`query` tag that would have compiled to `select $1 from notes` and returned
the literal string for every row — valid SQL, wrong rows, no error. It now
uses `queryRaw`, with a comment explaining why.
* fix: refuse non-string role claims instead of downgrading to anon
* chore: keep prettier off the release-please changelog
---------
Co-authored-by: Katerina Skroumpelou <sk.katherine@gmail.com>
|
||
|
|
c8fd9de39e |
test(e2e): cover edge functions on the real Deno edge runtime (#111)
* test(e2e): cover edge functions on the real Deno edge runtime * fix(e2e): work around CLI import-scanner crash on vendored JSDoc examples |
||
|
|
09a67506db |
test: add E2E tests for all four adapters against a local Supabase stack (#99)
* test: add E2E tests for all four adapters against a local Supabase stack Adds an e2e vitest project (SDK-1143) covering what the mocked unit tests cannot: real GoTrue-issued JWTs verified against the live JWKS endpoint, real Supabase client operations via supabaseAdmin, resolveEnv() reading process.env, and imports from dist/ so packaging regressions fail here. One scenario set (auth + data access + isolation) runs over real HTTP against minimal Hono, H3, Elysia, and NestJS apps. Elysia runs behind a node:http server (srvx) so CI needs no Bun. A separate E2E workflow starts the local stack with the Supabase CLI, builds, and runs the suite. * test: grant explicit table privileges in the e2e notes migration Newer Supabase stacks make new tables private by default — the API roles (anon/authenticated/service_role) no longer receive DML grants on table creation. CI installs the latest CLI, so all supabaseAdmin queries failed with "permission denied for table notes" while JWT scenarios passed. Reproduced locally on CLI 2.109.1; explicit grants fix it on both old and new stacks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: align NestJS missing-body handling and cover it in the scenarios The NestJS app silently inserted an empty note when the body was missing, while the other three adapters returned 400 — and no scenario exercised those 400 branches. NestJS now throws BadRequestException like the rest, and a shared missing-body scenario keeps all four aligned. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: cover forged JWTs, the RLS-scoped client, and optional-auth rejection Closes the three gaps from PR review: the garbage-token scenario failed at header decode without ever reaching signature verification, ctx.supabase (the RLS-scoped client) was never exercised, and nothing pinned that a present-but-invalid token on an optional route is rejected rather than downgraded to anonymous. - Mint a well-formed JWT with the live JWKS kid but a wrong signing key in global setup; every adapter must 401 it — proving signature verification end-to-end, not just structure checks. - Add GET /my-notes reading through ctx.supabase with no WHERE clause, backed by a user_id = auth.uid() select policy — proving the caller's token reaches PostgREST and Postgres RLS scopes the rows. - Assert GET /me-optional with an invalid token → 401. 10 → 14 scenarios per adapter (56 tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: add core-wrapper app, admin-bypass proof, and sign-in readability Addresses PR review comments: - New fifth app on the core withSupabase(config, handler) fetch wrapper — the exact programming model Supabase Edge Functions deploy — running the full scenario set behind node:http. A real Deno runtime e2e via `supabase functions serve` is tracked in SDK-1280. - New GET /all-notes route (admin client, no filter) + scenario: user2's request sees user1's rows through supabaseAdmin, directly proving the admin client is not scoped to the caller's identity. - Replace the `;({ data, error } = ...)` destructuring-reassignment in the sign-in helper with a plain result variable. 15 scenarios × 5 apps (75 tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |