Skip to content

Commit 77656ba

Browse files
tomaspozomandarini
andauthored
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>
1 parent 3908590 commit 77656ba

27 files changed

Lines changed: 1624 additions & 95 deletions

.prettierignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
node_modules
22
dist
33
pnpm-lock.yaml
4+
# generated by release-please; prettier restyles its bullets, causing churn
5+
CHANGELOG.md

README.md

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,45 @@ export default {
438438
}
439439
```
440440
441+
## Postgres (RLS-scoped queries)
442+
443+
When PostgREST isn't the right tool — joins, CTEs, window functions — `withPostgresClient` puts a direct Postgres connection on `ctx.postgres`, scoped to the caller by RLS:
444+
445+
```ts
446+
import { withSupabase } from '@supabase/server'
447+
import { withPostgresClient } from '@supabase/server/middleware/postgres'
448+
449+
export default {
450+
fetch: withSupabase(
451+
{ auth: 'user', middleware: [withPostgresClient()] },
452+
async (_req, ctx) => {
453+
// No WHERE clause — RLS scopes the rows to the caller.
454+
const notes = await ctx.postgres.query`select id, body from notes`
455+
return Response.json(notes)
456+
},
457+
),
458+
}
459+
```
460+
461+
Each query runs in its own transaction that injects the caller's claims and drops to their role, exactly like PostgREST — so `auth.uid()` resolves and your policies enforce. Only `authenticated` and `anon` are assumed; a token naming any other role (including `service_role`, and custom roles) is refused with `code: 'UNSUPPORTED_ROLE'` rather than silently downgraded to `anon`.
462+
463+
When a handler legitimately needs to cross user boundaries, `withPostgresAdminClient` is the explicit opt-out — it contributes `ctx.postgresAdmin`, which bypasses RLS and needs no caller identity, so it works under `auth: 'secret'` and `auth: 'none'` too:
464+
465+
```ts
466+
import { withPostgresAdminClient } from '@supabase/server/middleware/postgres-admin'
467+
468+
withSupabase(
469+
{ auth: 'secret', middleware: [withPostgresAdminClient()] },
470+
handler,
471+
)
472+
```
473+
474+
The pair mirrors `ctx.supabase` / `ctx.supabaseAdmin`, and they share one connection pool. Keeping them as two middleware is deliberate: bypassing RLS stays visible at the composition site, so you can grep for every handler that can do it.
475+
476+
Needs `pg` installed (optional peer dependency) and a raw TCP socket: Node, Deno, Bun, and the Supabase Edge runtime — **not** Workers-style isolates. Reads `SUPABASE_DB_URL` by default. Remember that `authenticated` also needs table grants, not just policies.
477+
478+
See [`docs/postgres.md`](docs/postgres.md) for standalone composition with `withClaims`, the grants requirement, and current limits.
479+
441480
## Environment Variables
442481
443482
Automatically available in Supabase Edge Functions:
@@ -456,6 +495,7 @@ Also supported (for local dev, self-hosted, or other runtimes):
456495
| `SUPABASE_PUBLISHABLE_KEY` | `sb_publishable_...` | Single publishable key |
457496
| `SUPABASE_SECRET_KEY` | `sb_secret_...` | Single secret key |
458497
| `SUPABASE_JWKS_URL` | `https://...` | Remote JWKS endpoint (used when `SUPABASE_JWKS` is unset) |
498+
| `SUPABASE_DB_URL` | `postgresql://...` | Postgres connection string, read by `withPostgresClient` |
459499
460500
When both singular and plural forms are set, plural takes priority.
461501
@@ -481,14 +521,21 @@ No. `@supabase/ssr` handles cookie-based session management for frameworks like
481521
482522
## Exports
483523
484-
| Export | What's in it |
485-
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
486-
| `@supabase/server` | `withSupabase`, `createSupabaseContext` |
487-
| `@supabase/server/core` | `verifyAuth`, `verifyCredentials`, `extractCredentials`, `createContextClient`, `createAdminClient`, `resolveEnv` |
488-
| `@supabase/server/adapters/hono` | `withSupabase` (Hono middleware) |
489-
| `@supabase/server/adapters/h3` | `withSupabase` (H3 / Nuxt middleware) |
490-
| `@supabase/server/adapters/elysia` | `withSupabase` (Elysia plugin) |
491-
| `@supabase/server/adapters/nestjs` | `withSupabase` (NestJS guard), `SupabaseCtx` (param decorator) |
524+
| Export | What's in it |
525+
| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
526+
| `@supabase/server` | `withSupabase`, `createSupabaseContext` |
527+
| `@supabase/server/core` | `verifyAuth`, `verifyCredentials`, `extractCredentials`, `createContextClient`, `createAdminClient`, `resolveEnv` |
528+
| `@supabase/server/adapters/hono` | `withSupabase` (Hono middleware) |
529+
| `@supabase/server/adapters/h3` | `withSupabase` (H3 / Nuxt middleware) |
530+
| `@supabase/server/adapters/elysia` | `withSupabase` (Elysia plugin) |
531+
| `@supabase/server/adapters/nestjs` | `withSupabase` (NestJS guard), `SupabaseCtx` (param decorator) |
532+
| `@supabase/server/middleware/client` | `withSupabaseClient` (RLS-scoped `ctx.supabase` client) |
533+
| `@supabase/server/middleware/admin-client` | `withSupabaseAdminClient` (`ctx.supabaseAdmin`, bypasses RLS) |
534+
| `@supabase/server/middleware/claims` | `withClaims` (JWKS-verified `ctx.jwtClaims`) |
535+
| `@supabase/server/middleware/postgres` | `withPostgresClient` (RLS-scoped `ctx.postgres` client) |
536+
| `@supabase/server/middleware/postgres-admin` | `withPostgresAdminClient` (`ctx.postgresAdmin`, bypasses RLS) |
537+
| `@supabase/server/oauth-protected-resource` | `withOAuthProtectedResource`, `resourceMetadataResponse`, `unauthorizedResponse` |
538+
| `@supabase/server/peer/supabase-js` | Re-exported `supabase-js` types (`SupabaseClient`, `PostgrestError`, …) |
492539
493540
## Documentation
494541
@@ -505,6 +552,7 @@ No. `@supabase/ssr` handles cookie-based session management for frameworks like
505552
| How do environment variables work across runtimes? | [`docs/environment-variables.md`](docs/environment-variables.md) |
506553
| How do I handle errors? What codes exist? | [`docs/error-handling.md`](docs/error-handling.md) |
507554
| How do I get typed database queries? | [`docs/typescript-generics.md`](docs/typescript-generics.md) |
555+
| How do I run raw SQL scoped to the caller by RLS? | [`docs/postgres.md`](docs/postgres.md) |
508556
| How do I use this with `@supabase/ssr` (Next.js, SvelteKit, Remix)? | [`docs/ssr-frameworks.md`](docs/ssr-frameworks.md) |
509557
| What's the complete API surface? | [`docs/api-reference.md`](docs/api-reference.md) |
510558

docs/api-reference.md

Lines changed: 141 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,135 @@ Defaults to `auth: 'user'` when config is omitted.
166166

167167
---
168168

169+
## @supabase/server/middleware/postgres
170+
171+
### withPostgresClient
172+
173+
```ts
174+
const withPostgresClient: Middleware<
175+
'postgres',
176+
WithPostgresClientConfig | void,
177+
{ jwtClaims: RequestClaims | null },
178+
PostgresApi
179+
>
180+
```
181+
182+
Contributes `ctx.postgres`a `pg` client scoped to the caller by RLS. Each query runs in its own transaction that sets `request.jwt.claims` and drops to the caller's role before the statement, so `auth.uid()` resolves and policies enforce.
183+
184+
Only `authenticated` and `anon` are assumed. A verified token naming any other role`service_role` or a custom roleshort-circuits with a 500 and `{ message, code: 'UNSUPPORTED_ROLE' }` naming the role, rather than being downgraded to `anon`. A missing or absent `role` claim is `anon`.
185+
186+
Requires `ctx.jwtClaims` upstreamsupplied by `withSupabase` or by `withClaims` in a standalone `pipeline`. Composing it without one is a compile-time error.
187+
188+
Short-circuits with a 500 and `{ message, code: 'ENV_ERROR' }` when no connection string is available.
189+
190+
Needs raw TCP: Node, Deno, Bun, and the Supabase Edge runtime, not Workers-style isolates. `pg` is an optional peer dependency.
191+
192+
See [`docs/postgres.md`](postgres.md).
193+
194+
### PostgresApi
195+
196+
```ts
197+
interface PostgresApi {
198+
query<T = Record<string, unknown>>(
199+
strings: TemplateStringsArray,
200+
...values: unknown[]
201+
): Promise<T[]>
202+
203+
queryRaw<T = Record<string, unknown>>(
204+
text: string,
205+
params?: unknown[],
206+
): Promise<T[]>
207+
}
208+
```
209+
210+
The value at `ctx.postgres`. Both methods return the result rows directly (not a `pg` `Result`).
211+
212+
`query` is a **tagged template**, so every interpolation becomes a bind parameter and can never alter the statement:
213+
214+
```ts
215+
const rows = await ctx.postgres
216+
.query`select id, body from notes where id = ${id}`
217+
// -> select id, body from notes where id = $1 with values [id]
218+
```
219+
220+
Tagged templates cannot carry type arguments, so annotate the binding instead of writing `query<NoteRow>`:
221+
222+
```ts
223+
const rows: NoteRow[] = await ctx.postgres.query`select id, body from notes`
224+
```
225+
226+
Passing a plain string to `query` throwsthe two calls differ only in their brackets, so it refuses rather than silently reinterpreting.
227+
228+
`queryRaw` takes SQL text plus `params`, for text that cannot be a literal: a query builder emitting `{ sql, parameters }`, or SQL that must interpolate an identifier. Identifiers can never be bind parameters, so check them against a set you control and quote them with `ident`:
229+
230+
```ts
231+
import { ident } from '@supabase/server/middleware/postgres'
232+
233+
const SORTABLE = new Set(['created_at', 'title'])
234+
if (!SORTABLE.has(column)) throw new Error('unsupported sort column')
235+
const rows = await ctx.postgres.queryRaw(
236+
`select id, title from posts order by ${ident(column)} desc`,
237+
)
238+
```
239+
240+
`ident` quotes and escapes, but does not authorizeit stops injection, not a caller reading a column they should not see. The allowlist is what does that.
241+
242+
### WithPostgresClientConfig
243+
244+
```ts
245+
interface WithPostgresClientConfig {
246+
connectionString?: string
247+
}
248+
```
249+
250+
Defaults to the `SUPABASE_DB_URL` environment variable. Pools are created lazily, one per connection string per process.
251+
252+
### RequestClaims
253+
254+
```ts
255+
interface RequestClaims {
256+
role?: string
257+
[key: string]: unknown
258+
}
259+
```
260+
261+
The minimal claims shape `withPostgresClient` requires upstream at `ctx.jwtClaims`. Satisfied by `withSupabase`'s JWKS-verified claims and by `withClaims`. Only `role` is read; the whole object is serialized into `request.jwt.claims`.
262+
263+
---
264+
265+
## @supabase/server/middleware/postgres-admin
266+
267+
### withPostgresAdminClient
268+
269+
```ts
270+
const withPostgresAdminClient: Middleware<
271+
'postgresAdmin',
272+
WithPostgresAdminClientConfig | void,
273+
Record<never, never>,
274+
PostgresApi
275+
>
276+
```
277+
278+
Contributes `ctx.postgresAdmin`a `pg` client that **bypasses RLS**. Queries run as-is, as the role in the connection string: no claim injection, no role switching, no wrapping transaction.
279+
280+
Declares no upstream prerequisite, so it composes in any auth mode including `'secret'` and `'none'`. Shares the pool cache with `withPostgresClient`same connection string, one pool.
281+
282+
Short-circuits with a 500 and `{ message, code: 'ENV_ERROR' }` when no connection string is available.
283+
284+
Authorization is the caller's responsibility: RLS is not consulted, so per-user scoping must be an explicit `where` clause.
285+
286+
### WithPostgresAdminClientConfig
287+
288+
```ts
289+
interface WithPostgresAdminClientConfig {
290+
connectionString?: string
291+
}
292+
```
293+
294+
Defaults to the `SUPABASE_DB_URL` environment variable.
295+
296+
---
297+
169298
## Types
170299

171300
### AuthMode
@@ -353,17 +482,18 @@ class AuthError extends Error {
353482

354483
## Error Code Constants
355484

356-
| Constant | Value | Class | Meaning |
357-
| ----------------------------------- | ----------------------------------- | ----------- | ------------------------------------------------- |
358-
| `EnvGenericError` | `'ENV_ERROR'` | `EnvError` | Generic environment error |
359-
| `MissingSupabaseURLError` | `'MISSING_SUPABASE_URL'` | `EnvError` | `SUPABASE_URL` not set |
360-
| `MissingPublishableKeyError` | `'MISSING_PUBLISHABLE_KEY'` | `EnvError` | Named publishable key not found |
361-
| `MissingDefaultPublishableKeyError` | `'MISSING_DEFAULT_PUBLISHABLE_KEY'` | `EnvError` | No default publishable key |
362-
| `MissingSecretKeyError` | `'MISSING_SECRET_KEY'` | `EnvError` | Named secret key not found |
363-
| `MissingDefaultSecretKeyError` | `'MISSING_DEFAULT_SECRET_KEY'` | `EnvError` | No default secret key |
364-
| `AuthGenericError` | `'AUTH_ERROR'` | `AuthError` | Generic auth error |
365-
| `InvalidCredentialsError` | `'INVALID_CREDENTIALS'` | `AuthError` | No credential matched, or JWT failed verification |
366-
| `CreateSupabaseClientError` | `'CREATE_SUPABASE_CLIENT_ERROR'` | `AuthError` | Client creation failed after auth |
485+
| Constant | Value | Class | Meaning |
486+
| ----------------------------------- | ----------------------------------- | ----------- | -------------------------------------------------------------- |
487+
| `EnvGenericError` | `'ENV_ERROR'` | `EnvError` | Generic environment error |
488+
| `MissingSupabaseURLError` | `'MISSING_SUPABASE_URL'` | `EnvError` | `SUPABASE_URL` not set |
489+
| `MissingPublishableKeyError` | `'MISSING_PUBLISHABLE_KEY'` | `EnvError` | Named publishable key not found |
490+
| `MissingDefaultPublishableKeyError` | `'MISSING_DEFAULT_PUBLISHABLE_KEY'` | `EnvError` | No default publishable key |
491+
| `MissingSecretKeyError` | `'MISSING_SECRET_KEY'` | `EnvError` | Named secret key not found |
492+
| `MissingDefaultSecretKeyError` | `'MISSING_DEFAULT_SECRET_KEY'` | `EnvError` | No default secret key |
493+
| `AuthGenericError` | `'AUTH_ERROR'` | `AuthError` | Generic auth error |
494+
| `InvalidCredentialsError` | `'INVALID_CREDENTIALS'` | `AuthError` | No credential matched, or JWT failed verification |
495+
| `CreateSupabaseClientError` | `'CREATE_SUPABASE_CLIENT_ERROR'` | `AuthError` | Client creation failed after auth |
496+
| `UnsupportedRoleError` | `'UNSUPPORTED_ROLE'` || `withPostgresClient` will not assume the caller's `role` claim |
367497

368498
---
369499

0 commit comments

Comments
 (0)