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..134eb84 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('middleware', () => { + it('composes middleware 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, middleware: [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('middleware 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, middleware: [withCapture()] }, + async () => Response.json({ ok: true }), + ) + + await handler(new Request('http://localhost')) + expect(capturedHasSupabase).toBe(true) + }) + + it('middleware 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, middleware: [withBlock()] }, + innerHandler, + ) + + const res = await handler(new Request('http://localhost')) + expect(res.status).toBe(403) + expect(innerHandler).not.toHaveBeenCalled() + }) + + it('middleware 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, middleware: [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 middleware 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, middleware: [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..3654366 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 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 MiddlewareCtx = + Entries extends readonly [ + Entry, + ...infer Rest, + ] + ? Rest extends readonly AnyEntry[] + ? { [P in Key]: Contribution } & MiddlewareCtx + : { [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 middleware — existing API, unchanged. * export default { * fetch: withSupabase({ auth: 'user' }, async (req, ctx) => { * const { data } = await ctx.supabase.rpc('get_my_profile') @@ -28,8 +49,59 @@ import type { SupabaseContext, WithSupabaseConfig } from './types.js' * ``` */ export function withSupabase( - config: WithSupabaseConfig, + config: WithSupabaseConfig & { middleware?: never }, handler: (req: Request, ctx: SupabaseContext) => Promise, +): (req: Request) => Promise + +/** + * 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 + * 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', middleware: [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.** `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 Entries extends readonly AnyEntry[] = readonly AnyEntry[], +>( + config: WithSupabaseConfig & { middleware: Entries }, + handler: ( + req: Request, + ctx: SupabaseContext & MiddlewareCtx, + ) => Promise, +): (req: Request) => Promise + +export function withSupabase( + config: WithSupabaseConfig & { middleware?: readonly AnyEntry[] }, + handler: AnyHandler, ): (req: Request) => Promise { return async (req: Request) => { if (config.cors !== false && req.method === 'OPTIONS') { @@ -53,7 +125,30 @@ export function withSupabase( ) } - const response = await handler(req, ctx) + 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) + // 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)