From b0b4e89e79b5a9137919dae31171cc53776ab582 Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Wed, 1 Jul 2026 19:32:07 +0300 Subject: [PATCH 1/3] 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 a3ccde0..9ad2bc4 100644 --- a/package.json +++ b/package.json @@ -171,6 +171,7 @@ "@arethetypeswrong/cli": "^0.18.4" }, "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 445d55f..c0b533b 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 @@ -857,6 +860,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'} @@ -3333,6 +3341,8 @@ snapshots: '@supabase/realtime-js': 2.106.0 '@supabase/storage-js': 2.106.0 + '@supabase/web-middleware@https://pkg.pr.new/supabase/web-middleware/@supabase/web-middleware@9': {} + '@swc/core-darwin-arm64@1.15.33': optional: true diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d474d52..a5de064 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,6 +7,7 @@ minimumReleaseAgeExclude: - '@esbuild/*' blockExoticSubdeps: true allowBuilds: + '@supabase/web-middleware': true '@nestjs/core': false '@swc/core': false esbuild: false diff --git a/src/with-supabase.test.ts b/src/with-supabase.test.ts index 8eec024..3d0237e 100644 --- a/src/with-supabase.test.ts +++ b/src/with-supabase.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defineMiddleware } from '@supabase/web-middleware' import { _resetAllowDeprecationWarned } from './core/utils/deprecation.js' import { withSupabase } from './with-supabase.js' @@ -98,6 +99,127 @@ describe('withSupabase', () => { expect(res.headers.get('Access-Control-Allow-Origin')).toBeNull() }) + describe('plugins', () => { + it('composes plugins after the Supabase context is established', async () => { + const withFlag = defineMiddleware< + 'flag', + void, + Record, + boolean + >({ + key: 'flag', + run: () => async () => ({ flag: true }), + }) + + const handler = withSupabase( + { auth: 'none', env: baseEnv, plugins: [withFlag()] }, + async (_req, ctx) => + Response.json({ authMode: ctx.authMode, flag: ctx.flag }), + ) + + const res = await handler(new Request('http://localhost')) + const body = await res.json() + expect(body.authMode).toBe('none') + expect(body.flag).toBe(true) + }) + + it('plugin receives the Supabase context at runtime', async () => { + let capturedHasSupabase = false + + const withCapture = defineMiddleware< + 'captured', + void, + Record, + true + >({ + key: 'captured', + run: () => async (_req, ctx) => { + capturedHasSupabase = !!(ctx as { supabase?: unknown }).supabase + return { captured: true as const } + }, + }) + + const handler = withSupabase( + { auth: 'none', env: baseEnv, plugins: [withCapture()] }, + async () => Response.json({ ok: true }), + ) + + await handler(new Request('http://localhost')) + expect(capturedHasSupabase).toBe(true) + }) + + it('plugin can short-circuit before the handler', async () => { + const withBlock = defineMiddleware< + 'blocked', + void, + Record, + true + >({ + key: 'blocked', + run: () => async () => new Response('blocked', { status: 403 }), + }) + + const innerHandler = vi.fn(async () => Response.json({ ok: true })) + + const handler = withSupabase( + { auth: 'none', env: baseEnv, plugins: [withBlock()] }, + innerHandler, + ) + + const res = await handler(new Request('http://localhost')) + expect(res.status).toBe(403) + expect(innerHandler).not.toHaveBeenCalled() + }) + + it('plugins run in array order (first = outermost, runs first on request)', async () => { + const order: string[] = [] + + const withA = defineMiddleware<'a', void, Record, true>({ + key: 'a', + run: () => async () => { + order.push('a') + return { a: true as const } + }, + }) + const withB = defineMiddleware<'b', void, Record, true>({ + key: 'b', + run: () => async () => { + order.push('b') + return { b: true as const } + }, + }) + + const handler = withSupabase( + { auth: 'none', env: baseEnv, plugins: [withA(), withB()] }, + async (_req, ctx) => Response.json({ a: ctx.a, b: ctx.b }), + ) + + const res = await handler(new Request('http://localhost')) + expect(order).toEqual(['a', 'b']) + expect(await res.json()).toEqual({ a: true, b: true }) + }) + + it('CORS headers still apply when plugins are present', async () => { + const withNoop = defineMiddleware< + 'noop', + void, + Record, + true + >({ + key: 'noop', + run: () => async () => ({ noop: true as const }), + }) + + const handler = withSupabase( + { auth: 'none', env: baseEnv, plugins: [withNoop()] }, + async () => Response.json({ ok: true }), + ) + + const res = await handler(new Request('http://localhost')) + expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*') + }) + }) + describe('allow → auth deprecation', () => { beforeEach(() => { _resetAllowDeprecationWarned() diff --git a/src/with-supabase.ts b/src/with-supabase.ts index f9c103c..b467f27 100644 --- a/src/with-supabase.ts +++ b/src/with-supabase.ts @@ -1,6 +1,26 @@ import { buildCorsHeaders, addCorsHeaders } from './cors.js' import { createSupabaseContext } from './create-supabase-context.js' import type { SupabaseContext, WithSupabaseConfig } from './types.js' +import type { Entry } from '@supabase/web-middleware' + +type AnyEntry = Entry +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyHandler = (req: Request, ctx: any) => Promise + +/** + * Accumulate the ctx contributions of a plugin tuple — same logic as + * `pipeline`'s internal `Accumulate`, seeded from `object` (no `BaseContext` + * or `_runtime` in the visible ctx type; see implementation note below). + */ +type PluginsCtx = + Plugins extends readonly [ + Entry, + ...infer Rest, + ] + ? Rest extends readonly AnyEntry[] + ? { [P in Key]: Contribution } & PluginsCtx + : { [P in Key]: Contribution } + : object /** * Wraps a request handler with Supabase auth, client creation, and CORS handling. @@ -19,6 +39,7 @@ import type { SupabaseContext, WithSupabaseConfig } from './types.js' * ```ts * import { withSupabase } from '@supabase/server' * + * // Without plugins — existing API, unchanged. * export default { * fetch: withSupabase({ auth: 'user' }, async (req, ctx) => { * const { data } = await ctx.supabase.rpc('get_my_profile') @@ -30,6 +51,55 @@ import type { SupabaseContext, WithSupabaseConfig } from './types.js' export function withSupabase( config: WithSupabaseConfig, handler: (req: Request, ctx: SupabaseContext) => Promise, +): (req: Request) => Promise + +/** + * Variant that accepts a `plugins` array — each `withFoo(config)` call returns + * an `Entry` from `@supabase/web-middleware`. Plugins run **after** the Supabase + * context is established; they receive `ctx.supabase`, `ctx.userClaims`, etc. + * already present and contribute their own typed keys on top. + * + * @example + * ```ts + * import { withSupabase } from '@supabase/server' + * import { withGuestbook } from '@supabase/plugin-guestbook/server' + * import { withRateLimit } from '@supabase/plugin-rate-limit/server' + * + * export default { + * fetch: withSupabase( + * { auth: 'user', plugins: [withRateLimit({ rpm: 100 }), withGuestbook()] }, + * async (req, ctx) => { + * ctx.supabase // from @supabase/server + * ctx.rateLimit // from withRateLimit + * ctx.guestbook // from withGuestbook + * return Response.json(await ctx.guestbook.list()) + * }, + * ), + * } + * ``` + * + * **Type note.** `PluginsCtx` accumulates the key contributions of the + * plugins array. Plugins that declare `In` prerequisites on Supabase-provided + * keys (`supabase`, `userClaims`, …) satisfy those at runtime (the Supabase + * context is merged before plugins run) but not at the type level — a full + * implementation would widen the prerequisite-validation seed to include + * `SupabaseContext`. Ordering and collision checks within the plugins array work + * normally via `web-middleware`'s runtime chain. + */ +export function withSupabase< + Database = unknown, + const Plugins extends readonly AnyEntry[] = readonly AnyEntry[], +>( + config: WithSupabaseConfig & { plugins: Plugins }, + handler: ( + req: Request, + ctx: SupabaseContext & PluginsCtx, + ) => Promise, +): (req: Request) => Promise + +export function withSupabase( + config: WithSupabaseConfig & { plugins?: readonly AnyEntry[] }, + handler: AnyHandler, ): (req: Request) => Promise { return async (req: Request) => { if (config.cors !== false && req.method === 'OPTIONS') { @@ -53,7 +123,29 @@ export function withSupabase( ) } - const response = await handler(req, ctx) + let response: Response + if (config.plugins?.length) { + // Compose plugins around the handler — same fold as pipeline's reduceRight, + // but without calling pipeline() so we supply the seeded ctx ourselves. + const composed = ( + config.plugins as readonly AnyEntry[] + ).reduceRight((h, entry) => entry(h), handler) + // Seed _runtime so web-middleware entries recognise this as an upstream + // context (isContext() checks for _runtime.getEnv). Falls through to + // process.env; a full implementation would bridge to SupabaseEnv. + const g = globalThis as { + process?: { env?: Record } + } + response = await composed(req, { + ...ctx, + _runtime: { + name: 'unknown' as const, + getEnv: (key: string): string | undefined => g.process?.env?.[key], + }, + }) + } else { + response = await handler(req, ctx as object) + } if (config.cors !== false) { return addCorsHeaders(response, config.cors) From f55d19ea01c1b4f81bfce95845dbf42d53755032 Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Wed, 1 Jul 2026 20:00:56 +0300 Subject: [PATCH 2/3] 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 b467f27..b33bfc5 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 a1d956324c496c80207fa135c0dafc9dfe5ce8ff Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Thu, 2 Jul 2026 13:24:09 +0300 Subject: [PATCH 3/3] refactor: rename plugins option to middleware on withSupabase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The array holds middleware entries (per-request behavior from defineMiddleware) — the word 'plugins' is reserved for the package-level concept whose client namespace goes in createClient({ plugins }). One word per concept: server-side composition is 'middleware', client-side namespaces are 'plugins', a Plugin is the package that ships both. PluginsCtx -> MiddlewareCtx; overload trick unchanged (middleware?: never on overload 1). Co-Authored-By: Claude Fable 5 --- src/with-supabase.test.ts | 22 ++++++++-------- src/with-supabase.ts | 55 +++++++++++++++++++++------------------ 2 files changed, 40 insertions(+), 37 deletions(-) diff --git a/src/with-supabase.test.ts b/src/with-supabase.test.ts index 3d0237e..134eb84 100644 --- a/src/with-supabase.test.ts +++ b/src/with-supabase.test.ts @@ -99,8 +99,8 @@ describe('withSupabase', () => { expect(res.headers.get('Access-Control-Allow-Origin')).toBeNull() }) - describe('plugins', () => { - it('composes plugins after the Supabase context is established', async () => { + describe('middleware', () => { + it('composes middleware after the Supabase context is established', async () => { const withFlag = defineMiddleware< 'flag', void, @@ -112,7 +112,7 @@ describe('withSupabase', () => { }) const handler = withSupabase( - { auth: 'none', env: baseEnv, plugins: [withFlag()] }, + { auth: 'none', env: baseEnv, middleware: [withFlag()] }, async (_req, ctx) => Response.json({ authMode: ctx.authMode, flag: ctx.flag }), ) @@ -123,7 +123,7 @@ describe('withSupabase', () => { expect(body.flag).toBe(true) }) - it('plugin receives the Supabase context at runtime', async () => { + it('middleware receives the Supabase context at runtime', async () => { let capturedHasSupabase = false const withCapture = defineMiddleware< @@ -140,7 +140,7 @@ describe('withSupabase', () => { }) const handler = withSupabase( - { auth: 'none', env: baseEnv, plugins: [withCapture()] }, + { auth: 'none', env: baseEnv, middleware: [withCapture()] }, async () => Response.json({ ok: true }), ) @@ -148,7 +148,7 @@ describe('withSupabase', () => { expect(capturedHasSupabase).toBe(true) }) - it('plugin can short-circuit before the handler', async () => { + it('middleware can short-circuit before the handler', async () => { const withBlock = defineMiddleware< 'blocked', void, @@ -162,7 +162,7 @@ describe('withSupabase', () => { const innerHandler = vi.fn(async () => Response.json({ ok: true })) const handler = withSupabase( - { auth: 'none', env: baseEnv, plugins: [withBlock()] }, + { auth: 'none', env: baseEnv, middleware: [withBlock()] }, innerHandler, ) @@ -171,7 +171,7 @@ describe('withSupabase', () => { expect(innerHandler).not.toHaveBeenCalled() }) - it('plugins run in array order (first = outermost, runs first on request)', async () => { + it('middleware run in array order (first = outermost, runs first on request)', async () => { const order: string[] = [] const withA = defineMiddleware<'a', void, Record, true>({ @@ -190,7 +190,7 @@ describe('withSupabase', () => { }) const handler = withSupabase( - { auth: 'none', env: baseEnv, plugins: [withA(), withB()] }, + { auth: 'none', env: baseEnv, middleware: [withA(), withB()] }, async (_req, ctx) => Response.json({ a: ctx.a, b: ctx.b }), ) @@ -199,7 +199,7 @@ describe('withSupabase', () => { expect(await res.json()).toEqual({ a: true, b: true }) }) - it('CORS headers still apply when plugins are present', async () => { + it('CORS headers still apply when middleware are present', async () => { const withNoop = defineMiddleware< 'noop', void, @@ -211,7 +211,7 @@ describe('withSupabase', () => { }) const handler = withSupabase( - { auth: 'none', env: baseEnv, plugins: [withNoop()] }, + { auth: 'none', env: baseEnv, middleware: [withNoop()] }, async () => Response.json({ ok: true }), ) diff --git a/src/with-supabase.ts b/src/with-supabase.ts index b33bfc5..3654366 100644 --- a/src/with-supabase.ts +++ b/src/with-supabase.ts @@ -8,17 +8,17 @@ type AnyEntry = Entry type AnyHandler = (req: Request, ctx: any) => Promise /** - * Accumulate the ctx contributions of a plugin tuple — same logic as + * Accumulate the ctx contributions of a middleware tuple — same logic as * `pipeline`'s internal `Accumulate`, seeded from `object` (no `BaseContext` * or `_runtime` in the visible ctx type; see implementation note below). */ -type PluginsCtx = - Plugins extends readonly [ +type MiddlewareCtx = + Entries extends readonly [ Entry, ...infer Rest, ] ? Rest extends readonly AnyEntry[] - ? { [P in Key]: Contribution } & PluginsCtx + ? { [P in Key]: Contribution } & MiddlewareCtx : { [P in Key]: Contribution } : object @@ -39,7 +39,7 @@ type PluginsCtx = * ```ts * import { withSupabase } from '@supabase/server' * - * // Without plugins — existing API, unchanged. + * // Without middleware — existing API, unchanged. * export default { * fetch: withSupabase({ auth: 'user' }, async (req, ctx) => { * const { data } = await ctx.supabase.rpc('get_my_profile') @@ -49,15 +49,17 @@ type PluginsCtx = * ``` */ export function withSupabase( - config: WithSupabaseConfig & { plugins?: never }, + config: WithSupabaseConfig & { middleware?: never }, handler: (req: Request, ctx: SupabaseContext) => Promise, ): (req: Request) => Promise /** - * Variant that accepts a `plugins` array — each `withFoo(config)` call returns - * an `Entry` from `@supabase/web-middleware`. Plugins run **after** the Supabase - * context is established; they receive `ctx.supabase`, `ctx.userClaims`, etc. - * already present and contribute their own typed keys on top. + * Variant that accepts a `middleware` array — each `withFoo(config)` call + * returns an `Entry` from `@supabase/web-middleware`. Middleware run **after** + * the Supabase context is established; they receive `ctx.supabase`, + * `ctx.userClaims`, etc. already present and contribute their own typed keys + * on top. (This is the server leg of a Plugin: the package's middleware goes + * here; its client namespace goes in `createClient`'s `plugins` array.) * * @example * ```ts @@ -67,7 +69,7 @@ export function withSupabase( * * export default { * fetch: withSupabase( - * { auth: 'user', plugins: [withRateLimit({ rpm: 100 }), withGuestbook()] }, + * { auth: 'user', middleware: [withRateLimit({ rpm: 100 }), withGuestbook()] }, * async (req, ctx) => { * ctx.supabase // from @supabase/server * ctx.rateLimit // from withRateLimit @@ -78,27 +80,27 @@ export function withSupabase( * } * ``` * - * **Type note.** `PluginsCtx` accumulates the key contributions of the - * plugins array. Plugins that declare `In` prerequisites on Supabase-provided - * keys (`supabase`, `userClaims`, …) satisfy those at runtime (the Supabase - * context is merged before plugins run) but not at the type level — a full - * implementation would widen the prerequisite-validation seed to include - * `SupabaseContext`. Ordering and collision checks within the plugins array work - * normally via `web-middleware`'s runtime chain. + * **Type note.** `MiddlewareCtx` accumulates the key contributions of + * the middleware array. Middleware that declare `In` prerequisites on + * Supabase-provided keys (`supabase`, `userClaims`, …) satisfy those at runtime + * (the Supabase context is merged before the middleware run) but not at the + * type level — a full implementation would widen the prerequisite-validation + * seed to include `SupabaseContext`. Ordering and collision checks within the + * middleware array work normally via `web-middleware`'s runtime chain. */ export function withSupabase< Database = unknown, - const Plugins extends readonly AnyEntry[] = readonly AnyEntry[], + const Entries extends readonly AnyEntry[] = readonly AnyEntry[], >( - config: WithSupabaseConfig & { plugins: Plugins }, + config: WithSupabaseConfig & { middleware: Entries }, handler: ( req: Request, - ctx: SupabaseContext & PluginsCtx, + ctx: SupabaseContext & MiddlewareCtx, ) => Promise, ): (req: Request) => Promise export function withSupabase( - config: WithSupabaseConfig & { plugins?: readonly AnyEntry[] }, + config: WithSupabaseConfig & { middleware?: readonly AnyEntry[] }, handler: AnyHandler, ): (req: Request) => Promise { return async (req: Request) => { @@ -124,11 +126,12 @@ export function withSupabase( } let response: Response - if (config.plugins?.length) { - // Compose plugins around the handler — same fold as pipeline's reduceRight, - // but without calling pipeline() so we supply the seeded ctx ourselves. + if (config.middleware?.length) { + // Compose the middleware around the handler — same fold as pipeline's + // reduceRight, but without calling pipeline() so we supply the seeded + // ctx ourselves. const composed = ( - config.plugins as readonly AnyEntry[] + config.middleware as readonly AnyEntry[] ).reduceRight((h, entry) => entry(h), handler) // Seed _runtime so web-middleware entries recognise this as an upstream // context (isContext() checks for _runtime.getEnv). Falls through to