From 87aba66f3836dba930d079942b2ef311e5e93393 Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Wed, 1 Jul 2026 19:32:07 +0300 Subject: [PATCH 1/5] feat: add plugins option to withSupabase --- package.json | 1 + pnpm-lock.yaml | 10 ++++ pnpm-workspace.yaml | 1 + src/with-supabase.test.ts | 122 ++++++++++++++++++++++++++++++++++++++ src/with-supabase.ts | 94 ++++++++++++++++++++++++++++- 5 files changed, 227 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 63377ea..5cf0057 100644 --- a/package.json +++ b/package.json @@ -176,6 +176,7 @@ "vitest": "^4.1.0" }, "dependencies": { + "@supabase/web-middleware": "https://pkg.pr.new/supabase/web-middleware/@supabase/web-middleware@9", "jose": "^6.2.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 01bbc68..21eabf1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: .: dependencies: + '@supabase/web-middleware': + specifier: https://pkg.pr.new/supabase/web-middleware/@supabase/web-middleware@9 + version: https://pkg.pr.new/supabase/web-middleware/@supabase/web-middleware@9 jose: specifier: ^6.2.0 version: 6.2.0 @@ -863,6 +866,11 @@ packages: resolution: {integrity: sha512-OOoo3sLj9iVXNp6b+fkyOfFeQrvvNy7nQbaONNf72dOaictUeS39hFDS9argIRTag6M3ZxIypNWcrDAwLgUihQ==} engines: {node: '>=20.0.0'} + '@supabase/web-middleware@https://pkg.pr.new/supabase/web-middleware/@supabase/web-middleware@9': + resolution: {integrity: sha512-xiwxauZor23Bbnp5XLHcamOQqS81fqX7nJHQM4yEYju6lFQuVqiNUxpws1ORdiAR4eten3HOxJTjIGVrbMr8nw==, tarball: https://pkg.pr.new/supabase/web-middleware/@supabase/web-middleware@9} + version: 0.1.0 + engines: {node: '>=20'} + '@swc/core-darwin-arm64@1.15.33': resolution: {integrity: sha512-N+L0uXhuO7FIfzqwgxmzv0zIpV0qEp8wPX3QQs2p4atjMoywup2JTeDlXPw+z9pWJGCae3JjM+tZ6myclI+2gA==} engines: {node: '>=10'} @@ -3346,6 +3354,8 @@ snapshots: '@supabase/realtime-js': 2.106.0 '@supabase/storage-js': 2.106.0 + '@supabase/web-middleware@https://pkg.pr.new/supabase/web-middleware/@supabase/web-middleware@9': {} + '@swc/core-darwin-arm64@1.15.33': optional: true diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d474d52..a5de064 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,6 +7,7 @@ minimumReleaseAgeExclude: - '@esbuild/*' blockExoticSubdeps: true allowBuilds: + '@supabase/web-middleware': true '@nestjs/core': false '@swc/core': false esbuild: false diff --git a/src/with-supabase.test.ts b/src/with-supabase.test.ts index 8eec024..3d0237e 100644 --- a/src/with-supabase.test.ts +++ b/src/with-supabase.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defineMiddleware } from '@supabase/web-middleware' import { _resetAllowDeprecationWarned } from './core/utils/deprecation.js' import { withSupabase } from './with-supabase.js' @@ -98,6 +99,127 @@ describe('withSupabase', () => { expect(res.headers.get('Access-Control-Allow-Origin')).toBeNull() }) + describe('plugins', () => { + it('composes plugins after the Supabase context is established', async () => { + const withFlag = defineMiddleware< + 'flag', + void, + Record, + boolean + >({ + key: 'flag', + run: () => async () => ({ flag: true }), + }) + + const handler = withSupabase( + { auth: 'none', env: baseEnv, plugins: [withFlag()] }, + async (_req, ctx) => + Response.json({ authMode: ctx.authMode, flag: ctx.flag }), + ) + + const res = await handler(new Request('http://localhost')) + const body = await res.json() + expect(body.authMode).toBe('none') + expect(body.flag).toBe(true) + }) + + it('plugin receives the Supabase context at runtime', async () => { + let capturedHasSupabase = false + + const withCapture = defineMiddleware< + 'captured', + void, + Record, + true + >({ + key: 'captured', + run: () => async (_req, ctx) => { + capturedHasSupabase = !!(ctx as { supabase?: unknown }).supabase + return { captured: true as const } + }, + }) + + const handler = withSupabase( + { auth: 'none', env: baseEnv, plugins: [withCapture()] }, + async () => Response.json({ ok: true }), + ) + + await handler(new Request('http://localhost')) + expect(capturedHasSupabase).toBe(true) + }) + + it('plugin can short-circuit before the handler', async () => { + const withBlock = defineMiddleware< + 'blocked', + void, + Record, + true + >({ + key: 'blocked', + run: () => async () => new Response('blocked', { status: 403 }), + }) + + const innerHandler = vi.fn(async () => Response.json({ ok: true })) + + const handler = withSupabase( + { auth: 'none', env: baseEnv, plugins: [withBlock()] }, + innerHandler, + ) + + const res = await handler(new Request('http://localhost')) + expect(res.status).toBe(403) + expect(innerHandler).not.toHaveBeenCalled() + }) + + it('plugins run in array order (first = outermost, runs first on request)', async () => { + const order: string[] = [] + + const withA = defineMiddleware<'a', void, Record, true>({ + key: 'a', + run: () => async () => { + order.push('a') + return { a: true as const } + }, + }) + const withB = defineMiddleware<'b', void, Record, true>({ + key: 'b', + run: () => async () => { + order.push('b') + return { b: true as const } + }, + }) + + const handler = withSupabase( + { auth: 'none', env: baseEnv, plugins: [withA(), withB()] }, + async (_req, ctx) => Response.json({ a: ctx.a, b: ctx.b }), + ) + + const res = await handler(new Request('http://localhost')) + expect(order).toEqual(['a', 'b']) + expect(await res.json()).toEqual({ a: true, b: true }) + }) + + it('CORS headers still apply when plugins are present', async () => { + const withNoop = defineMiddleware< + 'noop', + void, + Record, + true + >({ + key: 'noop', + run: () => async () => ({ noop: true as const }), + }) + + const handler = withSupabase( + { auth: 'none', env: baseEnv, plugins: [withNoop()] }, + async () => Response.json({ ok: true }), + ) + + const res = await handler(new Request('http://localhost')) + expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*') + }) + }) + describe('allow → auth deprecation', () => { beforeEach(() => { _resetAllowDeprecationWarned() diff --git a/src/with-supabase.ts b/src/with-supabase.ts index aed4a8b..d5beeac 100644 --- a/src/with-supabase.ts +++ b/src/with-supabase.ts @@ -1,6 +1,26 @@ import { addCorsHeaders, buildCorsHeaders } from './cors.js' import { createSupabaseContext } from './create-supabase-context.js' import type { SupabaseContext, WithSupabaseConfig } from './types.js' +import type { Entry } from '@supabase/web-middleware' + +type AnyEntry = Entry +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyHandler = (req: Request, ctx: any) => Promise + +/** + * Accumulate the ctx contributions of a plugin tuple — same logic as + * `pipeline`'s internal `Accumulate`, seeded from `object` (no `BaseContext` + * or `_runtime` in the visible ctx type; see implementation note below). + */ +type PluginsCtx = + Plugins extends readonly [ + Entry, + ...infer Rest, + ] + ? Rest extends readonly AnyEntry[] + ? { [P in Key]: Contribution } & PluginsCtx + : { [P in Key]: Contribution } + : object /** * Wraps a request handler with Supabase auth, client creation, and CORS handling. @@ -19,6 +39,7 @@ import type { SupabaseContext, WithSupabaseConfig } from './types.js' * ```ts * import { withSupabase } from '@supabase/server' * + * // Without plugins — existing API, unchanged. * export default { * fetch: withSupabase({ auth: 'user' }, async (req, ctx) => { * const { data } = await ctx.supabase.rpc('get_my_profile') @@ -30,6 +51,55 @@ import type { SupabaseContext, WithSupabaseConfig } from './types.js' export function withSupabase( config: WithSupabaseConfig, handler: (req: Request, ctx: SupabaseContext) => Promise, +): (req: Request) => Promise + +/** + * Variant that accepts a `plugins` array — each `withFoo(config)` call returns + * an `Entry` from `@supabase/web-middleware`. Plugins run **after** the Supabase + * context is established; they receive `ctx.supabase`, `ctx.userClaims`, etc. + * already present and contribute their own typed keys on top. + * + * @example + * ```ts + * import { withSupabase } from '@supabase/server' + * import { withGuestbook } from '@supabase/plugin-guestbook/server' + * import { withRateLimit } from '@supabase/plugin-rate-limit/server' + * + * export default { + * fetch: withSupabase( + * { auth: 'user', plugins: [withRateLimit({ rpm: 100 }), withGuestbook()] }, + * async (req, ctx) => { + * ctx.supabase // from @supabase/server + * ctx.rateLimit // from withRateLimit + * ctx.guestbook // from withGuestbook + * return Response.json(await ctx.guestbook.list()) + * }, + * ), + * } + * ``` + * + * **Type note.** `PluginsCtx` accumulates the key contributions of the + * plugins array. Plugins that declare `In` prerequisites on Supabase-provided + * keys (`supabase`, `userClaims`, …) satisfy those at runtime (the Supabase + * context is merged before plugins run) but not at the type level — a full + * implementation would widen the prerequisite-validation seed to include + * `SupabaseContext`. Ordering and collision checks within the plugins array work + * normally via `web-middleware`'s runtime chain. + */ +export function withSupabase< + Database = unknown, + const Plugins extends readonly AnyEntry[] = readonly AnyEntry[], +>( + config: WithSupabaseConfig & { plugins: Plugins }, + handler: ( + req: Request, + ctx: SupabaseContext & PluginsCtx, + ) => Promise, +): (req: Request) => Promise + +export function withSupabase( + config: WithSupabaseConfig & { plugins?: readonly AnyEntry[] }, + handler: AnyHandler, ): (req: Request) => Promise { return async (req: Request) => { if (config.cors !== false && req.method === 'OPTIONS') { @@ -53,7 +123,29 @@ export function withSupabase( ) } - const response = await handler(req, ctx) + let response: Response + if (config.plugins?.length) { + // Compose plugins around the handler — same fold as pipeline's reduceRight, + // but without calling pipeline() so we supply the seeded ctx ourselves. + const composed = ( + config.plugins as readonly AnyEntry[] + ).reduceRight((h, entry) => entry(h), handler) + // Seed _runtime so web-middleware entries recognise this as an upstream + // context (isContext() checks for _runtime.getEnv). Falls through to + // process.env; a full implementation would bridge to SupabaseEnv. + const g = globalThis as { + process?: { env?: Record } + } + response = await composed(req, { + ...ctx, + _runtime: { + name: 'unknown' as const, + getEnv: (key: string): string | undefined => g.process?.env?.[key], + }, + }) + } else { + response = await handler(req, ctx as object) + } if (config.cors !== false) { return addCorsHeaders(response, config.cors) From 9d7536df7aef47292dd63a8620442e7331acb823 Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Wed, 1 Jul 2026 20:00:56 +0300 Subject: [PATCH 2/5] fix: add plugins?: never to overload 1 to force correct overload resolution TypeScript doesn't apply excess property checking during overload resolution, so calls with plugins: [...] were silently matching overload 1 and typing ctx as SupabaseContext. Adding plugins?: never to overload 1's config makes it definitively fail when plugins is present, falling through to the correct overload. Co-Authored-By: Claude Sonnet 4.6 --- src/with-supabase.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/with-supabase.ts b/src/with-supabase.ts index d5beeac..5758892 100644 --- a/src/with-supabase.ts +++ b/src/with-supabase.ts @@ -49,7 +49,7 @@ type PluginsCtx = * ``` */ export function withSupabase( - config: WithSupabaseConfig, + config: WithSupabaseConfig & { plugins?: never }, handler: (req: Request, ctx: SupabaseContext) => Promise, ): (req: Request) => Promise From 258f064524ea614e8c9a9edcc27bace384cc30b9 Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Thu, 2 Jul 2026 13:24:09 +0300 Subject: [PATCH 3/5] refactor: rename plugins option to middleware on withSupabase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The array holds middleware entries (per-request behavior from defineMiddleware) — the word 'plugins' is reserved for the package-level concept whose client namespace goes in createClient({ plugins }). One word per concept: server-side composition is 'middleware', client-side namespaces are 'plugins', a Plugin is the package that ships both. PluginsCtx -> MiddlewareCtx; overload trick unchanged (middleware?: never on overload 1). Co-Authored-By: Claude Fable 5 --- src/with-supabase.test.ts | 22 ++++++++-------- src/with-supabase.ts | 55 +++++++++++++++++++++------------------ 2 files changed, 40 insertions(+), 37 deletions(-) diff --git a/src/with-supabase.test.ts b/src/with-supabase.test.ts index 3d0237e..134eb84 100644 --- a/src/with-supabase.test.ts +++ b/src/with-supabase.test.ts @@ -99,8 +99,8 @@ describe('withSupabase', () => { expect(res.headers.get('Access-Control-Allow-Origin')).toBeNull() }) - describe('plugins', () => { - it('composes plugins after the Supabase context is established', async () => { + describe('middleware', () => { + it('composes middleware after the Supabase context is established', async () => { const withFlag = defineMiddleware< 'flag', void, @@ -112,7 +112,7 @@ describe('withSupabase', () => { }) const handler = withSupabase( - { auth: 'none', env: baseEnv, plugins: [withFlag()] }, + { auth: 'none', env: baseEnv, middleware: [withFlag()] }, async (_req, ctx) => Response.json({ authMode: ctx.authMode, flag: ctx.flag }), ) @@ -123,7 +123,7 @@ describe('withSupabase', () => { expect(body.flag).toBe(true) }) - it('plugin receives the Supabase context at runtime', async () => { + it('middleware receives the Supabase context at runtime', async () => { let capturedHasSupabase = false const withCapture = defineMiddleware< @@ -140,7 +140,7 @@ describe('withSupabase', () => { }) const handler = withSupabase( - { auth: 'none', env: baseEnv, plugins: [withCapture()] }, + { auth: 'none', env: baseEnv, middleware: [withCapture()] }, async () => Response.json({ ok: true }), ) @@ -148,7 +148,7 @@ describe('withSupabase', () => { expect(capturedHasSupabase).toBe(true) }) - it('plugin can short-circuit before the handler', async () => { + it('middleware can short-circuit before the handler', async () => { const withBlock = defineMiddleware< 'blocked', void, @@ -162,7 +162,7 @@ describe('withSupabase', () => { const innerHandler = vi.fn(async () => Response.json({ ok: true })) const handler = withSupabase( - { auth: 'none', env: baseEnv, plugins: [withBlock()] }, + { auth: 'none', env: baseEnv, middleware: [withBlock()] }, innerHandler, ) @@ -171,7 +171,7 @@ describe('withSupabase', () => { expect(innerHandler).not.toHaveBeenCalled() }) - it('plugins run in array order (first = outermost, runs first on request)', async () => { + it('middleware run in array order (first = outermost, runs first on request)', async () => { const order: string[] = [] const withA = defineMiddleware<'a', void, Record, true>({ @@ -190,7 +190,7 @@ describe('withSupabase', () => { }) const handler = withSupabase( - { auth: 'none', env: baseEnv, plugins: [withA(), withB()] }, + { auth: 'none', env: baseEnv, middleware: [withA(), withB()] }, async (_req, ctx) => Response.json({ a: ctx.a, b: ctx.b }), ) @@ -199,7 +199,7 @@ describe('withSupabase', () => { expect(await res.json()).toEqual({ a: true, b: true }) }) - it('CORS headers still apply when plugins are present', async () => { + it('CORS headers still apply when middleware are present', async () => { const withNoop = defineMiddleware< 'noop', void, @@ -211,7 +211,7 @@ describe('withSupabase', () => { }) const handler = withSupabase( - { auth: 'none', env: baseEnv, plugins: [withNoop()] }, + { auth: 'none', env: baseEnv, middleware: [withNoop()] }, async () => Response.json({ ok: true }), ) diff --git a/src/with-supabase.ts b/src/with-supabase.ts index 5758892..7e1969a 100644 --- a/src/with-supabase.ts +++ b/src/with-supabase.ts @@ -8,17 +8,17 @@ type AnyEntry = Entry type AnyHandler = (req: Request, ctx: any) => Promise /** - * Accumulate the ctx contributions of a plugin tuple — same logic as + * Accumulate the ctx contributions of a middleware tuple — same logic as * `pipeline`'s internal `Accumulate`, seeded from `object` (no `BaseContext` * or `_runtime` in the visible ctx type; see implementation note below). */ -type PluginsCtx = - Plugins extends readonly [ +type MiddlewareCtx = + Entries extends readonly [ Entry, ...infer Rest, ] ? Rest extends readonly AnyEntry[] - ? { [P in Key]: Contribution } & PluginsCtx + ? { [P in Key]: Contribution } & MiddlewareCtx : { [P in Key]: Contribution } : object @@ -39,7 +39,7 @@ type PluginsCtx = * ```ts * import { withSupabase } from '@supabase/server' * - * // Without plugins — existing API, unchanged. + * // Without middleware — existing API, unchanged. * export default { * fetch: withSupabase({ auth: 'user' }, async (req, ctx) => { * const { data } = await ctx.supabase.rpc('get_my_profile') @@ -49,15 +49,17 @@ type PluginsCtx = * ``` */ export function withSupabase( - config: WithSupabaseConfig & { plugins?: never }, + config: WithSupabaseConfig & { middleware?: never }, handler: (req: Request, ctx: SupabaseContext) => Promise, ): (req: Request) => Promise /** - * Variant that accepts a `plugins` array — each `withFoo(config)` call returns - * an `Entry` from `@supabase/web-middleware`. Plugins run **after** the Supabase - * context is established; they receive `ctx.supabase`, `ctx.userClaims`, etc. - * already present and contribute their own typed keys on top. + * Variant that accepts a `middleware` array — each `withFoo(config)` call + * returns an `Entry` from `@supabase/web-middleware`. Middleware run **after** + * the Supabase context is established; they receive `ctx.supabase`, + * `ctx.userClaims`, etc. already present and contribute their own typed keys + * on top. (This is the server leg of a Plugin: the package's middleware goes + * here; its client namespace goes in `createClient`'s `plugins` array.) * * @example * ```ts @@ -67,7 +69,7 @@ export function withSupabase( * * export default { * fetch: withSupabase( - * { auth: 'user', plugins: [withRateLimit({ rpm: 100 }), withGuestbook()] }, + * { auth: 'user', middleware: [withRateLimit({ rpm: 100 }), withGuestbook()] }, * async (req, ctx) => { * ctx.supabase // from @supabase/server * ctx.rateLimit // from withRateLimit @@ -78,27 +80,27 @@ export function withSupabase( * } * ``` * - * **Type note.** `PluginsCtx` accumulates the key contributions of the - * plugins array. Plugins that declare `In` prerequisites on Supabase-provided - * keys (`supabase`, `userClaims`, …) satisfy those at runtime (the Supabase - * context is merged before plugins run) but not at the type level — a full - * implementation would widen the prerequisite-validation seed to include - * `SupabaseContext`. Ordering and collision checks within the plugins array work - * normally via `web-middleware`'s runtime chain. + * **Type note.** `MiddlewareCtx` accumulates the key contributions of + * the middleware array. Middleware that declare `In` prerequisites on + * Supabase-provided keys (`supabase`, `userClaims`, …) satisfy those at runtime + * (the Supabase context is merged before the middleware run) but not at the + * type level — a full implementation would widen the prerequisite-validation + * seed to include `SupabaseContext`. Ordering and collision checks within the + * middleware array work normally via `web-middleware`'s runtime chain. */ export function withSupabase< Database = unknown, - const Plugins extends readonly AnyEntry[] = readonly AnyEntry[], + const Entries extends readonly AnyEntry[] = readonly AnyEntry[], >( - config: WithSupabaseConfig & { plugins: Plugins }, + config: WithSupabaseConfig & { middleware: Entries }, handler: ( req: Request, - ctx: SupabaseContext & PluginsCtx, + ctx: SupabaseContext & MiddlewareCtx, ) => Promise, ): (req: Request) => Promise export function withSupabase( - config: WithSupabaseConfig & { plugins?: readonly AnyEntry[] }, + config: WithSupabaseConfig & { middleware?: readonly AnyEntry[] }, handler: AnyHandler, ): (req: Request) => Promise { return async (req: Request) => { @@ -124,11 +126,12 @@ export function withSupabase( } let response: Response - if (config.plugins?.length) { - // Compose plugins around the handler — same fold as pipeline's reduceRight, - // but without calling pipeline() so we supply the seeded ctx ourselves. + if (config.middleware?.length) { + // Compose the middleware around the handler — same fold as pipeline's + // reduceRight, but without calling pipeline() so we supply the seeded + // ctx ourselves. const composed = ( - config.plugins as readonly AnyEntry[] + config.middleware as readonly AnyEntry[] ).reduceRight((h, entry) => entry(h), handler) // Seed _runtime so web-middleware entries recognise this as an upstream // context (isContext() checks for _runtime.getEnv). Falls through to From 44b05da4be49577cfe53f8ba13bade28d66bd4fa Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Wed, 8 Jul 2026 12:53:26 +0300 Subject: [PATCH 4/5] feat(middleware): add postgres and claims middleware entrypoints Graduate withPostgres and withClaims out of plugin-examples into @supabase/server/middleware/*, so the PRFAQ's built-in middleware ship from the package instead of example-local code (SDK-1163 item 5). - withPostgres reads claims from ctx.jwtClaims (already populated by withSupabase), so `middleware: [withPostgres()]` works with no separate withClaims. Keeps the RLS role-clamp and tx-local request.jwt.claims injection. pg is an optional peer dep (Node/Deno only, not Workers). - withClaims ships for the standalone agnostic pipeline() case (demo-only: no signature verification). Co-Authored-By: Claude Opus 4.8 (1M context) --- jsr.json | 4 +- package.json | 27 +++++- pnpm-lock.yaml | 116 +++++++++++++++++++++++- src/middleware/claims/index.test.ts | 67 ++++++++++++++ src/middleware/claims/index.ts | 57 ++++++++++++ src/middleware/postgres/index.test.ts | 123 +++++++++++++++++++++++++ src/middleware/postgres/index.ts | 126 ++++++++++++++++++++++++++ tsdown.config.ts | 11 ++- 8 files changed, 527 insertions(+), 4 deletions(-) create mode 100644 src/middleware/claims/index.test.ts create mode 100644 src/middleware/claims/index.ts create mode 100644 src/middleware/postgres/index.test.ts create mode 100644 src/middleware/postgres/index.ts diff --git a/jsr.json b/jsr.json index 59fbe8d..314c7bb 100644 --- a/jsr.json +++ b/jsr.json @@ -8,7 +8,9 @@ "./adapters/hono": "./src/adapters/hono/index.ts", "./adapters/h3": "./src/adapters/h3/index.ts", "./adapters/elysia": "./src/adapters/elysia/index.ts", - "./adapters/nestjs": "./src/adapters/nestjs/index.ts" + "./adapters/nestjs": "./src/adapters/nestjs/index.ts", + "./middleware/postgres": "./src/middleware/postgres/index.ts", + "./middleware/claims": "./src/middleware/claims/index.ts" }, "publish": { "include": ["src/**/*.ts", "README.md", "LICENSE"], diff --git a/package.json b/package.json index 5cf0057..079aeb9 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,26 @@ "default": "./dist/adapters/nestjs/index.cjs" } }, + "./middleware/postgres": { + "import": { + "types": "./dist/middleware/postgres/index.d.mts", + "default": "./dist/middleware/postgres/index.mjs" + }, + "require": { + "types": "./dist/middleware/postgres/index.d.cts", + "default": "./dist/middleware/postgres/index.cjs" + } + }, + "./middleware/claims": { + "import": { + "types": "./dist/middleware/claims/index.d.mts", + "default": "./dist/middleware/claims/index.mjs" + }, + "require": { + "types": "./dist/middleware/claims/index.d.cts", + "default": "./dist/middleware/claims/index.cjs" + } + }, "./package.json": "./package.json" }, "main": "./dist/index.cjs", @@ -128,7 +148,8 @@ "@supabase/supabase-js": "^2.0.0", "elysia": "^1.4.0", "h3": "^2.0.0", - "hono": "^4.0.0" + "hono": "^4.0.0", + "pg": "^8.0.0" }, "peerDependenciesMeta": { "@nestjs/common": { @@ -142,6 +163,9 @@ }, "elysia": { "optional": true + }, + "pg": { + "optional": true } }, "devDependencies": { @@ -156,6 +180,7 @@ "@supabase/supabase-js": "^2.105.4", "@swc/core": "^1.15.33", "@types/node": "^26.0.1", + "@types/pg": "^8.11.0", "@types/supertest": "^7.2.0", "elysia": "^1.4.0", "eslint": "^10.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21eabf1..cad6712 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: jose: specifier: ^6.2.0 version: 6.2.0 + pg: + specifier: ^8.0.0 + version: 8.22.0 devDependencies: '@arethetypeswrong/cli': specifier: ^0.18.4 @@ -51,6 +54,9 @@ importers: '@types/node': specifier: ^26.0.1 version: 26.0.1 + '@types/pg': + specifier: ^8.11.0 + version: 8.20.0 '@types/supertest': specifier: ^7.2.0 version: 7.2.0 @@ -867,7 +873,7 @@ packages: engines: {node: '>=20.0.0'} '@supabase/web-middleware@https://pkg.pr.new/supabase/web-middleware/@supabase/web-middleware@9': - resolution: {integrity: sha512-xiwxauZor23Bbnp5XLHcamOQqS81fqX7nJHQM4yEYju6lFQuVqiNUxpws1ORdiAR4eten3HOxJTjIGVrbMr8nw==, tarball: https://pkg.pr.new/supabase/web-middleware/@supabase/web-middleware@9} + resolution: {tarball: https://pkg.pr.new/supabase/web-middleware/@supabase/web-middleware@9} version: 0.1.0 engines: {node: '>=20'} @@ -1009,6 +1015,8 @@ packages: '@types/node@26.0.1': resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} + '@types/pg@8.20.0': + resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} '@types/superagent@8.1.9': resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} @@ -2092,6 +2100,40 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pg-cloudflare@1.4.0: + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} + + pg-connection-string@2.14.0: + resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.15.0: + resolution: {integrity: sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.22.0: + resolution: {integrity: sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2113,6 +2155,22 @@ packages: resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -2678,6 +2736,10 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -3462,6 +3524,11 @@ snapshots: '@types/node@26.0.1': dependencies: undici-types: 8.3.0 + '@types/pg@8.20.0': + dependencies: + '@types/node': 25.3.0 + pg-protocol: 1.15.0 + pg-types: 2.2.0 '@types/superagent@8.1.9': dependencies: @@ -4556,6 +4623,41 @@ snapshots: pathe@2.0.3: {} + pg-cloudflare@1.4.0: + optional: true + + pg-connection-string@2.14.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.14.0(pg@8.22.0): + dependencies: + pg: 8.22.0 + + pg-protocol@1.15.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.22.0: + dependencies: + pg-connection-string: 2.14.0 + pg-pool: 3.14.0(pg@8.22.0) + pg-protocol: 1.15.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@4.0.4: {} @@ -4586,6 +4688,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + prelude-ls@1.2.1: {} prettier@3.8.1: {} @@ -5136,6 +5248,8 @@ snapshots: wrappy@1.0.2: {} + xtend@4.0.2: {} + y18n@5.0.8: {} yaml@2.8.3: {} diff --git a/src/middleware/claims/index.test.ts b/src/middleware/claims/index.test.ts new file mode 100644 index 0000000..68ab4a1 --- /dev/null +++ b/src/middleware/claims/index.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' + +import { withClaims } from './index.js' + +function base64url(obj: unknown): string { + return Buffer.from(JSON.stringify(obj)) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') +} + +function tokenFor(claims: Record): string { + return `header.${base64url(claims)}.sig` +} + +const runtime = { name: 'node' as const, getEnv: () => undefined } + +describe('withClaims', () => { + it('decodes the Bearer token payload into ctx.jwtClaims', async () => { + let seen: unknown + const handler = withClaims(async (_req, ctx) => { + seen = ctx.jwtClaims + return Response.json({ ok: true }) + }) + + await handler( + new Request('http://localhost', { + headers: { + Authorization: `Bearer ${tokenFor({ sub: 'u1', role: 'authenticated' })}`, + }, + }), + { _runtime: runtime }, + ) + + expect(seen).toEqual({ sub: 'u1', role: 'authenticated' }) + }) + + it('contributes null when no Authorization header is present', async () => { + let seen: unknown = 'unset' + const handler = withClaims(async (_req, ctx) => { + seen = ctx.jwtClaims + return Response.json({ ok: true }) + }) + + await handler(new Request('http://localhost'), { _runtime: runtime }) + + expect(seen).toBeNull() + }) + + it('contributes null for a malformed token', async () => { + let seen: unknown = 'unset' + const handler = withClaims(async (_req, ctx) => { + seen = ctx.jwtClaims + return Response.json({ ok: true }) + }) + + await handler( + new Request('http://localhost', { + headers: { Authorization: 'Bearer not-a-jwt' }, + }), + { _runtime: runtime }, + ) + + expect(seen).toBeNull() + }) +}) diff --git a/src/middleware/claims/index.ts b/src/middleware/claims/index.ts new file mode 100644 index 0000000..b052290 --- /dev/null +++ b/src/middleware/claims/index.ts @@ -0,0 +1,57 @@ +import { defineMiddleware } from '@supabase/web-middleware' + +/** + * Loosely-typed JWT claims contributed by {@link withClaims}. + * + * @category Middleware + */ +export interface JwtClaims { + sub?: string + role?: string + [k: string]: unknown +} + +/** base64url-decode a JWT payload segment (no signature verification). */ +function decodeJwtPayload(token: string): JwtClaims | null { + const part = token.split('.')[1] + if (!part) return null + const b64 = part + .replace(/-/g, '+') + .replace(/_/g, '/') + .padEnd(Math.ceil(part.length / 4) * 4, '=') + try { + return JSON.parse(atob(b64)) as JwtClaims + } catch { + return null + } +} + +/** + * Contributes `ctx.jwtClaims` by decoding the caller's Bearer token. + * + * Use this only when composing a standalone `pipeline([...], handler)` that is + * **not** wrapped by `withSupabase` — for example a Supabase-agnostic Edge + * Function that still wants the caller's claims available to a downstream + * middleware such as {@link withPostgres}. Inside `withSupabase`, the context + * already carries `jwtClaims` (JWKS-verified), so `withClaims` is unnecessary. + * + * > **DEMO ONLY — does NOT verify the signature.** It base64url-decodes the + * > payload so the Postgres example is self-contained. `withSupabase` verifies + * > the JWT against the project JWKS before trusting the claims; never trust an + * > unverified token in production. + * + * @category Middleware + */ +export const withClaims = defineMiddleware< + 'jwtClaims', + void, + Record, + JwtClaims | null +>({ + key: 'jwtClaims', + run: () => async (req) => { + const auth = req.headers.get('Authorization') + const token = auth?.replace(/^Bearer\s+/i, '') + return { jwtClaims: token ? decodeJwtPayload(token) : null } + }, +}) diff --git a/src/middleware/postgres/index.test.ts b/src/middleware/postgres/index.test.ts new file mode 100644 index 0000000..1f81cde --- /dev/null +++ b/src/middleware/postgres/index.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Shared mock state, hoisted so the vi.mock factory can close over it. +const h = vi.hoisted(() => { + const issued: string[] = [] + const clientQuery = vi.fn(async (text: string) => { + issued.push(text) + return { rows: [{ ok: true }] } + }) + const release = vi.fn() + const connect = vi.fn(async () => ({ query: clientQuery, release })) + return { issued, clientQuery, release, connect } +}) + +vi.mock('pg', () => { + class Pool { + connect = h.connect + } + return { default: { Pool }, Pool } +}) + +const runtime = { + name: 'node' as const, + getEnv: (k: string) => + k === 'SUPABASE_DB_URL' ? 'postgres://localhost/test' : undefined, +} + +const { withPostgres } = await import('./index.js') + +describe('withPostgres', () => { + beforeEach(() => { + h.issued.length = 0 + h.clientQuery.mockClear() + h.connect.mockClear() + h.release.mockClear() + }) + afterEach(() => vi.restoreAllMocks()) + + it('returns 500 when no connection string is available', async () => { + const handler = withPostgres({ connectionString: undefined }, async () => + Response.json({ ok: true }), + ) + + const res = await handler(new Request('http://localhost'), { + _runtime: { name: 'node', getEnv: () => undefined }, + jwtClaims: null, + }) + + expect(res.status).toBe(500) + expect(await res.json()).toEqual({ error: 'no SUPABASE_DB_URL' }) + }) + + it('injects the caller claims and drops to the authenticated role', async () => { + const handler = withPostgres(async (_req, ctx) => { + await ctx.postgres.query('select 1') + return Response.json({ ok: true }) + }) + + await handler(new Request('http://localhost'), { + _runtime: runtime, + jwtClaims: { sub: 'u1', role: 'authenticated' }, + }) + + expect(h.issued).toEqual([ + 'begin', + `select set_config('request.jwt.claims', $1, true)`, + 'set local role authenticated', + 'select 1', + 'commit', + ]) + expect(h.release).toHaveBeenCalled() + }) + + it('clamps any non-authenticated role (incl. a forged service_role) to anon', async () => { + const handler = withPostgres(async (_req, ctx) => { + await ctx.postgres.query('select 1') + return Response.json({ ok: true }) + }) + + await handler(new Request('http://localhost'), { + _runtime: runtime, + jwtClaims: { sub: 'attacker', role: 'service_role' }, + }) + + expect(h.issued).toContain('set local role anon') + expect(h.issued).not.toContain('set local role service_role') + }) + + it('rolls back when the query throws', async () => { + // begin, set_config, set role succeed; the user query throws. + h.clientQuery + .mockImplementationOnce(async (t: string) => { + h.issued.push(t) + return { rows: [] } + }) + .mockImplementationOnce(async (t: string) => { + h.issued.push(t) + return { rows: [] } + }) + .mockImplementationOnce(async (t: string) => { + h.issued.push(t) + return { rows: [] } + }) + .mockImplementationOnce(async () => { + throw new Error('boom') + }) + + const handler = withPostgres(async (_req, ctx) => { + await ctx.postgres.query('select bad') + return Response.json({ ok: true }) + }) + + await expect( + handler(new Request('http://localhost'), { + _runtime: runtime, + jwtClaims: { role: 'authenticated' }, + }), + ).rejects.toThrow('boom') + + expect(h.issued).toContain('rollback') + expect(h.release).toHaveBeenCalled() + }) +}) diff --git a/src/middleware/postgres/index.ts b/src/middleware/postgres/index.ts new file mode 100644 index 0000000..ac6bc8e --- /dev/null +++ b/src/middleware/postgres/index.ts @@ -0,0 +1,126 @@ +import { defineMiddleware } from '@supabase/web-middleware' +import pg from 'pg' + +const { Pool } = pg + +// One pool per process, lazily created (config or SUPABASE_DB_URL). +let pool: pg.Pool | undefined +function getPool(connectionString: string): pg.Pool { + if (!pool) pool = new Pool({ connectionString, max: 4 }) + return pool +} + +/** + * Minimal claims shape {@link withPostgres} needs on the upstream context. + * + * Satisfied both by `withSupabase`'s JWKS-verified `ctx.jwtClaims` and by the + * standalone `withClaims` middleware — `withPostgres` only reads `role` and + * serializes the whole object into `request.jwt.claims`. + */ +interface RequestClaims { + role?: string + [key: string]: unknown +} + +/** + * The `ctx.postgres` client contributed by {@link withPostgres}. + * + * @category Middleware + */ +export interface PostgresApi { + /** Run a query inside the caller's RLS-scoped transaction. */ + query>( + text: string, + params?: unknown[], + ): Promise +} + +/** + * Configuration for {@link withPostgres}. + * + * @category Middleware + */ +export interface WithPostgresConfig { + /** Defaults to `ctx._runtime.getEnv('SUPABASE_DB_URL')`. */ + connectionString?: string +} + +/** + * Contributes `ctx.postgres` — an RLS-scoped `pg` client, the safe version of + * "authenticate, then query as the user". Every query runs in its own short + * transaction that injects the caller's claims and drops to their role, exactly + * like PostgREST: + * + * ```sql + * begin; + * select set_config('request.jwt.claims', $claims, true); -- auth.uid() resolves + * set local role authenticated; -- RLS now enforces + * + * commit; + * ``` + * + * Everything is transaction-local, so nothing leaks onto the pooled connection. + * + * Reads the caller's claims from `ctx.jwtClaims`, which `withSupabase` already + * populates (JWKS-verified) — so inside `withSupabase` you compose it directly: + * + * ```ts + * withSupabase({ auth: 'user', middleware: [withPostgres()] }, handler) + * ``` + * + * Standalone (no `withSupabase`), pair it with `withClaims` so `ctx.jwtClaims` + * is present before it runs. + * + * > **Runtime note.** `pg` needs raw TCP, so this runs on Node/Deno (including + * > the Supabase Edge runtime), **not** on Workers-style isolates. + * + * @category Middleware + */ +export const withPostgres = defineMiddleware< + 'postgres', + WithPostgresConfig | void, + { jwtClaims: RequestClaims | null }, + PostgresApi +>({ + key: 'postgres', + run: (config) => async (_req, ctx) => { + const connectionString = + config?.connectionString ?? ctx._runtime.getEnv('SUPABASE_DB_URL') + if (!connectionString) { + return Response.json({ error: 'no SUPABASE_DB_URL' }, { status: 500 }) + } + + const p = getPool(connectionString) + const claims = ctx.jwtClaims + // Clamp the role — a token can never flip the client into an RLS-bypassing + // role. service_role is deliberately not reachable here. + const role = claims?.role === 'authenticated' ? 'authenticated' : 'anon' + + const api: PostgresApi = { + async query>( + text: string, + params?: unknown[], + ) { + const client = await p.connect() + try { + await client.query('begin') + await client.query( + `select set_config('request.jwt.claims', $1, true)`, + [JSON.stringify(claims ?? {})], + ) + await client.query(`set local role ${role}`) // role is a clamped literal + const res = await client.query(text, params) + await client.query('commit') + return res.rows as T[] + } catch (e) { + await client.query('rollback') + throw e + } finally { + client.release() + } + }, + } + + return { postgres: api } + }, +}) diff --git a/tsdown.config.ts b/tsdown.config.ts index 118680c..474f12b 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -9,8 +9,17 @@ export default defineConfig({ 'src/adapters/h3/index.ts', 'src/adapters/elysia/index.ts', 'src/adapters/nestjs/index.ts', + 'src/middleware/postgres/index.ts', + 'src/middleware/claims/index.ts', ], format: ['esm', 'cjs'], dts: true, - external: ['@supabase/supabase-js', 'hono', 'h3', 'elysia', '@nestjs/common'], + external: [ + '@supabase/supabase-js', + 'hono', + 'h3', + 'elysia', + '@nestjs/common', + 'pg', + ], }) From 58d58f4085f68e369c816d9e5f09c69b14d05ddb Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Thu, 9 Jul 2026 14:39:40 +0300 Subject: [PATCH 5/5] feat(middleware): surface table-grants hint on 42501 in withPostgres Append the caller-role grants hint to permission-denied errors and document the grants requirement in the withPostgres JSDoc. Co-Authored-By: Claude Fable 5 --- src/middleware/postgres/index.test.ts | 41 +++++++++++++++++++++++++++ src/middleware/postgres/index.ts | 9 ++++++ 2 files changed, 50 insertions(+) diff --git a/src/middleware/postgres/index.test.ts b/src/middleware/postgres/index.test.ts index 1f81cde..d8ee1f5 100644 --- a/src/middleware/postgres/index.test.ts +++ b/src/middleware/postgres/index.test.ts @@ -120,4 +120,45 @@ describe('withPostgres', () => { expect(h.issued).toContain('rollback') expect(h.release).toHaveBeenCalled() }) + + it('appends a grants hint to permission-denied (42501) errors', async () => { + // begin, set_config, set role succeed; the user query hits missing grants. + h.clientQuery + .mockImplementationOnce(async (t: string) => { + h.issued.push(t) + return { rows: [] } + }) + .mockImplementationOnce(async (t: string) => { + h.issued.push(t) + return { rows: [] } + }) + .mockImplementationOnce(async (t: string) => { + h.issued.push(t) + return { rows: [] } + }) + .mockImplementationOnce(async () => { + const err = new Error('permission denied for table notes') as Error & { + code: string + } + err.code = '42501' + throw err + }) + + const handler = withPostgres(async (_req, ctx) => { + await ctx.postgres.query('select * from notes') + return Response.json({ ok: true }) + }) + + await expect( + handler(new Request('http://localhost'), { + _runtime: runtime, + jwtClaims: { role: 'authenticated' }, + }), + ).rejects.toThrow( + /permission denied for table notes \(RLS-scoped queries run as the caller's role 'authenticated'/, + ) + + expect(h.issued).toContain('rollback') + expect(h.release).toHaveBeenCalled() + }) }) diff --git a/src/middleware/postgres/index.ts b/src/middleware/postgres/index.ts index ac6bc8e..fca4718 100644 --- a/src/middleware/postgres/index.ts +++ b/src/middleware/postgres/index.ts @@ -71,6 +71,11 @@ export interface WithPostgresConfig { * Standalone (no `withSupabase`), pair it with `withClaims` so `ctx.jwtClaims` * is present before it runs. * + * > **Table grants.** Queries run as `authenticated` or `anon`, so those + * > roles need explicit table privileges (e.g. `grant select, insert on + * > to authenticated`) in addition to RLS policies. A missing grant + * > fails with `permission denied` (SQLSTATE 42501) before RLS is consulted. + * * > **Runtime note.** `pg` needs raw TCP, so this runs on Node/Deno (including * > the Supabase Edge runtime), **not** on Workers-style isolates. * @@ -114,6 +119,10 @@ export const withPostgres = defineMiddleware< return res.rows as T[] } catch (e) { await client.query('rollback') + // 42501 insufficient_privilege: the role lacks table grants. + if (e instanceof Error && (e as { code?: string }).code === '42501') { + e.message += ` (RLS-scoped queries run as the caller's role '${role}' — grant that role the table privileges it needs, e.g. "grant select on
to ${role}")` + } throw e } finally { client.release()