From 89aced138c0c0b36172f0dd8e8185872603b399b Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Wed, 1 Jul 2026 19:32:07 +0300 Subject: [PATCH 01/12] 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 2b56f77..2f49b20 100644 --- a/package.json +++ b/package.json @@ -177,6 +177,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 1943c15..ddf2ac2 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 @@ -870,6 +873,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'} @@ -3360,6 +3368,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 87fd317..53dd630 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' @@ -158,6 +159,127 @@ describe('withSupabase', () => { }) }) + 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 a8dc72e..d235f08 100644 --- a/src/with-supabase.ts +++ b/src/with-supabase.ts @@ -1,6 +1,26 @@ import { addCorsHeaders, buildCorsHeaders, isCorsDisabled } 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 (!isCorsDisabled(config.cors) && req.method === 'OPTIONS') { @@ -55,7 +125,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 (!isCorsDisabled(config.cors)) { return addCorsHeaders(response, config.cors) From c86120eaaebc99647634848b6afefb7fae6248fb Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Wed, 1 Jul 2026 20:00:56 +0300 Subject: [PATCH 02/12] 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 d235f08..10b5235 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 5d5b7d9dea3a3f0651cdcb500f40c896ac72be9b Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Thu, 2 Jul 2026 13:24:09 +0300 Subject: [PATCH 03/12] 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 53dd630..b8d555b 100644 --- a/src/with-supabase.test.ts +++ b/src/with-supabase.test.ts @@ -159,8 +159,8 @@ describe('withSupabase', () => { }) }) - 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, @@ -172,7 +172,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 }), ) @@ -183,7 +183,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< @@ -200,7 +200,7 @@ describe('withSupabase', () => { }) const handler = withSupabase( - { auth: 'none', env: baseEnv, plugins: [withCapture()] }, + { auth: 'none', env: baseEnv, middleware: [withCapture()] }, async () => Response.json({ ok: true }), ) @@ -208,7 +208,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, @@ -222,7 +222,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, ) @@ -231,7 +231,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>({ @@ -250,7 +250,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 }), ) @@ -259,7 +259,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, @@ -271,7 +271,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 10b5235..642d08d 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) => { @@ -126,11 +128,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 cbcd742d2a8a7a4b54a9839851928d4feda46984 Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Wed, 8 Jul 2026 12:53:26 +0300 Subject: [PATCH 04/12] 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 4507190..bcea0fd 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": [ diff --git a/package.json b/package.json index 2f49b20..6835c0d 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", @@ -129,7 +149,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": { @@ -143,6 +164,9 @@ }, "elysia": { "optional": true + }, + "pg": { + "optional": true } }, "devDependencies": { @@ -157,6 +181,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 ddf2ac2..3cb0e14 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 @@ -874,7 +880,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'} @@ -1016,6 +1022,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==} @@ -2099,6 +2107,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==} @@ -2124,6 +2166,22 @@ packages: resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} 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'} @@ -2689,6 +2747,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'} @@ -3476,6 +3538,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: @@ -4574,6 +4641,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: {} @@ -4606,6 +4708,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: {} @@ -5157,6 +5269,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 012137c28fc2faaf1af28ab353a4e930fff4795d Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Thu, 9 Jul 2026 14:39:40 +0300 Subject: [PATCH 05/12] 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() From f85c146eb632281c259e8bc77eb6bb3549e9598d Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Tue, 21 Jul 2026 14:29:40 +0300 Subject: [PATCH 06/12] refactor: adopt @supabase/middleware importable getEnv API Port from the @supabase/web-middleware PR-9 preview to @supabase/middleware at main (0641674), which dropped ctx._runtime: - withSupabase seeds the middleware chain via seedContext() instead of faking a { _runtime } facet (the engine now marks contexts with a symbol, so the structural fake no longer works) - withPostgres defaults its connection string from the importable getEnv('SUPABASE_DB_URL') instead of ctx._runtime.getEnv - tests use vi.stubEnv for the env fallback; withClaims tests call the handler as a bare fetch entry Co-Authored-By: Claude Fable 5 --- package.json | 2 +- pnpm-lock.yaml | 29 ++++++++++++++++++--------- src/middleware/claims/index.test.ts | 7 ++----- src/middleware/claims/index.ts | 2 +- src/middleware/postgres/index.test.ts | 26 +++++++++++++----------- src/middleware/postgres/index.ts | 6 +++--- src/with-supabase.test.ts | 2 +- src/with-supabase.ts | 28 +++++++++----------------- 8 files changed, 51 insertions(+), 51 deletions(-) diff --git a/package.json b/package.json index 6835c0d..7b01544 100644 --- a/package.json +++ b/package.json @@ -202,7 +202,7 @@ "vitest": "^4.1.0" }, "dependencies": { - "@supabase/web-middleware": "https://pkg.pr.new/supabase/web-middleware/@supabase/web-middleware@9", + "@supabase/middleware": "https://pkg.pr.new/supabase/middleware/@supabase/middleware@0641674", "jose": "^6.2.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3cb0e14..a9a5d5a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +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 + '@supabase/middleware': + specifier: https://pkg.pr.new/supabase/middleware/@supabase/middleware@0641674 + version: https://pkg.pr.new/supabase/middleware/@supabase/middleware@0641674 jose: specifier: ^6.2.0 version: 6.2.0 @@ -860,6 +860,11 @@ packages: resolution: {integrity: sha512-ADIkJYH5w7HbnGVAAlCbyKoLF5QdfyezBLfYXpUqhxZOacK6YepOvnP/8p4p+50bhTPWp6VhDxu19KO7e/qU2g==} engines: {node: '>=20.0.0'} + '@supabase/middleware@https://pkg.pr.new/supabase/middleware/@supabase/middleware@0641674': + resolution: {integrity: sha512-BVlmrVAJRzvMuwe1l4ldxHmBo5L1C4fWq1OSv8FtlzYRDVnIW2nZC0qxo9okk5Y4z5Cc5PW1veKvMjJMhobm3w==, tarball: https://pkg.pr.new/supabase/middleware/@supabase/middleware@0641674} + version: 0.1.0 + engines: {node: '>=20'} + '@supabase/phoenix@0.4.2': resolution: {integrity: sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==} @@ -879,11 +884,6 @@ 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: {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'} @@ -1022,6 +1022,7 @@ packages: '@types/node@26.0.1': resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} + '@types/pg@8.20.0': resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} @@ -2424,6 +2425,9 @@ packages: std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + streamsearch@1.1.0: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} @@ -3406,6 +3410,10 @@ snapshots: dependencies: tslib: 2.8.1 + '@supabase/middleware@https://pkg.pr.new/supabase/middleware/@supabase/middleware@0641674': + dependencies: + std-env: 4.2.0 + '@supabase/phoenix@0.4.2': {} '@supabase/postgrest-js@2.106.0': @@ -3430,8 +3438,6 @@ 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 @@ -3538,6 +3544,7 @@ snapshots: '@types/node@26.0.1': dependencies: undici-types: 8.3.0 + '@types/pg@8.20.0': dependencies: '@types/node': 25.3.0 @@ -4983,6 +4990,8 @@ snapshots: std-env@4.1.0: {} + std-env@4.2.0: {} + streamsearch@1.1.0: {} string-width@4.2.3: diff --git a/src/middleware/claims/index.test.ts b/src/middleware/claims/index.test.ts index 68ab4a1..e106fd8 100644 --- a/src/middleware/claims/index.test.ts +++ b/src/middleware/claims/index.test.ts @@ -14,8 +14,6 @@ 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 @@ -24,13 +22,13 @@ describe('withClaims', () => { return Response.json({ ok: true }) }) + // Called bare, the way a runtime invokes a fetch entry — no prerequisites. await handler( new Request('http://localhost', { headers: { Authorization: `Bearer ${tokenFor({ sub: 'u1', role: 'authenticated' })}`, }, }), - { _runtime: runtime }, ) expect(seen).toEqual({ sub: 'u1', role: 'authenticated' }) @@ -43,7 +41,7 @@ describe('withClaims', () => { return Response.json({ ok: true }) }) - await handler(new Request('http://localhost'), { _runtime: runtime }) + await handler(new Request('http://localhost')) expect(seen).toBeNull() }) @@ -59,7 +57,6 @@ describe('withClaims', () => { 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 index b052290..fd003ca 100644 --- a/src/middleware/claims/index.ts +++ b/src/middleware/claims/index.ts @@ -1,4 +1,4 @@ -import { defineMiddleware } from '@supabase/web-middleware' +import { defineMiddleware } from '@supabase/middleware' /** * Loosely-typed JWT claims contributed by {@link withClaims}. diff --git a/src/middleware/postgres/index.test.ts b/src/middleware/postgres/index.test.ts index d8ee1f5..a7757b3 100644 --- a/src/middleware/postgres/index.test.ts +++ b/src/middleware/postgres/index.test.ts @@ -19,12 +19,7 @@ vi.mock('pg', () => { return { default: { Pool }, Pool } }) -const runtime = { - name: 'node' as const, - getEnv: (k: string) => - k === 'SUPABASE_DB_URL' ? 'postgres://localhost/test' : undefined, -} - +const { seedContext } = await import('@supabase/middleware') const { withPostgres } = await import('./index.js') describe('withPostgres', () => { @@ -33,16 +28,23 @@ describe('withPostgres', () => { h.clientQuery.mockClear() h.connect.mockClear() h.release.mockClear() + // The connection-string default reads the importable getEnv, which falls + // back to the host env in tests. + vi.stubEnv('SUPABASE_DB_URL', 'postgres://localhost/test') + }) + afterEach(() => { + vi.unstubAllEnvs() + vi.restoreAllMocks() }) - afterEach(() => vi.restoreAllMocks()) it('returns 500 when no connection string is available', async () => { + vi.stubEnv('SUPABASE_DB_URL', undefined) const handler = withPostgres({ connectionString: undefined }, async () => Response.json({ ok: true }), ) const res = await handler(new Request('http://localhost'), { - _runtime: { name: 'node', getEnv: () => undefined }, + ...seedContext(), jwtClaims: null, }) @@ -57,7 +59,7 @@ describe('withPostgres', () => { }) await handler(new Request('http://localhost'), { - _runtime: runtime, + ...seedContext(), jwtClaims: { sub: 'u1', role: 'authenticated' }, }) @@ -78,7 +80,7 @@ describe('withPostgres', () => { }) await handler(new Request('http://localhost'), { - _runtime: runtime, + ...seedContext(), jwtClaims: { sub: 'attacker', role: 'service_role' }, }) @@ -112,7 +114,7 @@ describe('withPostgres', () => { await expect( handler(new Request('http://localhost'), { - _runtime: runtime, + ...seedContext(), jwtClaims: { role: 'authenticated' }, }), ).rejects.toThrow('boom') @@ -151,7 +153,7 @@ describe('withPostgres', () => { await expect( handler(new Request('http://localhost'), { - _runtime: runtime, + ...seedContext(), jwtClaims: { role: 'authenticated' }, }), ).rejects.toThrow( diff --git a/src/middleware/postgres/index.ts b/src/middleware/postgres/index.ts index fca4718..4547ff0 100644 --- a/src/middleware/postgres/index.ts +++ b/src/middleware/postgres/index.ts @@ -1,4 +1,4 @@ -import { defineMiddleware } from '@supabase/web-middleware' +import { defineMiddleware, getEnv } from '@supabase/middleware' import pg from 'pg' const { Pool } = pg @@ -41,7 +41,7 @@ export interface PostgresApi { * @category Middleware */ export interface WithPostgresConfig { - /** Defaults to `ctx._runtime.getEnv('SUPABASE_DB_URL')`. */ + /** Defaults to `getEnv('SUPABASE_DB_URL')` (from `@supabase/middleware`). */ connectionString?: string } @@ -90,7 +90,7 @@ export const withPostgres = defineMiddleware< key: 'postgres', run: (config) => async (_req, ctx) => { const connectionString = - config?.connectionString ?? ctx._runtime.getEnv('SUPABASE_DB_URL') + config?.connectionString ?? getEnv('SUPABASE_DB_URL') if (!connectionString) { return Response.json({ error: 'no SUPABASE_DB_URL' }, { status: 500 }) } diff --git a/src/with-supabase.test.ts b/src/with-supabase.test.ts index b8d555b..db26c9c 100644 --- a/src/with-supabase.test.ts +++ b/src/with-supabase.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { defineMiddleware } from '@supabase/web-middleware' +import { defineMiddleware } from '@supabase/middleware' import { _resetAllowDeprecationWarned } from './core/utils/deprecation.js' import { withSupabase } from './with-supabase.js' diff --git a/src/with-supabase.ts b/src/with-supabase.ts index 642d08d..927f23e 100644 --- a/src/with-supabase.ts +++ b/src/with-supabase.ts @@ -1,7 +1,8 @@ import { addCorsHeaders, buildCorsHeaders, isCorsDisabled } from './cors.js' import { createSupabaseContext } from './create-supabase-context.js' import type { SupabaseContext, WithSupabaseConfig } from './types.js' -import type { Entry } from '@supabase/web-middleware' +import { seedContext } from '@supabase/middleware' +import type { Entry } from '@supabase/middleware' type AnyEntry = Entry // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -9,8 +10,8 @@ type AnyHandler = (req: Request, ctx: any) => Promise /** * 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). + * `pipeline`'s internal `Accumulate`, seeded from `object` (the engine reserves + * no ctx keys; see implementation note below). */ type MiddlewareCtx = Entries extends readonly [ @@ -55,7 +56,7 @@ export function withSupabase( /** * Variant that accepts a `middleware` array — each `withFoo(config)` call - * returns an `Entry` from `@supabase/web-middleware`. Middleware run **after** + * returns an `Entry` from `@supabase/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 @@ -86,7 +87,7 @@ export function withSupabase( * (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. + * middleware array work normally via `@supabase/middleware`'s runtime chain. */ export function withSupabase< Database = unknown, @@ -135,19 +136,10 @@ export function withSupabase( const composed = ( 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 - // 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], - }, - }) + // seedContext() stamps the engine's context marker so middleware entries + // recognise this as an upstream context. Env access happens through the + // engine's importable getEnv — no per-ctx facet to bridge. + response = await composed(req, { ...seedContext(), ...ctx }) } else { response = await handler(req, ctx as object) } From a6971cdc9df89db7d95113c87e590613d763134c Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Tue, 21 Jul 2026 14:35:09 +0300 Subject: [PATCH 07/12] fix: add explicit Middleware types to withClaims/withPostgres exports JSR's slow-types check requires explicit types on public API symbols; the inferred defineMiddleware return type failed 'Verify JSR packaging'. Co-Authored-By: Claude Fable 5 --- src/middleware/claims/index.ts | 19 +++++++++++-------- src/middleware/postgres/index.ts | 8 +++++++- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/middleware/claims/index.ts b/src/middleware/claims/index.ts index fd003ca..1df9da2 100644 --- a/src/middleware/claims/index.ts +++ b/src/middleware/claims/index.ts @@ -1,4 +1,5 @@ import { defineMiddleware } from '@supabase/middleware' +import type { Middleware } from '@supabase/middleware' /** * Loosely-typed JWT claims contributed by {@link withClaims}. @@ -42,16 +43,18 @@ function decodeJwtPayload(token: string): JwtClaims | null { * * @category Middleware */ -export const withClaims = defineMiddleware< +export const withClaims: Middleware< '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 } +> = 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.ts b/src/middleware/postgres/index.ts index 4547ff0..8e4e05e 100644 --- a/src/middleware/postgres/index.ts +++ b/src/middleware/postgres/index.ts @@ -1,4 +1,5 @@ import { defineMiddleware, getEnv } from '@supabase/middleware' +import type { Middleware } from '@supabase/middleware' import pg from 'pg' const { Pool } = pg @@ -81,7 +82,12 @@ export interface WithPostgresConfig { * * @category Middleware */ -export const withPostgres = defineMiddleware< +export const withPostgres: Middleware< + 'postgres', + WithPostgresConfig | void, + { jwtClaims: RequestClaims | null }, + PostgresApi +> = defineMiddleware< 'postgres', WithPostgresConfig | void, { jwtClaims: RequestClaims | null }, From 5df5c34a4a3d9c6590f65b60c7c4da9e34336abc Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Tue, 21 Jul 2026 14:37:16 +0300 Subject: [PATCH 08/12] chore: rename allowBuilds key to @supabase/middleware Co-Authored-By: Claude Fable 5 --- pnpm-workspace.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a5de064..5c65c3b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,7 +7,7 @@ minimumReleaseAgeExclude: - '@esbuild/*' blockExoticSubdeps: true allowBuilds: - '@supabase/web-middleware': true + '@supabase/middleware': true '@nestjs/core': false '@swc/core': false esbuild: false From 246e945c8a973c229f6157a7acf75548f68b77f7 Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Tue, 11 Aug 2026 11:20:49 +0300 Subject: [PATCH 09/12] chore: adopt @supabase/middleware 0.3.0 from npm Replaces the pkg.pr.new preview build with the released package. Co-Authored-By: Claude Fable 5 --- package.json | 2 +- pnpm-lock.yaml | 29 ++++++++++++++--------------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index 7b01544..fdac5bb 100644 --- a/package.json +++ b/package.json @@ -202,7 +202,7 @@ "vitest": "^4.1.0" }, "dependencies": { - "@supabase/middleware": "https://pkg.pr.new/supabase/middleware/@supabase/middleware@0641674", + "@supabase/middleware": "^0.3.0", "jose": "^6.2.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9a5d5a..8b92054 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: .: dependencies: '@supabase/middleware': - specifier: https://pkg.pr.new/supabase/middleware/@supabase/middleware@0641674 - version: https://pkg.pr.new/supabase/middleware/@supabase/middleware@0641674 + specifier: ^0.3.0 + version: 0.3.0 jose: specifier: ^6.2.0 version: 6.2.0 @@ -860,10 +860,9 @@ packages: resolution: {integrity: sha512-ADIkJYH5w7HbnGVAAlCbyKoLF5QdfyezBLfYXpUqhxZOacK6YepOvnP/8p4p+50bhTPWp6VhDxu19KO7e/qU2g==} engines: {node: '>=20.0.0'} - '@supabase/middleware@https://pkg.pr.new/supabase/middleware/@supabase/middleware@0641674': - resolution: {integrity: sha512-BVlmrVAJRzvMuwe1l4ldxHmBo5L1C4fWq1OSv8FtlzYRDVnIW2nZC0qxo9okk5Y4z5Cc5PW1veKvMjJMhobm3w==, tarball: https://pkg.pr.new/supabase/middleware/@supabase/middleware@0641674} - version: 0.1.0 - engines: {node: '>=20'} + '@supabase/middleware@0.3.0': + resolution: {integrity: sha512-JN+dUr7Fyx96jfCUEXpzPEIpmkkogxHfII+fx7wWIiAUFI8C0lObDlzKIqrSVEKiFEK6CMsHWcqRarBQ8azCtw==} + engines: {node: '>=22'} '@supabase/phoenix@0.4.2': resolution: {integrity: sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==} @@ -2020,8 +2019,8 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - nanoid@3.3.18: - resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + nanoid@3.3.17: + resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -2163,8 +2162,8 @@ packages: resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} hasBin: true - postcss@8.5.26: - resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + postcss@8.5.25: + resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} engines: {node: ^10 || ^12 || >=14} postgres-array@2.0.0: @@ -3410,7 +3409,7 @@ snapshots: dependencies: tslib: 2.8.1 - '@supabase/middleware@https://pkg.pr.new/supabase/middleware/@supabase/middleware@0641674': + '@supabase/middleware@0.3.0': dependencies: std-env: 4.2.0 @@ -4571,7 +4570,7 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 - nanoid@3.3.18: {} + nanoid@3.3.17: {} natural-compare@1.4.0: {} @@ -4709,9 +4708,9 @@ snapshots: sonic-boom: 4.2.1 thread-stream: 4.2.0 - postcss@8.5.26: + postcss@8.5.25: dependencies: - nanoid: 3.3.18 + nanoid: 3.3.17 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -5221,7 +5220,7 @@ snapshots: esbuild: 0.28.2 fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 - postcss: 8.5.26 + postcss: 8.5.25 rollup: 4.62.4 tinyglobby: 0.2.17 optionalDependencies: From 1e2c020a7e323903c7504fdddd45196c290932c9 Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Tue, 11 Aug 2026 11:21:12 +0300 Subject: [PATCH 10/12] feat(middleware): verify withClaims against the project JWKS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit withClaims graduates from the demo-grade payload decoder to real verification: the user-mode JWT leg of verifyCredentials moves into a shared verifyUserJwt core (JWKS resolver caching, HS256 shared-secret path, sb_* passthrough), used by both. No decode-only mode remains — an invalid token short-circuits 401, a missing JWKS 500. Co-Authored-By: Claude Fable 5 --- src/core/resolve-env.ts | 2 +- src/core/verify-credentials.ts | 118 +++--------------------- src/core/verify-user-jwt.ts | 122 +++++++++++++++++++++++++ src/middleware/claims/index.test.ts | 137 ++++++++++++++++++++-------- src/middleware/claims/index.ts | 122 +++++++++++++++++-------- 5 files changed, 318 insertions(+), 183 deletions(-) create mode 100644 src/core/verify-user-jwt.ts diff --git a/src/core/resolve-env.ts b/src/core/resolve-env.ts index 7d4bcaa..ac6f482 100644 --- a/src/core/resolve-env.ts +++ b/src/core/resolve-env.ts @@ -126,7 +126,7 @@ function parseJwksUrl(raw: string | undefined): URL | null { * * @internal */ -function resolveJwks(): JSONWebKeySet | URL | null { +export function resolveJwks(): JSONWebKeySet | URL | null { const rawJwks = getEnvVar('SUPABASE_JWKS') if (rawJwks && rawJwks.trim()) { return parseJwks(rawJwks) diff --git a/src/core/verify-credentials.ts b/src/core/verify-credentials.ts index 3f2f735..085f31f 100644 --- a/src/core/verify-credentials.ts +++ b/src/core/verify-credentials.ts @@ -1,27 +1,17 @@ -import { - createLocalJWKSet, - createRemoteJWKSet, - decodeProtectedHeader, - importJWK, - JSONWebKeySet, - JWTPayload, - jwtVerify, - type JWTVerifyGetKey, -} from 'jose' - import { AuthError, Errors, InvalidCredentialsError } from '../errors.js' import type { AuthMode, AuthModeWithKey, AuthResult, Credentials, - JWTClaims, SupabaseEnv, - UserClaims, } from '../types.js' import { resolveEnv } from './resolve-env.js' import { resolveAuthOption } from './utils/deprecation.js' import { timingSafeEqual } from './utils/timing-safe-equal.js' +import { verifyUserJwt } from './verify-user-jwt.js' + +export type { JwksResolver } from './verify-user-jwt.js' /** * Options for {@link verifyCredentials}. @@ -79,63 +69,8 @@ function parseAuthMode(mode: AuthModeWithKey): { return { base, keyName } } -/** - * Converts raw {@link JWTClaims} (snake_case) to a normalized {@link UserClaims} (camelCase). - * @internal - */ -function jwtClaimsToUserClaims(jwtClaims: JWTClaims): UserClaims { - return { - id: jwtClaims.sub, - role: jwtClaims.role, - email: jwtClaims.email, - appMetadata: jwtClaims.app_metadata, - userMetadata: jwtClaims.user_metadata, - } -} - const INVALID = Symbol('invalid') -/** - * A JWKS key resolver with an accessor for the cached key set. - * @category Primitives - */ -export type JwksResolver = JWTVerifyGetKey & { - jwks: () => JSONWebKeySet | undefined -} -let remoteJwksResolver: { url: string; resolver: JwksResolver } | undefined = - undefined - -/** - * Returns a key resolver for the given JWKS source. - * - * For a {@link URL}, the underlying `createRemoteJWKSet` resolver is cached - * across requests so `jose`'s built-in cooldown / max-age caching is - * preserved. Local JWKS objects are wrapped on every call — they're trivially - * cheap and the object identity may change across requests. - * - * @internal - */ -function getJwksResolver(jwks: JSONWebKeySet | URL): JwksResolver { - if (jwks instanceof URL) { - const url = jwks.toString() - if (remoteJwksResolver?.url !== url) { - remoteJwksResolver = { url, resolver: createRemoteJWKSet(jwks) } - } - return remoteJwksResolver.resolver - } - - const localJwkSet = createLocalJWKSet(jwks) - function localJwtVerifyGetKey(...args: Parameters) { - return localJwkSet(...args) - } - - const localJwksResolver: JwksResolver = Object.assign(localJwtVerifyGetKey, { - jwks: () => jwks, - }) - - return localJwksResolver -} - /** * Attempts to authenticate credentials against a single auth mode. * @@ -235,46 +170,17 @@ async function tryMode( // JWT verification. if (credentials.token.startsWith('sb_')) return null if (!env.jwks) return null - try { - const jwkResolver = getJwksResolver(env.jwks) - const { alg, kid } = decodeProtectedHeader(credentials.token) - if (!alg || !kid) { - return INVALID - } - - let payload: JWTPayload | null = null - - // Symmetric algorithm requires importing the shared secret - if (alg === 'HS256') { - const jwk = jwkResolver - .jwks() - ?.keys.find((key) => key.alg === alg && key.kid === kid) - if (!jwk) { - return INVALID - } - const sharedSecret = await importJWK(jwk, 'HS256') - - const verify = await jwtVerify(credentials.token, sharedSecret) - payload = verify.payload - } else { - const verify = await jwtVerify(credentials.token, jwkResolver) - payload = verify.payload - } - - if (typeof payload.sub !== 'string') { - return INVALID - } - const jwtClaims = payload as unknown as JWTClaims - return { - authMode: 'user', - token: credentials.token, - userClaims: jwtClaimsToUserClaims(jwtClaims), - jwtClaims, - keyName: null, - } - } catch { + const verified = await verifyUserJwt(credentials.token, env.jwks) + if (!verified) { return INVALID } + return { + authMode: 'user', + token: credentials.token, + userClaims: verified.userClaims, + jwtClaims: verified.jwtClaims, + keyName: null, + } } default: diff --git a/src/core/verify-user-jwt.ts b/src/core/verify-user-jwt.ts new file mode 100644 index 0000000..ecc54a3 --- /dev/null +++ b/src/core/verify-user-jwt.ts @@ -0,0 +1,122 @@ +import { + createLocalJWKSet, + createRemoteJWKSet, + decodeProtectedHeader, + importJWK, + JSONWebKeySet, + jwtVerify, + type JWTPayload, + type JWTVerifyGetKey, +} from 'jose' + +import type { JWTClaims, UserClaims } from '../types.js' + +/** + * Converts raw {@link JWTClaims} (snake_case) to a normalized {@link UserClaims} (camelCase). + * @internal + */ +export function jwtClaimsToUserClaims(jwtClaims: JWTClaims): UserClaims { + return { + id: jwtClaims.sub, + role: jwtClaims.role, + email: jwtClaims.email, + appMetadata: jwtClaims.app_metadata, + userMetadata: jwtClaims.user_metadata, + } +} + +/** + * A JWKS key resolver with an accessor for the cached key set. + * @category Primitives + */ +export type JwksResolver = JWTVerifyGetKey & { + jwks: () => JSONWebKeySet | undefined +} + +let remoteJwksResolver: { url: string; resolver: JwksResolver } | undefined = + undefined + +/** + * Returns a key resolver for the given JWKS source. + * + * For a {@link URL}, the underlying `createRemoteJWKSet` resolver is cached + * across requests so `jose`'s built-in cooldown / max-age caching is + * preserved. Local JWKS objects are wrapped on every call — they're trivially + * cheap and the object identity may change across requests. + * + * @internal + */ +function getJwksResolver(jwks: JSONWebKeySet | URL): JwksResolver { + if (jwks instanceof URL) { + const url = jwks.toString() + if (remoteJwksResolver?.url !== url) { + remoteJwksResolver = { url, resolver: createRemoteJWKSet(jwks) } + } + return remoteJwksResolver.resolver + } + + const localJwkSet = createLocalJWKSet(jwks) + function localJwtVerifyGetKey(...args: Parameters) { + return localJwkSet(...args) + } + + const localJwksResolver: JwksResolver = Object.assign(localJwtVerifyGetKey, { + jwks: () => jwks, + }) + + return localJwksResolver +} + +/** + * Verifies a user JWT against the project JWKS — the single verification core + * shared by `verifyCredentials`'s `user` mode and the `withClaims` middleware. + * + * Handles both asymmetric keys (resolved through the JWKS) and the `HS256` + * shared-secret case (imported from the matching JWK). A payload without a + * string `sub` is rejected — a user token always identifies a subject. + * + * @param token - The bearer token to verify. + * @param jwks - JWKS source: an inline key set or a remote JWKS URL. + * @returns The decoded claims on success, `null` when verification fails. + * + * @internal + */ +export async function verifyUserJwt( + token: string, + jwks: JSONWebKeySet | URL, +): Promise<{ jwtClaims: JWTClaims; userClaims: UserClaims } | null> { + try { + const jwkResolver = getJwksResolver(jwks) + const { alg, kid } = decodeProtectedHeader(token) + if (!alg || !kid) { + return null + } + + let payload: JWTPayload | null = null + + // Symmetric algorithm requires importing the shared secret + if (alg === 'HS256') { + const jwk = jwkResolver + .jwks() + ?.keys.find((key) => key.alg === alg && key.kid === kid) + if (!jwk) { + return null + } + const sharedSecret = await importJWK(jwk, 'HS256') + + const verify = await jwtVerify(token, sharedSecret) + payload = verify.payload + } else { + const verify = await jwtVerify(token, jwkResolver) + payload = verify.payload + } + + if (typeof payload.sub !== 'string') { + return null + } + const jwtClaims = payload as unknown as JWTClaims + return { jwtClaims, userClaims: jwtClaimsToUserClaims(jwtClaims) } + } catch { + return null + } +} diff --git a/src/middleware/claims/index.test.ts b/src/middleware/claims/index.test.ts index e106fd8..e29de7d 100644 --- a/src/middleware/claims/index.test.ts +++ b/src/middleware/claims/index.test.ts @@ -1,64 +1,129 @@ -import { describe, expect, it } from 'vitest' +import { exportJWK, generateKeyPair, generateSecret, SignJWT } from 'jose' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import type { JSONWebKeySet } from 'jose' + +import { InvalidCredentialsError } from '../../errors.js' import { withClaims } from './index.js' -function base64url(obj: unknown): string { - return Buffer.from(JSON.stringify(obj)) - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, '') -} +describe('withClaims', () => { + let jwks: JSONWebKeySet + let rsToken: string + let hsToken: string + let foreignToken: string -function tokenFor(claims: Record): string { - return `header.${base64url(claims)}.sig` -} + beforeAll(async () => { + // Asymmetric JWK + const { privateKey, publicKey } = await generateKeyPair('RS256') + const publicJwk = await exportJWK(publicKey) + publicJwk.alg = 'RS256' + publicJwk.use = 'sig' + publicJwk.kid = 'asymmetric-key-id' -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 }) + // Symmetric Shared Secret JWK + const jwtSecret = await generateSecret('HS256', { extractable: true }) + const symmetricJwk = await exportJWK(jwtSecret) + symmetricJwk.alg = 'HS256' + symmetricJwk.kid = 'symmetric-shared-secret-key-id' + + jwks = { keys: [publicJwk, symmetricJwk] } + + const signWith = ( + key: CryptoKey | Uint8Array, + alg: string, + kid: string, + ) => + new SignJWT({ sub: 'user-123', role: 'authenticated' }) + .setProtectedHeader({ alg, kid }) + .setIssuedAt() + .setExpirationTime('1h') + .sign(key) + + rsToken = await signWith(privateKey, 'RS256', publicJwk.kid!) + hsToken = await signWith(jwtSecret, 'HS256', symmetricJwk.kid!) + + // Signed by a key that is NOT in the JWKS — verification must fail. + const { privateKey: foreignKey } = await generateKeyPair('RS256') + foreignToken = await signWith(foreignKey, 'RS256', publicJwk.kid!) + }) + + function requestWithToken(token?: string): Request { + return new Request('http://localhost', { + headers: token ? { Authorization: `Bearer ${token}` } : {}, }) + } - // Called bare, the way a runtime invokes a fetch entry — no prerequisites. - await handler( - new Request('http://localhost', { - headers: { - Authorization: `Bearer ${tokenFor({ sub: 'u1', role: 'authenticated' })}`, - }, - }), + it('contributes JWKS-verified claims for a valid token', async () => { + for (const token of [() => rsToken, () => hsToken]) { + let seen: unknown + const handler = withClaims({ jwks }, async (_req, ctx) => { + seen = ctx.jwtClaims + return Response.json({ ok: true }) + }) + + const res = await handler(requestWithToken(token())) + expect(res.status).toBe(200) + expect(seen).toMatchObject({ sub: 'user-123', role: 'authenticated' }) + } + }) + + it('short-circuits 401 for a token signed by an unknown key', async () => { + const handler = withClaims({ jwks }, async () => + Response.json({ ok: true }), ) - expect(seen).toEqual({ sub: 'u1', role: 'authenticated' }) + const res = await handler(requestWithToken(foreignToken)) + expect(res.status).toBe(401) + const body = await res.json() + expect(body.code).toBe(InvalidCredentialsError) + }) + + it('short-circuits 401 for a malformed token', async () => { + const handler = withClaims({ jwks }, async () => + Response.json({ ok: true }), + ) + + const res = await handler(requestWithToken('not-a-jwt')) + expect(res.status).toBe(401) }) it('contributes null when no Authorization header is present', async () => { let seen: unknown = 'unset' - const handler = withClaims(async (_req, ctx) => { + const handler = withClaims({ jwks }, async (_req, ctx) => { seen = ctx.jwtClaims return Response.json({ ok: true }) }) - await handler(new Request('http://localhost')) - + // Called bare, the way a runtime invokes a fetch entry — no prerequisites. + const res = await handler(requestWithToken()) + expect(res.status).toBe(200) expect(seen).toBeNull() }) - it('contributes null for a malformed token', async () => { + it('contributes null for an sb_* secret in the Authorization header', async () => { let seen: unknown = 'unset' - const handler = withClaims(async (_req, ctx) => { + const handler = withClaims({ jwks }, async (_req, ctx) => { seen = ctx.jwtClaims return Response.json({ ok: true }) }) - await handler( - new Request('http://localhost', { - headers: { Authorization: 'Bearer not-a-jwt' }, - }), - ) - + const res = await handler(requestWithToken('sb_secret_xyz')) + expect(res.status).toBe(200) expect(seen).toBeNull() }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('short-circuits 500 when a token is present but no JWKS is configured', async () => { + vi.stubEnv('SUPABASE_JWKS', '') + vi.stubEnv('SUPABASE_JWKS_URL', '') + const handler = withClaims(async () => Response.json({ ok: true })) + + const res = await handler(requestWithToken(rsToken)) + expect(res.status).toBe(500) + const body = await res.json() + expect(body.message).toContain('JWKS') + }) }) diff --git a/src/middleware/claims/index.ts b/src/middleware/claims/index.ts index 1df9da2..9fd8cbf 100644 --- a/src/middleware/claims/index.ts +++ b/src/middleware/claims/index.ts @@ -1,60 +1,102 @@ import { defineMiddleware } from '@supabase/middleware' import type { Middleware } from '@supabase/middleware' +import type { JSONWebKeySet } from 'jose' + +import { extractCredentials } from '../../core/extract-credentials.js' +import { resolveJwks } from '../../core/resolve-env.js' +import { verifyUserJwt } from '../../core/verify-user-jwt.js' +import { EnvGenericError, InvalidCredentialsError } from '../../errors.js' +import type { JWTClaims } from '../../types.js' /** - * Loosely-typed JWT claims contributed by {@link withClaims}. + * Configuration for {@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 - } +export interface WithClaimsConfig { + /** + * JWKS source used to verify tokens: an inline key set or a remote JWKS + * URL. Defaults to `SUPABASE_JWKS` (inline JSON) or `SUPABASE_JWKS_URL` + * (https endpoint) from the environment. + */ + jwks?: JSONWebKeySet | URL } /** - * Contributes `ctx.jwtClaims` by decoding the caller's Bearer token. + * Contributes `ctx.jwtClaims` by verifying the caller's Bearer token against + * the project JWKS — the same verification core `withSupabase` uses for its + * `user` auth mode. * - * Use this only when composing a standalone `pipeline([...], handler)` that is + * Use this 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. + * Function that still wants the caller's verified claims available to a + * downstream middleware such as `withPostgres`. Inside `withSupabase`, the + * context already carries `jwtClaims`, so `withClaims` is unnecessary. + * + * Behavior: + * - No `Authorization: Bearer` token (or an `sb_*` API key in that position) + * → contributes `null`; the request proceeds as anonymous. + * - Token present but invalid → short-circuits with a 401 JSON response + * (`{ message, code }`, matching `withSupabase`'s error shape). + * - Token present but no JWKS configured → short-circuits with a 500 — + * verification is not optional; there is no decode-only mode. + * + * @example Standalone pipeline + * ```ts + * import { pipeline } from '@supabase/middleware' + * import { withClaims } from '@supabase/server/middleware/claims' + * import { withPostgres } from '@supabase/server/middleware/postgres' * - * > **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. + * export default { + * fetch: pipeline([withClaims(), withPostgres()], async (req, ctx) => { + * const rows = await ctx.postgres.query('select id, title from posts') + * return Response.json({ rows, caller: ctx.jwtClaims?.sub ?? 'anon' }) + * }), + * } + * ``` * * @category Middleware */ export const withClaims: Middleware< 'jwtClaims', - void, + WithClaimsConfig | void, Record, - JwtClaims | null -> = 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 } - }, + JWTClaims | null +> = defineMiddleware< + 'jwtClaims', + WithClaimsConfig | void, + Record, + JWTClaims | null +>({ + key: 'jwtClaims', + run: (config) => async (req) => { + const { token } = extractCredentials(req) + // `sb_*` secrets ride the Authorization header alongside the apikey + // header — they are API keys, not user JWTs. + if (!token || token.startsWith('sb_')) { + return { jwtClaims: null } + } + + const jwks = config?.jwks ?? resolveJwks() + if (!jwks) { + return Response.json( + { + message: + 'A JWKS source is required to verify claims. Set SUPABASE_JWKS or SUPABASE_JWKS_URL, or pass `jwks` to withClaims.', + code: EnvGenericError, + }, + { status: 500 }, + ) + } + + const verified = await verifyUserJwt(token, jwks) + if (!verified) { + return Response.json( + { message: 'Invalid credentials', code: InvalidCredentialsError }, + { status: 401 }, + ) + } + + return { jwtClaims: verified.jwtClaims } }, -) +}) From 4147572bb210e5c5a6c86b6449d73233de6af40b Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Tue, 11 Aug 2026 11:21:28 +0300 Subject: [PATCH 11/12] feat(middleware): add client and admin-client entrypoints withSupabaseClient contributes ctx.supabase (RLS-scoped, caller's token) and withSupabaseAdminClient contributes ctx.supabaseAdmin, wrapping the existing createContextClient / createAdminClient primitives. Composed under withSupabase they read the seeded authMode / authKeyName to mirror verified credentials exactly; standalone they work as plain engine entries. Co-Authored-By: Claude Fable 5 --- jsr.json | 13 +-- package.json | 20 +++++ src/middleware/admin-client/index.test.ts | 66 +++++++++++++++ src/middleware/admin-client/index.ts | 87 ++++++++++++++++++++ src/middleware/client/index.test.ts | 76 +++++++++++++++++ src/middleware/client/index.ts | 99 +++++++++++++++++++++++ tsdown.config.ts | 2 + 7 files changed, 354 insertions(+), 9 deletions(-) create mode 100644 src/middleware/admin-client/index.test.ts create mode 100644 src/middleware/admin-client/index.ts create mode 100644 src/middleware/client/index.test.ts create mode 100644 src/middleware/client/index.ts diff --git a/jsr.json b/jsr.json index bcea0fd..6bdb95e 100644 --- a/jsr.json +++ b/jsr.json @@ -9,18 +9,13 @@ "./adapters/h3": "./src/adapters/h3/index.ts", "./adapters/elysia": "./src/adapters/elysia/index.ts", "./adapters/nestjs": "./src/adapters/nestjs/index.ts", + "./middleware/client": "./src/middleware/client/index.ts", + "./middleware/admin-client": "./src/middleware/admin-client/index.ts", "./middleware/postgres": "./src/middleware/postgres/index.ts", "./middleware/claims": "./src/middleware/claims/index.ts" }, "publish": { - "include": [ - "src/**/*.ts", - "README.md", - "LICENSE" - ], - "exclude": [ - "src/**/*.test.ts", - "src/**/*.spec.ts" - ] + "include": ["src/**/*.ts", "README.md", "LICENSE"], + "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts"] } } diff --git a/package.json b/package.json index fdac5bb..4cd6eed 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,26 @@ "default": "./dist/adapters/nestjs/index.cjs" } }, + "./middleware/client": { + "import": { + "types": "./dist/middleware/client/index.d.mts", + "default": "./dist/middleware/client/index.mjs" + }, + "require": { + "types": "./dist/middleware/client/index.d.cts", + "default": "./dist/middleware/client/index.cjs" + } + }, + "./middleware/admin-client": { + "import": { + "types": "./dist/middleware/admin-client/index.d.mts", + "default": "./dist/middleware/admin-client/index.mjs" + }, + "require": { + "types": "./dist/middleware/admin-client/index.d.cts", + "default": "./dist/middleware/admin-client/index.cjs" + } + }, "./middleware/postgres": { "import": { "types": "./dist/middleware/postgres/index.d.mts", diff --git a/src/middleware/admin-client/index.test.ts b/src/middleware/admin-client/index.test.ts new file mode 100644 index 0000000..f752bee --- /dev/null +++ b/src/middleware/admin-client/index.test.ts @@ -0,0 +1,66 @@ +import { pipeline } from '@supabase/middleware' +import { describe, expect, it } from 'vitest' + +import type { SupabaseClient } from '@supabase/supabase-js' + +import { + EnvError, + MissingDefaultSecretKeyError, + MissingSecretKeyError, +} from '../../errors.js' +import { withSupabaseAdminClient } from './index.js' + +const baseEnv = { + url: 'https://test.supabase.co', + publishableKeys: { default: 'sb_publishable_xyz' }, + secretKeys: { default: 'sb_secret_xyz' }, + jwks: null, +} + +describe('withSupabaseAdminClient', () => { + it('contributes ctx.supabaseAdmin in a standalone pipeline', async () => { + let seen: SupabaseClient | undefined + const handler = pipeline( + [withSupabaseAdminClient({ env: baseEnv })], + async (_req, ctx) => { + seen = ctx.supabaseAdmin + return Response.json({ ok: true }) + }, + ) + + const res = await handler(new Request('http://localhost')) + expect(res.status).toBe(200) + expect(seen).toBeDefined() + expect(typeof seen!.from).toBe('function') + }) + + it("selects the matched secret key from an upstream withSupabase context's authKeyName", async () => { + const handler = pipeline([withSupabaseAdminClient({ env: baseEnv })], () => + Promise.resolve(Response.json({ ok: true })), + ) + + // authKeyName 'internal' is not in the key set — the throw proves the + // named key is what the middleware asked for. + await expect( + handler(new Request('http://localhost'), { + [Symbol.for('@supabase/middleware:context')]: true, + authMode: 'secret', + authKeyName: 'internal', + } as never), + ).rejects.toMatchObject({ code: MissingSecretKeyError }) + }) + + it('throws EnvError when no secret key exists', async () => { + const handler = pipeline( + [withSupabaseAdminClient({ env: { ...baseEnv, secretKeys: {} } })], + async () => Response.json({ ok: true }), + ) + + await expect( + handler(new Request('http://localhost')), + ).rejects.toMatchObject({ code: MissingDefaultSecretKeyError }) + await expect( + handler(new Request('http://localhost')), + ).rejects.toBeInstanceOf(EnvError) + }) +}) diff --git a/src/middleware/admin-client/index.ts b/src/middleware/admin-client/index.ts new file mode 100644 index 0000000..4ba3f57 --- /dev/null +++ b/src/middleware/admin-client/index.ts @@ -0,0 +1,87 @@ +import { defineMiddleware } from '@supabase/middleware' +import type { Entry } from '@supabase/middleware' +import type { SupabaseClient } from '@supabase/supabase-js' + +import { createAdminClient } from '../../core/create-admin-client.js' +import { CreateSupabaseClientError, EnvError, Errors } from '../../errors.js' +import type { AuthMode, CreateAdminClientOptions } from '../../types.js' + +/** + * Configuration for {@link withSupabaseAdminClient} — the same environment and + * client options `createAdminClient` accepts, minus the per-request auth + * identity (which is read from the upstream context). + * + * @category Middleware + */ +export type WithSupabaseAdminClientConfig = Omit< + CreateAdminClientOptions, + 'auth' +> + +/** Auth keys an upstream `withSupabase` seeds onto the context. @internal */ +interface UpstreamAuth { + authMode?: AuthMode + authKeyName?: string +} + +const base = defineMiddleware< + 'supabaseAdmin', + WithSupabaseAdminClientConfig | void, + Record, + SupabaseClient +>({ + key: 'supabaseAdmin', + run: (config) => async (_req, ctx) => { + const upstream = (ctx ?? {}) as UpstreamAuth + // Under `withSupabase`, use the secret key the request matched; standalone + // (or in other modes), the default secret key. + const keyName = + upstream.authMode === 'secret' ? upstream.authKeyName : undefined + + let supabaseAdmin: SupabaseClient + try { + supabaseAdmin = createAdminClient({ + auth: { keyName }, + env: config?.env, + supabaseOptions: config?.supabaseOptions, + }) + } catch (e) { + throw e instanceof EnvError ? e : Errors[CreateSupabaseClientError]() + } + return { supabaseAdmin } + }, +}) + +/** + * Contributes `ctx.supabaseAdmin` — an admin Supabase client that bypasses + * Row-Level Security, authenticated with a secret key. This is the same + * middleware `withSupabase` composes internally to build its context. + * + * @throws {@link index.EnvError} When `SUPABASE_URL` or the secret key is + * missing — composing wrappers (like `withSupabase`) map this to a 500 + * response; standalone pipelines see it as a thrown error. + * + * @example Standalone pipeline + * ```ts + * import { pipeline } from '@supabase/middleware' + * import { withSupabaseAdminClient } from '@supabase/server/middleware/admin-client' + * + * export default { + * fetch: pipeline([withSupabaseAdminClient()], async (req, ctx) => { + * await ctx.supabaseAdmin.from('audit_log').insert({ action: 'ping' }) + * return Response.json({ ok: true }) + * }), + * } + * ``` + * + * @category Middleware + */ +export function withSupabaseAdminClient( + config?: WithSupabaseAdminClientConfig, +): Entry<'supabaseAdmin', Record, SupabaseClient> { + return base(config) as unknown as Entry< + 'supabaseAdmin', + Record, + SupabaseClient + > +} diff --git a/src/middleware/client/index.test.ts b/src/middleware/client/index.test.ts new file mode 100644 index 0000000..22beaf5 --- /dev/null +++ b/src/middleware/client/index.test.ts @@ -0,0 +1,76 @@ +import { pipeline } from '@supabase/middleware' +import { describe, expect, it } from 'vitest' + +import type { SupabaseClient } from '@supabase/supabase-js' + +import { + EnvError, + MissingPublishableKeyError, + MissingSupabaseURLError, +} from '../../errors.js' +import { withSupabaseClient } from './index.js' + +const baseEnv = { + url: 'https://test.supabase.co', + publishableKeys: { default: 'sb_publishable_xyz', web: 'sb_publishable_web' }, + secretKeys: { default: 'sb_secret_xyz' }, + jwks: null, +} + +describe('withSupabaseClient', () => { + it('contributes ctx.supabase in a standalone pipeline', async () => { + let seen: SupabaseClient | undefined + const handler = pipeline( + [withSupabaseClient({ env: baseEnv })], + async (_req, ctx) => { + seen = ctx.supabase + return Response.json({ ok: true }) + }, + ) + + const res = await handler(new Request('http://localhost')) + expect(res.status).toBe(200) + expect(seen).toBeDefined() + expect(typeof seen!.from).toBe('function') + }) + + it("selects the matched publishable key from an upstream withSupabase context's authKeyName", async () => { + const handler = pipeline( + [ + withSupabaseClient({ + env: { + ...baseEnv, + publishableKeys: { default: 'sb_publishable_xyz' }, + }, + }), + ], + async () => Response.json({ ok: true }), + ) + + // authKeyName 'web' is not in the key set — the throw proves the named + // key is what the middleware asked for. + await expect( + handler(new Request('http://localhost'), { + [Symbol.for('@supabase/middleware:context')]: true, + authMode: 'publishable', + authKeyName: 'web', + } as never), + ).rejects.toMatchObject({ code: MissingPublishableKeyError }) + }) + + it('throws EnvError when SUPABASE_URL is missing', async () => { + const handler = pipeline( + [withSupabaseClient({ env: { ...baseEnv, url: '' } })], + async () => Response.json({ ok: true }), + ) + + await expect( + handler(new Request('http://localhost')), + ).rejects.toMatchObject({ + code: MissingSupabaseURLError, + }) + await expect( + handler(new Request('http://localhost')), + ).rejects.toBeInstanceOf(EnvError) + }) +}) diff --git a/src/middleware/client/index.ts b/src/middleware/client/index.ts new file mode 100644 index 0000000..44a8e4b --- /dev/null +++ b/src/middleware/client/index.ts @@ -0,0 +1,99 @@ +import { defineMiddleware } from '@supabase/middleware' +import type { Entry } from '@supabase/middleware' +import type { SupabaseClient } from '@supabase/supabase-js' + +import { createContextClient } from '../../core/create-context-client.js' +import { extractCredentials } from '../../core/extract-credentials.js' +import { CreateSupabaseClientError, EnvError, Errors } from '../../errors.js' +import type { AuthMode, CreateContextClientOptions } from '../../types.js' + +/** + * Configuration for {@link withSupabaseClient} — the same environment and + * client options `createContextClient` accepts, minus the per-request auth + * identity (which is read from the request and the upstream context). + * + * @category Middleware + */ +export type WithSupabaseClientConfig = Omit + +/** Auth keys an upstream `withSupabase` seeds onto the context. @internal */ +interface UpstreamAuth { + authMode?: AuthMode + authKeyName?: string +} + +const base = defineMiddleware< + 'supabase', + WithSupabaseClientConfig | void, + Record, + SupabaseClient +>({ + key: 'supabase', + run: (config) => async (req, ctx) => { + const upstream = (ctx ?? {}) as UpstreamAuth + const { token: bearer } = extractCredentials(req) + // `sb_*` secrets ride the Authorization header alongside the apikey + // header — never attach them as a user token. + const rawToken = bearer && !bearer.startsWith('sb_') ? bearer : undefined + // Under `withSupabase`, mirror verified auth exactly: the bearer token is + // attached only when it was verified (`user` mode), and the publishable + // key is the one the request matched. Standalone, attach the raw bearer — + // PostgREST verifies it — and use the default publishable key. + const token = + upstream.authMode === undefined || upstream.authMode === 'user' + ? rawToken + : undefined + const keyName = + upstream.authMode === 'publishable' ? upstream.authKeyName : undefined + + let supabase: SupabaseClient + try { + supabase = createContextClient({ + auth: { token, keyName }, + env: config?.env, + supabaseOptions: config?.supabaseOptions, + }) + } catch (e) { + throw e instanceof EnvError ? e : Errors[CreateSupabaseClientError]() + } + return { supabase } + }, +}) + +/** + * Contributes `ctx.supabase` — a Supabase client scoped to the caller's + * identity, so Row-Level Security policies apply. This is the same middleware + * `withSupabase` composes internally to build its context. + * + * Standalone, the caller's Bearer token (when present) is attached unverified — + * PostgREST verifies it on every query. Compose {@link claims.withClaims} + * upstream when the pipeline itself needs verified claims. + * + * @throws {@link index.EnvError} When `SUPABASE_URL` or the publishable key is + * missing — composing wrappers (like `withSupabase`) map this to a 500 + * response; standalone pipelines see it as a thrown error. + * + * @example Standalone pipeline + * ```ts + * import { pipeline } from '@supabase/middleware' + * import { withSupabaseClient } from '@supabase/server/middleware/client' + * + * export default { + * fetch: pipeline([withSupabaseClient()], async (req, ctx) => { + * const { data } = await ctx.supabase.from('posts').select('id, title') + * return Response.json(data) + * }), + * } + * ``` + * + * @category Middleware + */ +export function withSupabaseClient( + config?: WithSupabaseClientConfig, +): Entry<'supabase', Record, SupabaseClient> { + return base(config) as unknown as Entry< + 'supabase', + Record, + SupabaseClient + > +} diff --git a/tsdown.config.ts b/tsdown.config.ts index 474f12b..d3e7282 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -11,6 +11,8 @@ export default defineConfig({ 'src/adapters/nestjs/index.ts', 'src/middleware/postgres/index.ts', 'src/middleware/claims/index.ts', + 'src/middleware/client/index.ts', + 'src/middleware/admin-client/index.ts', ], format: ['esm', 'cjs'], dts: true, From 39d89be48c02aa50aedbd0a50979cce87379d12b Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Tue, 11 Aug 2026 11:21:30 +0300 Subject: [PATCH 12/12] refactor: compose withSupabase on the engine clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit withSupabase now runs on @supabase/middleware for every request: the two public client middleware fold around the user's middleware array and handler, seeded with the verified auth identity via seedContext. The host's second fetch argument (Workers env) is forwarded so bindings reach getEnv. Public API, ctx keys, and error shapes are unchanged — client-construction failures keep their historical JSON responses (phase-guarded so handler throws still propagate), and the existing test suite passes unmodified as the parity proof. Co-Authored-By: Claude Fable 5 --- src/with-supabase.test.ts | 55 +++++++++++++++++- src/with-supabase.ts | 115 ++++++++++++++++++++++++++++---------- 2 files changed, 141 insertions(+), 29 deletions(-) diff --git a/src/with-supabase.test.ts b/src/with-supabase.test.ts index db26c9c..93c2e72 100644 --- a/src/with-supabase.test.ts +++ b/src/with-supabase.test.ts @@ -1,7 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { defineMiddleware } from '@supabase/middleware' +import { defineMiddleware, getEnv } from '@supabase/middleware' import { _resetAllowDeprecationWarned } from './core/utils/deprecation.js' +import { EnvError } from './errors.js' import { withSupabase } from './with-supabase.js' const baseEnv = { @@ -259,6 +260,31 @@ describe('withSupabase', () => { expect(await res.json()).toEqual({ a: true, b: true }) }) + it("forwards the host's second fetch argument to getEnv as platform env", async () => { + const withReadEnv = defineMiddleware< + 'bindingValue', + void, + Record, + string | undefined + >({ + key: 'bindingValue', + run: () => async () => ({ + bindingValue: getEnv('WITH_SUPABASE_TEST_BINDING'), + }), + }) + + const handler = withSupabase( + { auth: 'none', env: baseEnv, middleware: [withReadEnv()] }, + async (_req, ctx) => Response.json({ bindingValue: ctx.bindingValue }), + ) + + // Simulate a Workers-style invocation: fetch(request, env). + const res = await handler(new Request('http://localhost'), { + WITH_SUPABASE_TEST_BINDING: 'from-platform', + }) + expect(await res.json()).toEqual({ bindingValue: 'from-platform' }) + }) + it('CORS headers still apply when middleware are present', async () => { const withNoop = defineMiddleware< 'noop', @@ -280,6 +306,33 @@ describe('withSupabase', () => { }) }) + describe('client construction errors', () => { + it('maps client-construction EnvError to a 500 JSON response', async () => { + const handler = withSupabase( + { + auth: 'none', + env: { ...baseEnv, publishableKeys: {} }, + }, + async () => Response.json({ ok: true }), + ) + + const res = await handler(new Request('http://localhost')) + expect(res.status).toBe(500) + const body = await res.json() + expect(body.code).toBe('MISSING_DEFAULT_PUBLISHABLE_KEY') + }) + + it('lets EnvError thrown by the handler propagate instead of mapping it', async () => { + const handler = withSupabase({ auth: 'none', env: baseEnv }, async () => { + throw new EnvError('handler-level env failure') + }) + + await expect(handler(new Request('http://localhost'))).rejects.toThrow( + 'handler-level env failure', + ) + }) + }) + describe('allow → auth deprecation', () => { beforeEach(() => { _resetAllowDeprecationWarned() diff --git a/src/with-supabase.ts b/src/with-supabase.ts index 927f23e..f428cb4 100644 --- a/src/with-supabase.ts +++ b/src/with-supabase.ts @@ -1,5 +1,8 @@ import { addCorsHeaders, buildCorsHeaders, isCorsDisabled } from './cors.js' -import { createSupabaseContext } from './create-supabase-context.js' +import { verifyAuth } from './core/verify-auth.js' +import { AuthError, CreateSupabaseClientError, EnvError } from './errors.js' +import { withSupabaseAdminClient } from './middleware/admin-client/index.js' +import { withSupabaseClient } from './middleware/client/index.js' import type { SupabaseContext, WithSupabaseConfig } from './types.js' import { seedContext } from '@supabase/middleware' import type { Entry } from '@supabase/middleware' @@ -32,7 +35,10 @@ type MiddlewareCtx = * * @param config - Auth modes, CORS, and environment overrides. See {@link WithSupabaseConfig}. * @param handler - Receives the `Request` and a fully-initialized {@link SupabaseContext}. - * @returns A `(req: Request) => Promise` fetch handler. + * @returns A fetch handler. The optional second parameter is the host's + * platform argument (a Workers `env`, a Deno `ServeHandlerInfo`) — when the + * runtime supplies one, it is captured as the platform env behind + * `@supabase/middleware`'s `getEnv` for any composed middleware. * * @category Middleware * @@ -52,7 +58,7 @@ type MiddlewareCtx = export function withSupabase( config: WithSupabaseConfig & { middleware?: never }, handler: (req: Request, ctx: SupabaseContext) => Promise, -): (req: Request) => Promise +): (req: Request, platformArg?: unknown) => Promise /** * Variant that accepts a `middleware` array — each `withFoo(config)` call @@ -98,13 +104,37 @@ export function withSupabase< req: Request, ctx: SupabaseContext & MiddlewareCtx, ) => Promise, -): (req: Request) => Promise +): (req: Request, platformArg?: unknown) => Promise export function withSupabase( config: WithSupabaseConfig & { middleware?: readonly AnyEntry[] }, handler: AnyHandler, -): (req: Request) => Promise { - return async (req: Request) => { +): (req: Request, platformArg?: unknown) => Promise { + // withSupabase runs on the engine: the context clients are the same public + // middleware anyone can compose (`./middleware/client`, + // `./middleware/admin-client`), folded around the user's middleware and + // handler — the same fold as pipeline's reduceRight, but without calling + // pipeline() so we supply the seeded ctx ourselves. + const clientEntries: readonly AnyEntry[] = [ + withSupabaseClient({ + env: config.env, + supabaseOptions: config.supabaseOptions, + }) as AnyEntry, + withSupabaseAdminClient({ + env: config.env, + supabaseOptions: config.supabaseOptions, + }) as AnyEntry, + ] + // The user's middleware and handler fold once at wrap time. + const userComposed = (config.middleware ?? []).reduceRight( + (h, entry) => entry(h), + handler, + ) + + return async (req: Request, platformArg?: unknown) => { + const corsHeaders = () => + !isCorsDisabled(config.cors) ? buildCorsHeaders(config.cors) : {} + if (!isCorsDisabled(config.cors) && req.method === 'OPTIONS') { return new Response(null, { status: 204, @@ -112,36 +142,65 @@ export function withSupabase( }) } - const { data: ctx, error } = await createSupabaseContext( - req, - config, - ) + const { data: auth, error } = await verifyAuth(req, { + auth: config.auth, + allow: config.allow, + env: config.env, + }) if (error) { return Response.json( { message: error.message, code: error.code }, - { - status: error.status, - headers: !isCorsDisabled(config.cors) - ? buildCorsHeaders(config.cors) - : {}, - }, + { status: error.status, headers: corsHeaders() }, ) } + // Track whether the request has moved past client construction: only + // failures from the two client entries map to the historical JSON error + // responses — user middleware and handler throws propagate unchanged, + // exactly as before the rewrite. + let inClientPhase = true + const markUserPhase: AnyHandler = (r, ctx) => { + inClientPhase = false + return userComposed(r, ctx) + } + const composed = clientEntries.reduceRight( + (h, entry) => entry(h), + markUserPhase, + ) + let response: Response - 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.middleware as readonly AnyEntry[] - ).reduceRight((h, entry) => entry(h), handler) + try { // seedContext() stamps the engine's context marker so middleware entries - // recognise this as an upstream context. Env access happens through the - // engine's importable getEnv — no per-ctx facet to bridge. - response = await composed(req, { ...seedContext(), ...ctx }) - } else { - response = await handler(req, ctx as object) + // recognise this as an upstream context, and captures the host's second + // fetch argument (a Workers `env`, a Deno `ServeHandlerInfo`) as the + // platform env behind the engine's importable getEnv — without the + // forward, Workers bindings would be invisible to middleware. The + // verified auth identity is seeded alongside it; the client middleware + // read `authMode` / `authKeyName` to mirror the verified credentials. + response = await composed(req, { + ...seedContext(platformArg), + userClaims: auth.userClaims, + jwtClaims: auth.jwtClaims, + authMode: auth.authMode, + authKeyName: auth.keyName ?? undefined, + }) + } catch (e) { + // Client construction failures keep their historical response shape: + // EnvError (missing URL / keys) and the client middleware's + // CreateSupabaseClientError map to the same JSON errors + // createSupabaseContext produced. + const mapped = !inClientPhase + ? null + : e instanceof EnvError + ? new AuthError(e.message, e.code, 500) + : e instanceof AuthError && e.code === CreateSupabaseClientError + ? e + : null + if (!mapped) throw e + return Response.json( + { message: mapped.message, code: mapped.code }, + { status: mapped.status, headers: corsHeaders() }, + ) } if (!isCorsDisabled(config.cors)) {