Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
10 changes: 10 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ minimumReleaseAgeExclude:
- '@esbuild/*'
blockExoticSubdeps: true
allowBuilds:
'@supabase/web-middleware': true
'@nestjs/core': false
'@swc/core': false
esbuild: false
Expand Down
122 changes: 122 additions & 0 deletions src/with-supabase.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<never, never>,
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<never, never>,
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<never, never>,
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<never, never>, true>({
key: 'a',
run: () => async () => {
order.push('a')
return { a: true as const }
},
})
const withB = defineMiddleware<'b', void, Record<never, never>, 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<never, never>,
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()
Expand Down
99 changes: 97 additions & 2 deletions src/with-supabase.ts
Original file line number Diff line number Diff line change
@@ -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<string, object, unknown>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyHandler = (req: Request, ctx: any) => Promise<Response>

/**
* 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 AnyEntry[]> =
Entries extends readonly [
Entry<infer Key extends string, object, infer Contribution>,
...infer Rest,
]
? Rest extends readonly AnyEntry[]
? { [P in Key]: Contribution } & MiddlewareCtx<Rest>
: { [P in Key]: Contribution }
: object

/**
* Wraps a request handler with Supabase auth, client creation, and CORS handling.
Expand All @@ -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')
Expand All @@ -28,8 +49,59 @@ import type { SupabaseContext, WithSupabaseConfig } from './types.js'
* ```
*/
export function withSupabase<Database = unknown>(
config: WithSupabaseConfig,
config: WithSupabaseConfig & { middleware?: never },
handler: (req: Request, ctx: SupabaseContext<Database>) => Promise<Response>,
): (req: Request) => Promise<Response>

/**
* 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<Entries>` 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<Database> & MiddlewareCtx<Entries>,
) => Promise<Response>,
): (req: Request) => Promise<Response>

export function withSupabase<Database = unknown>(
config: WithSupabaseConfig & { middleware?: readonly AnyEntry[] },
handler: AnyHandler,
): (req: Request) => Promise<Response> {
return async (req: Request) => {
if (config.cors !== false && req.method === 'OPTIONS') {
Expand All @@ -53,7 +125,30 @@ export function withSupabase<Database = unknown>(
)
}

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<AnyHandler>((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<string, string | undefined> }
}
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)
Expand Down
Loading