diff --git a/CHANGELOG.md b/CHANGELOG.md index db70c3c..6245b39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,25 +2,22 @@ ## [1.1.0](https://github.com/supabase/server/compare/server-v1.0.0...server-v1.1.0) (2026-05-19) - ### Features -* add Elysia adapter ([#46](https://github.com/supabase/server/issues/46)) ([148169e](https://github.com/supabase/server/commit/148169e5f7737ea50049f3649056f5a44a266a1f)) -* **env:** add support for JWKS discovery endpoints ([#53](https://github.com/supabase/server/issues/53)) ([45d677a](https://github.com/supabase/server/commit/45d677ae6539cfa58e0c339960f53e9a7ca90e7d)) - +- add Elysia adapter ([#46](https://github.com/supabase/server/issues/46)) ([148169e](https://github.com/supabase/server/commit/148169e5f7737ea50049f3649056f5a44a266a1f)) +- **env:** add support for JWKS discovery endpoints ([#53](https://github.com/supabase/server/issues/53)) ([45d677a](https://github.com/supabase/server/commit/45d677ae6539cfa58e0c339960f53e9a7ca90e7d)) ### Bug Fixes -* **auth:** skip user mode when token has sb_ prefix ([#67](https://github.com/supabase/server/issues/67)) ([b193216](https://github.com/supabase/server/commit/b1932169e28163040b9b22db73b0f84739d9bb8b)) -* **ci:** update node packages ([#57](https://github.com/supabase/server/issues/57)) ([f275907](https://github.com/supabase/server/commit/f2759071fd84932e15ebd48f21c04ab311bd5237)) -* **jsr:** resolve slow-type errors in elysia and h3 adapters ([#69](https://github.com/supabase/server/issues/69)) ([7c56b13](https://github.com/supabase/server/commit/7c56b132985bd04673108dab7251b1939326d18e)) +- **auth:** skip user mode when token has sb\_ prefix ([#67](https://github.com/supabase/server/issues/67)) ([b193216](https://github.com/supabase/server/commit/b1932169e28163040b9b22db73b0f84739d9bb8b)) +- **ci:** update node packages ([#57](https://github.com/supabase/server/issues/57)) ([f275907](https://github.com/supabase/server/commit/f2759071fd84932e15ebd48f21c04ab311bd5237)) +- **jsr:** resolve slow-type errors in elysia and h3 adapters ([#69](https://github.com/supabase/server/issues/69)) ([7c56b13](https://github.com/supabase/server/commit/7c56b132985bd04673108dab7251b1939326d18e)) ## [1.0.0](https://github.com/supabase/server/compare/server-v0.2.0...server-v1.0.0) (2026-05-06) - ### Miscellaneous Chores -* release 1.0.0 ([#50](https://github.com/supabase/server/issues/50)) ([67de77f](https://github.com/supabase/server/commit/67de77f00b7ebbf4e1de973489703959c7e3a838)) +- release 1.0.0 ([#50](https://github.com/supabase/server/issues/50)) ([67de77f](https://github.com/supabase/server/commit/67de77f00b7ebbf4e1de973489703959c7e3a838)) ## [0.2.0](https://github.com/supabase/server/compare/server-v0.1.4...server-v0.2.0) (2026-04-24) diff --git a/jsr.json b/jsr.json index 77dbe1e..0290a97 100644 --- a/jsr.json +++ b/jsr.json @@ -4,19 +4,13 @@ "exports": { ".": "./src/index.ts", "./core": "./src/core/index.ts", + "./core/adapters": "./src/core/adapters/index.ts", "./adapters/hono": "./src/adapters/hono/index.ts", "./adapters/h3": "./src/adapters/h3/index.ts", "./adapters/elysia": "./src/adapters/elysia/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 8b44e34..a0c48de 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,11 @@ "import": "./dist/core/index.mjs", "require": "./dist/core/index.cjs" }, + "./core/adapters": { + "types": "./dist/core/adapters/index.d.mts", + "import": "./dist/core/adapters/index.mjs", + "require": "./dist/core/adapters/index.cjs" + }, "./adapters/hono": { "types": "./dist/adapters/hono/index.d.mts", "import": "./dist/adapters/hono/index.mjs", diff --git a/src/adapters/elysia/plugin.test.ts b/src/adapters/elysia/plugin.test.ts index a264aa6..cd43bf1 100644 --- a/src/adapters/elysia/plugin.test.ts +++ b/src/adapters/elysia/plugin.test.ts @@ -1,7 +1,7 @@ import { Elysia } from 'elysia' import { describe, expect, it } from 'vitest' -import { withSupabase } from './plugin.js' +import { SupabaseError, withSupabase } from './plugin.js' describe('elysia supabase plugin', () => { const env = { @@ -85,3 +85,80 @@ describe('elysia supabase plugin', () => { expect(res.headers.get('Access-Control-Allow-Origin')).toBeNull() }) }) + +describe('elysia withSupabase fetch-handler form (two-arg)', () => { + const env = { + url: 'https://test.supabase.co', + publishableKeys: { default: 'sb_publishable_xyz' }, + secretKeys: { default: 'sb_secret_xyz' }, + jwks: null, + } + + it('mounts directly on .all and runs the inner handler with the Supabase ctx', async () => { + const app = new Elysia().all( + '/route', + withSupabase({ auth: 'none', env }, async (_req, ctx) => + Response.json({ + authMode: ctx.authMode, + hasSupabase: !!ctx.supabase, + }), + ), + ) + + const res = await app.handle(new Request('http://localhost/route')) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ authMode: 'none', hasSupabase: true }) + }) + + it('throws SupabaseError on auth failure so onError handles it (consistent with one-arg form)', async () => { + let caughtCode: string | undefined + const app = new Elysia() + .error({ SupabaseError }) + .onError(({ code, error, status }) => { + if (code !== 'SupabaseError') return + caughtCode = error.cause.code + return status(error.status as 401, { + error: error.message, + code: error.cause.code, + }) + }) + .all( + '/', + withSupabase({ auth: 'user', env }, async () => + Response.json({ ok: true }), + ), + ) + + const res = await app.handle(new Request('http://localhost/')) + expect(res.status).toBe(401) + expect(caughtCode).toBeDefined() + }) + + it('skips re-running auth when an upstream plugin already resolved supabaseContext', async () => { + let innerHandlerCalls = 0 + const app = new Elysia().use(withSupabase({ auth: 'none', env })).all( + '/protected', + withSupabase({ auth: 'secret', env }, async (_req, ctx) => { + innerHandlerCalls++ + return Response.json({ authMode: ctx.authMode }) + }), + ) + + // No apikey header — would fail 'secret' if the two-arg form re-ran auth + const res = await app.handle(new Request('http://localhost/protected')) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.authMode).toBe('none') + expect(innerHandlerCalls).toBe(1) + }) + + it('also accepts a plain Request directly (Web Fetch use)', async () => { + const handler = withSupabase({ auth: 'none', env }, async () => + Response.json({ ok: true }), + ) + + const res = await handler(new Request('http://localhost/')) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ ok: true }) + }) +}) diff --git a/src/adapters/elysia/plugin.ts b/src/adapters/elysia/plugin.ts index 846b59f..1409587 100644 --- a/src/adapters/elysia/plugin.ts +++ b/src/adapters/elysia/plugin.ts @@ -1,8 +1,12 @@ import { Elysia, type ExtractErrorFromHandle } from 'elysia' import { createSupabaseContext } from '../../create-supabase-context.js' +import { + defineAdapter, + type AdapterWithSupabase, +} from '../../core/adapters/index.js' import type { AuthError } from '../../errors.js' -import type { SupabaseContext, WithSupabaseConfig } from '../../types.js' +import type { SupabaseContext } from '../../types.js' export class SupabaseError extends Error { status: number @@ -13,54 +17,11 @@ export class SupabaseError extends Error { } } -/** - * Elysia plugin that creates a {@link SupabaseContext} and makes it available in route handlers. - * - * Skips if a previous plugin already set the context, enabling route-level overrides. - * Throws a `SupabaseError` on auth failure. `.status` is on the error directly; the original - * `AuthError` is available as the typed `.cause`. Discriminate in `onError` via `code === 'SupabaseError'`. - * - * @param config - Auth modes and optional environment overrides. CORS is excluded — use Elysia's CORS utilities. - * @returns An Elysia plugin that exposes `supabaseContext`. - * - * @example App-wide auth via `.use()` - * ```ts - * import { Elysia } from 'elysia' - * import { withSupabase } from '@supabase/server/adapters/elysia' - * - * const app = new Elysia() - * .use(withSupabase({ allow: 'user' })) - * .get('/games', async ({ supabaseContext }) => { - * const { data } = await supabaseContext.supabase.from('favorite_games').select() - * return data - * }) - * - * app.listen(3000) - * ``` - * - * @example Per-route auth via scoped `.use()` - * ```ts - * import { Elysia } from 'elysia' - * import { withSupabase } from '@supabase/server/adapters/elysia' - * - * const app = new Elysia() - * .get('/health', () => ({ status: 'ok' })) - * .group('/api', (app) => - * app - * .use(withSupabase({ allow: 'user' })) - * .get('/profile', async ({ supabaseContext }) => { - * return supabaseContext.userClaims - * }) - * ) - * - * app.listen(3000) - * ``` - */ -// The explicit return type below mirrors Elysia's own generic defaults, which use -// `{}` literals — switching to `object` or `Record` would not satisfy -// the corresponding generic constraints. +// The explicit Elysia plugin type below mirrors Elysia's own generic defaults, +// which use `{}` literals — switching to `object` or `Record` +// would not satisfy the corresponding generic constraints. /* eslint-disable @typescript-eslint/no-empty-object-type */ -export function withSupabase(config?: Omit): Elysia< +type SupabasePlugin = Elysia< '', { decorator: {}; store: {}; derive: {}; resolve: {} }, { typebox: {}; error: { readonly SupabaseError: SupabaseError } }, @@ -87,19 +48,95 @@ export function withSupabase(config?: Omit): Elysia< standaloneSchema: {} response: {} } -> { - /* eslint-enable @typescript-eslint/no-empty-object-type */ - return new Elysia() - .error({ SupabaseError }) - .resolve(async (ctx): Promise<{ supabaseContext: SupabaseContext }> => { - const existing = (ctx as { supabaseContext?: SupabaseContext }) - .supabaseContext - if (existing) return { supabaseContext: existing } +> +/* eslint-enable @typescript-eslint/no-empty-object-type */ - const { data, error } = await createSupabaseContext(ctx.request, config) - if (error) throw new SupabaseError(error) +/** + * Elysia adapter for `@supabase/server`. + * + * Exports a single overloaded `withSupabase`: + * + * - **One arg** — `withSupabase(config)` returns an Elysia plugin that + * exposes `supabaseContext` via `.resolve()`. Throws a + * {@link SupabaseError} on auth failure; the original `AuthError` is + * the typed `.cause`. Skips if a previous plugin already resolved the + * context. + * - **Two args** — `withSupabase(config, handler)` returns a dual-mode + * route handler that accepts either a plain `Request` (Web Fetch) or + * an Elysia route context, extracts the underlying `Request`, and + * runs base `withSupabase` against it. Mount directly via + * `.all(path, withSupabase(config, handler))`. Use this form to + * compose with gates from `@supabase/server/gates/*`. + * + * Behavior of the two-arg form matches the one-arg plugin: + * - **Auth failures throw `SupabaseError`**, flowing into Elysia's + * `onError` (discriminate via `code === 'SupabaseError'`). + * - **Skip-if-set** — when an upstream plugin already resolved + * `supabaseContext`, the inner handler runs with that existing + * context instead of re-verifying. + * - **CORS is excluded from the config** — use Elysia's CORS plugin. + * + * @example One-arg — app-wide auth via `.use()` + * ```ts + * import { Elysia } from 'elysia' + * import { withSupabase } from '@supabase/server/adapters/elysia' + * + * const app = new Elysia() + * .use(withSupabase({ auth: 'user' })) + * .get('/games', async ({ supabaseContext }) => { + * const { data } = await supabaseContext.supabase.from('favorite_games').select() + * return data + * }) + * + * app.listen(3000) + * ``` + * + * @example Two-arg — per-route auth + gates + * ```ts + * import { Elysia } from 'elysia' + * import { withSupabase } from '@supabase/server/adapters/elysia' + * import { withFeatureFlag } from '@supabase/server/gates/feature-flag' + * + * new Elysia() + * .all( + * '/beta', + * withSupabase( + * { auth: 'user' }, + * withFeatureFlag( + * { name: 'beta', evaluate: (req) => req.headers.has('x-beta') }, + * async (_req, ctx) => + * Response.json({ user: ctx.userClaims?.id, flag: ctx.featureFlag.name }), + * ), + * ), + * ) + * .listen(3000) + * ``` + */ +export const withSupabase: AdapterWithSupabase< + { request: Request; supabaseContext?: SupabaseContext }, + SupabasePlugin +> = defineAdapter< + { request: Request; supabaseContext?: SupabaseContext }, + SupabasePlugin +>({ + name: 'elysia', + extractRequest: (ctx) => ctx.request, + getExistingContext: (ctx) => ctx.supabaseContext, + throwAuthError: (error) => { + throw new SupabaseError(error) + }, + middleware: (config) => + new Elysia() + .error({ SupabaseError }) + .resolve(async (ctx): Promise<{ supabaseContext: SupabaseContext }> => { + const existing = (ctx as { supabaseContext?: SupabaseContext }) + .supabaseContext + if (existing) return { supabaseContext: existing } - return { supabaseContext: data } - }) - .as('scoped') -} + const { data, error } = await createSupabaseContext(ctx.request, config) + if (error) throw new SupabaseError(error) + + return { supabaseContext: data } + }) + .as('scoped'), +}) diff --git a/src/adapters/h3/middleware.test.ts b/src/adapters/h3/middleware.test.ts index d5c7e7d..f05e248 100644 --- a/src/adapters/h3/middleware.test.ts +++ b/src/adapters/h3/middleware.test.ts @@ -103,3 +103,85 @@ describe('h3 supabase middleware', () => { expect(res.headers.get('Access-Control-Allow-Origin')).toBeNull() }) }) + +describe('h3 withSupabase fetch-handler form (two-arg)', () => { + const env = { + url: 'https://test.supabase.co', + publishableKeys: { default: 'sb_publishable_xyz' }, + secretKeys: { default: 'sb_secret_xyz' }, + jwks: null, + } + + it('mounts directly on app.all and runs the inner handler with the Supabase ctx', async () => { + const app = new H3() + app.all( + '/route', + withSupabase({ auth: 'none', env }, async (_req, ctx) => + Response.json({ + authMode: ctx.authMode, + hasSupabase: !!ctx.supabase, + }), + ), + ) + + const res = await app.request('/route') + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ authMode: 'none', hasSupabase: true }) + }) + + it('throws HTTPError on auth failure so onError handles it (consistent with one-arg form)', async () => { + const app = new H3() + let caught: unknown + app.use( + onError((error) => { + caught = error + return Response.json( + { caught: (error as Error).message }, + { status: HTTPError.isError(error) ? error.status : 500 }, + ) + }), + ) + app.all( + '/', + withSupabase({ auth: 'user', env }, async () => + Response.json({ ok: true }), + ), + ) + + const res = await app.request('/') + expect(res.status).toBe(401) + expect(caught).toBeDefined() + expect(HTTPError.isError(caught)).toBe(true) + }) + + it('skips re-running auth when an upstream middleware already set event.context.supabaseContext', async () => { + const app = new H3() + app.use(withSupabase({ auth: 'none', env })) + + let innerHandlerCalls = 0 + app.all( + '/protected', + withSupabase({ auth: 'secret', env }, async (_req, ctx) => { + innerHandlerCalls++ + return Response.json({ authMode: ctx.authMode }) + }), + ) + + // No apikey header — would fail 'secret' if the two-arg form re-ran auth + const res = await app.request('/protected') + expect(res.status).toBe(200) + const body = await res.json() + expect(body.authMode).toBe('none') + expect(innerHandlerCalls).toBe(1) + }) + + it('also accepts a plain Request directly (Web Fetch use)', async () => { + const handler = withSupabase({ auth: 'none', env }, async () => + Response.json({ ok: true }), + ) + + const res = await handler(new Request('https://example.test/')) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ ok: true }) + }) +}) diff --git a/src/adapters/h3/middleware.ts b/src/adapters/h3/middleware.ts index 168a754..7fd3b89 100644 --- a/src/adapters/h3/middleware.ts +++ b/src/adapters/h3/middleware.ts @@ -1,19 +1,38 @@ import { defineMiddleware, HTTPError } from 'h3' -import type { Middleware } from 'h3' +import type { H3Event, Middleware } from 'h3' import { createSupabaseContext } from '../../create-supabase-context.js' -import type { SupabaseContext, WithSupabaseConfig } from '../../types.js' +import { + defineAdapter, + type AdapterWithSupabase, +} from '../../core/adapters/index.js' +import type { SupabaseContext } from '../../types.js' /** - * H3 middleware that creates a {@link SupabaseContext} and stores it in `event.context.supabaseContext`. + * H3 adapter for `@supabase/server`. * - * Skips if a previous middleware already set the context, enabling chained middleware via `app.use()`. - * Throws an `HTTPError` on auth failure. + * Exports a single overloaded `withSupabase`: * - * @param config - Auth modes and optional environment overrides. CORS is excluded — use H3's CORS utilities. - * @returns An H3 middleware. + * - **One arg** — `withSupabase(config)` returns an H3 `Middleware` that + * creates a {@link SupabaseContext}, stores it on + * `event.context.supabaseContext`, and throws an `HTTPError` (carrying + * the original `AuthError` as `.cause`) on auth failure. Skips + * re-running auth if a previous middleware already set the context. + * - **Two args** — `withSupabase(config, handler)` returns a dual-mode + * route handler that accepts either a plain `Request` (Web Fetch) or + * an `H3Event` (H3 route handler), extracts the underlying `Request`, + * and runs base `withSupabase` against it. Mount directly via + * `app.all(path, withSupabase(config, handler))`. Use this form to + * compose with gates from `@supabase/server/gates/*`. * - * @example App-wide auth via `app.use()` + * Behavior of the two-arg form matches the one-arg middleware: + * - **Auth failures throw `HTTPError`**, flowing into H3's `onError` hook. + * - **Skip-if-set** — when an upstream middleware already populated + * `event.context.supabaseContext`, the inner handler runs with that + * existing context instead of re-verifying. + * - **CORS is excluded from the config** — use H3's CORS utilities. + * + * @example One-arg — app-wide auth via `app.use()` * ```ts * import { H3 } from 'h3' * import { withSupabase } from '@supabase/server/adapters/h3' @@ -29,31 +48,51 @@ import type { SupabaseContext, WithSupabaseConfig } from '../../types.js' * export default { fetch: app.fetch } * ``` * - * @example Per-route auth via `defineHandler` + * @example Two-arg — per-route auth + gates * ```ts - * import { defineHandler } from 'h3' + * import { H3 } from 'h3' * import { withSupabase } from '@supabase/server/adapters/h3' + * import { withFeatureFlag } from '@supabase/server/gates/feature-flag' * - * export default defineHandler({ - * middleware: [withSupabase({ auth: 'user' })], - * handler: async (event) => { - * const { supabase } = event.context.supabaseContext - * return supabase.from('favorite_games').select() - * }, - * }) + * const app = new H3() + * + * app.all( + * '/beta', + * withSupabase( + * { auth: 'user' }, + * withFeatureFlag( + * { name: 'beta', evaluate: (req) => req.headers.has('x-beta') }, + * async (_req, ctx) => + * Response.json({ user: ctx.userClaims?.id, flag: ctx.featureFlag.name }), + * ), + * ), + * ) * ``` */ -export function withSupabase( - config?: Omit, -): Middleware { - return defineMiddleware(async (event, next) => { - const context = event.context as { supabaseContext?: SupabaseContext } - if (context.supabaseContext) return next() - const { data: ctx, error } = await createSupabaseContext(event.req, config) - if (error) { +export const withSupabase: AdapterWithSupabase = + defineAdapter({ + name: 'h3', + extractRequest: (event) => event.req, + getExistingContext: (event) => + (event.context as { supabaseContext?: SupabaseContext }).supabaseContext, + throwAuthError: (error) => { throw new HTTPError(error.message, { status: error.status, cause: error }) - } - context.supabaseContext = ctx - return next() + }, + middleware: (config) => + defineMiddleware(async (event, next) => { + const context = event.context as { supabaseContext?: SupabaseContext } + if (context.supabaseContext) return next() + const { data: ctx, error } = await createSupabaseContext( + event.req, + config, + ) + if (error) { + throw new HTTPError(error.message, { + status: error.status, + cause: error, + }) + } + context.supabaseContext = ctx + return next() + }), }) -} diff --git a/src/adapters/hono/middleware.test.ts b/src/adapters/hono/middleware.test.ts index e37fcdc..dac4a16 100644 --- a/src/adapters/hono/middleware.test.ts +++ b/src/adapters/hono/middleware.test.ts @@ -96,3 +96,83 @@ describe('hono supabase middleware', () => { expect(res.headers.get('Access-Control-Allow-Origin')).toBeNull() }) }) + +describe('hono withSupabase fetch-handler form (two-arg)', () => { + const env = { + url: 'https://test.supabase.co', + publishableKeys: { default: 'sb_publishable_xyz' }, + secretKeys: { default: 'sb_publishable_xyz' }, + jwks: null, + } + + it('mounts directly on app.all and runs the inner handler with the Supabase ctx', async () => { + const app = new Hono() + app.all( + '/route', + withSupabase({ auth: 'none', env }, async (_req, ctx) => + Response.json({ + authMode: ctx.authMode, + hasSupabase: !!ctx.supabase, + }), + ), + ) + + const res = await app.request('/route') + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ authMode: 'none', hasSupabase: true }) + }) + + it('throws HTTPException on auth failure so app.onError handles it (consistent with one-arg form)', async () => { + const app = new Hono() + let caught: Error | undefined + app.onError((err, c) => { + caught = err + return c.json({ caught: err.message }, 401) + }) + app.all( + '/', + withSupabase({ auth: 'user', env }, async () => + Response.json({ ok: true }), + ), + ) + + const res = await app.request('/') + expect(res.status).toBe(401) + expect(caught).toBeDefined() + const cause = ( + caught as (Error & { cause?: { code?: string } }) | undefined + )?.cause + expect(cause?.code).toBeDefined() + }) + + it('skips re-running auth when an upstream middleware already set c.var.supabaseContext', async () => { + const app = new Hono<{ Variables: { supabaseContext: SupabaseContext } }>() + app.use('*', withSupabase({ auth: 'none', env })) + + let innerHandlerCalls = 0 + app.all( + '/protected', + withSupabase({ auth: 'secret', env }, async (_req, ctx) => { + innerHandlerCalls++ + return Response.json({ authMode: ctx.authMode }) + }), + ) + + // No apikey header — would fail 'secret' if the two-arg form re-ran auth + const res = await app.request('/protected') + expect(res.status).toBe(200) + const body = await res.json() + expect(body.authMode).toBe('none') + expect(innerHandlerCalls).toBe(1) + }) + + it('also accepts a plain Request directly (Web Fetch use)', async () => { + const handler = withSupabase({ auth: 'none', env }, async () => + Response.json({ ok: true }), + ) + + const res = await handler(new Request('https://example.test/')) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ ok: true }) + }) +}) diff --git a/src/adapters/hono/middleware.ts b/src/adapters/hono/middleware.ts index 33581d4..302ff25 100644 --- a/src/adapters/hono/middleware.ts +++ b/src/adapters/hono/middleware.ts @@ -1,20 +1,39 @@ -import type { MiddlewareHandler } from 'hono' +import type { Context, MiddlewareHandler } from 'hono' import { HTTPException } from 'hono/http-exception' import { createMiddleware } from 'hono/factory' import { createSupabaseContext } from '../../create-supabase-context.js' -import type { SupabaseContext, WithSupabaseConfig } from '../../types.js' +import { + defineAdapter, + type AdapterWithSupabase, +} from '../../core/adapters/index.js' +import type { SupabaseContext } from '../../types.js' /** - * Hono middleware that creates a {@link SupabaseContext} and stores it in `c.var.supabaseContext`. + * Hono adapter for `@supabase/server`. * - * Skips if a previous middleware already set the context, enabling route-level overrides. - * Throws a Hono `HTTPException` on auth failure. + * Exports a single overloaded `withSupabase`: * - * @param config - Auth modes and optional environment overrides. CORS is excluded — use Hono's `cors()`. - * @returns A Hono middleware that sets `c.var.supabaseContext`. + * - **One arg** — `withSupabase(config)` returns a Hono `MiddlewareHandler` + * that creates a {@link SupabaseContext}, stores it on + * `c.var.supabaseContext`, and throws a Hono `HTTPException` (carrying + * the original `AuthError` as `.cause`) on auth failure. Skips + * re-running auth if a previous middleware already set the context. + * - **Two args** — `withSupabase(config, handler)` returns a dual-mode + * route handler that accepts either a plain `Request` (Web Fetch) or + * a Hono `Context` (Hono route handler), extracts the underlying + * `Request`, and runs base `withSupabase` against it. Mount directly + * via `app.all(path, withSupabase(config, handler))`. Use this form + * to compose with gates from `@supabase/server/gates/*`. * - * @example + * Behavior of the two-arg form matches the one-arg middleware: + * - **Auth failures throw `HTTPException`**, flowing into `app.onError`. + * - **Skip-if-set** — when an upstream middleware already populated + * `c.var.supabaseContext`, the inner handler runs with that existing + * context instead of re-verifying. + * - **CORS is excluded from the config** — use Hono's `cors()`. + * + * @example One-arg — app-wide auth via `app.use()` * ```ts * import { Hono } from 'hono' * import { withSupabase } from '@supabase/server/adapters/hono' @@ -30,31 +49,70 @@ import type { SupabaseContext, WithSupabaseConfig } from '../../types.js' * * export default { fetch: app.fetch } * ``` + * + * @example Two-arg — per-route auth + gates + * ```ts + * import { Hono } from 'hono' + * import { withSupabase } from '@supabase/server/adapters/hono' + * import { withFeatureFlag } from '@supabase/server/gates/feature-flag' + * + * const app = new Hono() + * + * app.all( + * '/beta', + * withSupabase( + * { auth: 'user' }, + * withFeatureFlag( + * { name: 'beta', evaluate: (req) => req.headers.has('x-beta') }, + * async (_req, ctx) => + * Response.json({ user: ctx.userClaims?.id, flag: ctx.featureFlag.name }), + * ), + * ), + * ) + * ``` */ -export function withSupabase( - config?: Omit, -): MiddlewareHandler<{ Variables: { supabaseContext: SupabaseContext } }> { - return createMiddleware<{ - Variables: { supabaseContext: SupabaseContext } - }>(async (c, next) => { - // Skip if a previous middleware already set the context. - // This enables route-level overrides: a route can use withSupabase({ auth: 'secret' }) - // while the app-wide middleware uses withSupabase({ auth: 'user' }), without the - // app-wide one overwriting the stricter context already established. - if (c.var.supabaseContext) { - await next() - return - } +export const withSupabase: AdapterWithSupabase< + Context, + MiddlewareHandler<{ Variables: { supabaseContext: SupabaseContext } }> +> = defineAdapter< + Context, + MiddlewareHandler<{ Variables: { supabaseContext: SupabaseContext } }> +>({ + name: 'hono', + extractRequest: (c) => c.req.raw, + getExistingContext: (c) => + (c.var as { supabaseContext?: SupabaseContext }).supabaseContext, + throwAuthError: (error) => { + throw new HTTPException(error.status as 401 | 500, { + message: error.message, + cause: error, + }) + }, + middleware: (config) => + createMiddleware<{ Variables: { supabaseContext: SupabaseContext } }>( + async (c, next) => { + // Skip if a previous middleware already set the context. + // This enables route-level overrides: a route can use withSupabase({ auth: 'secret' }) + // while the app-wide middleware uses withSupabase({ auth: 'user' }), without the + // app-wide one overwriting the stricter context already established. + if (c.var.supabaseContext) { + await next() + return + } - const { data: ctx, error } = await createSupabaseContext(c.req.raw, config) - if (error) { - throw new HTTPException(error.status as 401 | 500, { - message: error.message, - cause: error, - }) - } + const { data: ctx, error } = await createSupabaseContext( + c.req.raw, + config, + ) + if (error) { + throw new HTTPException(error.status as 401 | 500, { + message: error.message, + cause: error, + }) + } - c.set('supabaseContext', ctx) - await next() - }) -} + c.set('supabaseContext', ctx) + await next() + }, + ), +}) diff --git a/src/core/adapters/define-adapter.test.ts b/src/core/adapters/define-adapter.test.ts new file mode 100644 index 0000000..243c603 --- /dev/null +++ b/src/core/adapters/define-adapter.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it, vi } from 'vitest' + +import { AuthError } from '../../errors.js' +import type { SupabaseContext, WithSupabaseConfig } from '../../types.js' + +import { defineAdapter } from './define-adapter.js' + +const baseMock = vi.hoisted(() => ({ withSupabase: vi.fn() })) +vi.mock('../../with-supabase.js', () => baseMock) + +interface FakeContext { + request: Request + supabaseContext?: SupabaseContext +} + +const MIDDLEWARE_SENTINEL = Symbol('middleware') +type Middleware = { + tag: typeof MIDDLEWARE_SENTINEL + config?: WithSupabaseConfig +} + +const fake = defineAdapter({ + name: 'fake', + extractRequest: (ctx) => ctx.request, + middleware: (config) => ({ tag: MIDDLEWARE_SENTINEL, config }), +}) + +describe('defineAdapter — one-arg form (middleware)', () => { + it('returns whatever the spec.middleware factory returns, passing config through', () => { + const result = fake({ auth: 'user' }) + expect(result).toEqual({ + tag: MIDDLEWARE_SENTINEL, + config: { auth: 'user' }, + }) + }) + + it('accepts a no-arg call (config is undefined)', () => { + const result = fake() + expect(result).toEqual({ + tag: MIDDLEWARE_SENTINEL, + config: undefined, + }) + }) + + it('does not call base.withSupabase for the one-arg form', () => { + baseMock.withSupabase.mockClear() + fake({ auth: 'user' }) + expect(baseMock.withSupabase).not.toHaveBeenCalled() + }) +}) + +describe('defineAdapter — two-arg form (route handler)', () => { + it('forwards config and handler to base, augmenting cors: false', () => { + baseMock.withSupabase.mockReturnValueOnce(async () => new Response()) + + const config = { auth: 'user' } as const + const handler = async () => Response.json({}) + + fake(config, handler) + + expect(baseMock.withSupabase).toHaveBeenLastCalledWith( + { auth: 'user', cors: false }, + handler, + ) + }) + + it('passes a plain Request straight through to base', async () => { + const baseResponse = new Response('ok') + const inner = vi.fn(async () => baseResponse) + baseMock.withSupabase.mockReturnValueOnce(inner) + + const wrapped = fake({ auth: 'user' }, async () => new Response()) + const req = new Request('https://example.test/') + + const res = await wrapped(req) + + expect(inner).toHaveBeenCalledWith(req) + expect(res).toBe(baseResponse) + }) + + it('extracts the Request from the framework context and forwards it', async () => { + const baseResponse = new Response('ok') + const inner = vi.fn(async () => baseResponse) + baseMock.withSupabase.mockReturnValueOnce(inner) + + const wrapped = fake({ auth: 'user' }, async () => new Response()) + const req = new Request('https://example.test/') + + const res = await wrapped({ request: req }) + + expect(inner).toHaveBeenCalledWith(req) + expect(res).toBe(baseResponse) + }) + + it('throws TypeError with the adapter name when input is unrecognized', () => { + baseMock.withSupabase.mockReturnValueOnce(async () => new Response()) + + const wrapped = fake({ auth: 'user' }, async () => new Response()) + + try { + // @ts-expect-error — intentionally wrong shape + wrapped({ wrong: 'shape' }) + expect.fail('should have thrown') + } catch (e) { + expect(e).toBeInstanceOf(TypeError) + expect((e as Error).message).toContain('@supabase/server/adapters/fake') + expect((e as Error).message).toContain('Object') + } + }) +}) + +describe('defineAdapter — getExistingContext (skip-if-set)', () => { + const skip = defineAdapter({ + name: 'skip-fake', + extractRequest: (ctx) => ctx.request, + getExistingContext: (ctx) => ctx.supabaseContext, + middleware: (config) => ({ tag: MIDDLEWARE_SENTINEL, config }), + }) + + it('invokes the handler directly with the existing ctx when present', async () => { + const inner = vi.fn() + baseMock.withSupabase.mockReturnValueOnce(inner) + + const userHandler = vi.fn(async () => Response.json({ ok: true })) + const wrapped = skip({ auth: 'user' }, userHandler) + + const req = new Request('https://example.test/') + const existingCtx = { authMode: 'user' } as unknown as SupabaseContext + const res = await wrapped({ request: req, supabaseContext: existingCtx }) + + expect(inner).not.toHaveBeenCalled() + expect(userHandler).toHaveBeenCalledWith(req, existingCtx) + expect(res.status).toBe(200) + }) + + it('falls through to base when no existing ctx is attached', async () => { + const inner = vi.fn(async () => new Response('via base')) + baseMock.withSupabase.mockReturnValueOnce(inner) + + const userHandler = vi.fn(async () => new Response()) + const wrapped = skip({ auth: 'user' }, userHandler) + + const req = new Request('https://example.test/') + await wrapped({ request: req }) + + expect(userHandler).not.toHaveBeenCalled() + expect(inner).toHaveBeenCalledWith(req) + }) + + it('does not consult getExistingContext when input is a plain Request', async () => { + const inner = vi.fn(async () => new Response('via base')) + baseMock.withSupabase.mockReturnValueOnce(inner) + + const userHandler = vi.fn(async () => new Response()) + const wrapped = skip({ auth: 'user' }, userHandler) + + const req = new Request('https://example.test/') + await wrapped(req) + + expect(userHandler).not.toHaveBeenCalled() + expect(inner).toHaveBeenCalledWith(req) + }) +}) + +describe('defineAdapter — throwAuthError', () => { + class FrameworkError extends Error { + readonly cause: AuthError + constructor(error: AuthError) { + super('framework-native') + this.cause = error + } + } + + const throwing = defineAdapter({ + name: 'throw-fake', + extractRequest: (ctx) => ctx.request, + throwAuthError: (error) => { + throw new FrameworkError(error) + }, + middleware: (config) => ({ tag: MIDDLEWARE_SENTINEL, config }), + }) + + it('passes throwAuthError as onAuthError on the base config (two-arg form)', () => { + baseMock.withSupabase.mockReturnValueOnce(async () => new Response()) + + throwing({ auth: 'user' }, async () => new Response()) + + const [calledConfig] = baseMock.withSupabase.mock.calls.at(-1) as [ + WithSupabaseConfig, + unknown, + ] + expect(calledConfig.onAuthError).toBeTypeOf('function') + expect(calledConfig.cors).toBe(false) + }) + + it('does not pass onAuthError when throwAuthError is omitted', () => { + baseMock.withSupabase.mockReturnValueOnce(async () => new Response()) + + fake({ auth: 'user' }, async () => new Response()) + + const [calledConfig] = baseMock.withSupabase.mock.calls.at(-1) as [ + WithSupabaseConfig, + unknown, + ] + expect(calledConfig.onAuthError).toBeUndefined() + }) +}) diff --git a/src/core/adapters/define-adapter.ts b/src/core/adapters/define-adapter.ts new file mode 100644 index 0000000..8c9a019 --- /dev/null +++ b/src/core/adapters/define-adapter.ts @@ -0,0 +1,204 @@ +import type { AuthError } from '../../errors.js' +import type { SupabaseContext, WithSupabaseConfig } from '../../types.js' +import { withSupabase as baseWithSupabase } from '../../with-supabase.js' + +/** + * Spec for {@link defineAdapter}. + * + * @template NativeContext - The framework's native route-handler input + * (e.g. Hono's `Context`, H3's `H3Event`, Elysia's route args). The + * produced two-arg `withSupabase` accepts `Request | NativeContext` + * and extracts the underlying Request via {@link extractRequest}. + * @template MiddlewareReturn - What the adapter's one-arg + * `withSupabase(config)` returns — typically the framework's native + * middleware or plugin type. Inferred from {@link middleware}. + */ +export interface AdapterSpec { + /** Adapter name, surfaced in error messages (e.g. `'hono'`). */ + name: string + + /** + * Returns the underlying `Request` carried by the framework's native + * route input. Should return `undefined` when the input isn't + * recognized as a `NativeContext` — `defineAdapter` will then throw + * a `TypeError` naming the adapter. + */ + extractRequest: (input: NativeContext) => Request | undefined + + /** + * Returns an existing {@link SupabaseContext} already attached to the + * framework's native input by an upstream middleware/plugin (e.g. + * `c.var.supabaseContext` for Hono). When this returns a value, the + * two-arg form skips base auth and invokes the inner handler + * directly with the existing context — matching the + * skip-if-already-set behavior of the one-arg middleware form. + * + * Omit to disable skip behavior (every two-arg call runs base auth). + */ + getExistingContext?: (input: NativeContext) => SupabaseContext | undefined + + /** + * Maps an `AuthError` from base auth into a framework-native error + * thrown into the framework's error pipeline (e.g. Hono's + * `HTTPException` → `onError`). Must throw — return type is `never` + * and any returned value is ignored. + * + * When provided, passed as `onAuthError` to base on every two-arg + * call. Omit to fall back to base's default JSON error response. + */ + throwAuthError?: (error: AuthError) => never + + /** + * Builds the framework-native middleware/plugin that the one-arg + * `withSupabase(config)` returns. The return type is captured by + * {@link MiddlewareReturn} and flows through to the adapter's + * exported `withSupabase` so consumers see the right native type. + * + * This is the only bespoke part of the adapter — its body uses the + * framework's own primitives (`createMiddleware`, `defineMiddleware`, + * `new Elysia().resolve(...)`, etc.) and is genuinely + * framework-specific. The two-arg dual-mode form is generated by + * `defineAdapter` and identical across adapters. + */ + middleware: (config?: Omit) => MiddlewareReturn +} + +/** + * The fully overloaded `withSupabase` function each adapter exports. + * + * - **One arg** — `withSupabase(config)` returns the framework-native + * middleware/plugin built by {@link AdapterSpec.middleware}. + * - **Two args** — `withSupabase(config, handler)` returns a dual-mode + * route handler that accepts either a `Request` (Web Fetch use) or + * the framework's `NativeContext` (mounted on a route), extracts the + * underlying Request, and runs base `withSupabase` against it. + */ +export interface AdapterWithSupabase { + (config?: Omit): MiddlewareReturn + ( + config: Omit, + handler: (req: Request, ctx: SupabaseContext) => Promise, + ): (input: Request | NativeContext) => Promise + ( + config: Omit, + handler: ( + req: Request, + ctx: SupabaseContext, + ) => Promise, + ): (input: Request | NativeContext) => Promise +} + +/** + * Build a framework adapter's `withSupabase` export. + * + * Returns a single fully-overloaded `withSupabase` function. The + * one-arg form dispatches to the spec's `middleware` factory (the + * framework-native middleware/plugin). The two-arg form is generated + * by `defineAdapter` — a dual-mode route handler that accepts either + * `Request` or the framework's native input, extracts the underlying + * Request, and runs base `withSupabase` against it. + * + * The two-arg form is uniform across adapters (only the + * extraction function varies); the one-arg form is each framework's + * own idiom (middleware function for Hono/H3, plugin object for + * Elysia, etc.). `defineAdapter` centralizes the former and lets each + * adapter own the latter via the {@link AdapterSpec.middleware} field. + * + * On the two-arg form: + * - **CORS is forced off** (`cors: false`). CORS belongs to the + * framework's CORS middleware, not the adapter. + * - **`getExistingContext`** (optional) lets the two-arg form skip + * base auth when an upstream middleware already populated + * `supabaseContext`, matching one-arg skip-if-set behavior. + * - **`throwAuthError`** (optional) maps auth failures to a + * framework-native error thrown into the framework's error + * pipeline, matching one-arg error semantics. + * + * @example Hono + * ```ts + * import type { Context, MiddlewareHandler } from 'hono' + * import { HTTPException } from 'hono/http-exception' + * import { createMiddleware } from 'hono/factory' + * import { defineAdapter } from '@supabase/server/core/adapters' + * + * export const withSupabase = defineAdapter< + * Context, + * MiddlewareHandler<{ Variables: { supabaseContext: SupabaseContext } }> + * >({ + * name: 'hono', + * extractRequest: (c) => c.req.raw, + * getExistingContext: (c) => c.var.supabaseContext, + * throwAuthError: (error) => { + * throw new HTTPException(error.status as 401 | 500, { + * message: error.message, + * cause: error, + * }) + * }, + * middleware: (config) => + * createMiddleware<{ Variables: { supabaseContext: SupabaseContext } }>( + * async (c, next) => { + * // framework-specific body (skip-if-set, throw HTTPException, etc.) + * }, + * ), + * }) + * ``` + */ +export function defineAdapter( + spec: AdapterSpec, +): AdapterWithSupabase { + function extract(input: Request | NativeContext): Request { + if (input instanceof Request) return input + const req = spec.extractRequest(input) + if (!(req instanceof Request)) { + throw new TypeError(buildErrorMessage(spec.name, input)) + } + return req + } + + function twoArg( + config: Omit, + handler: ( + req: Request, + ctx: SupabaseContext, + ) => Promise, + ): (input: Request | NativeContext) => Promise { + const baseConfig: WithSupabaseConfig = { + ...config, + cors: false, + ...(spec.throwAuthError ? { onAuthError: spec.throwAuthError } : {}), + } + const inner = baseWithSupabase(baseConfig, handler) + + return (input) => { + if (input instanceof Request) return inner(input) + const req = extract(input) + const existing = spec.getExistingContext?.(input) + if (existing) return handler(req, existing as SupabaseContext) + return inner(req) + } + } + + function withSupabase( + config?: WithSupabaseConfig, + handler?: (req: Request, ctx: SupabaseContext) => Promise, + ): + | MiddlewareReturn + | ((input: Request | NativeContext) => Promise) { + if (handler) return twoArg(config!, handler) + return spec.middleware(config) + } + + return withSupabase as AdapterWithSupabase +} + +function buildErrorMessage(name: string, received: unknown): string { + const what = + received === null || typeof received !== 'object' + ? typeof received + : ((received as { constructor?: { name?: string } }).constructor?.name ?? + 'object') + return ( + `withSupabase from @supabase/server/adapters/${name} expected a Request or a ${name} route context, ` + + `but received ${what}. Mount via \`app.all(path, withSupabase(config, handler))\` (or the equivalent for your framework).` + ) +} diff --git a/src/core/adapters/index.ts b/src/core/adapters/index.ts new file mode 100644 index 0000000..2f3c137 --- /dev/null +++ b/src/core/adapters/index.ts @@ -0,0 +1,5 @@ +export { + defineAdapter, + type AdapterSpec, + type AdapterWithSupabase, +} from './define-adapter.js' diff --git a/src/types.ts b/src/types.ts index 438e11e..d7c3739 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,6 +3,8 @@ import type { SupabaseClientOptions, } from '@supabase/supabase-js' +import type { AuthError } from './errors.js' + /** * Authentication mode that determines what credentials a request must provide. * @@ -289,6 +291,23 @@ export interface WithSupabaseConfig { * ``` */ supabaseOptions?: SupabaseClientOptions + + /** + * Callback invoked when auth fails, before the default JSON error + * response is built. The callback must throw — its return type is + * `never` and any value it returns is ignored. Use this to map auth + * failures into framework-native errors that flow through your + * framework's error pipeline (`HTTPException` for Hono, + * `HTTPError` for H3, a custom error class for Elysia, etc.). + * + * When omitted (the default), {@link withSupabase} returns the auth + * error as a JSON response — the original behavior, unchanged. + * + * Framework adapters set this internally via `defineAdapter`'s + * `throwAuthError` hook so the two-arg form's auth failures match + * the one-arg middleware/plugin form's error pipeline. + */ + onAuthError?: (error: AuthError) => never } /** diff --git a/src/with-supabase.ts b/src/with-supabase.ts index b155184..d5425bb 100644 --- a/src/with-supabase.ts +++ b/src/with-supabase.ts @@ -42,6 +42,7 @@ export function withSupabase( config, ) if (error) { + if (config.onAuthError) config.onAuthError(error) return Response.json( { message: error.message, code: error.code }, { diff --git a/tsdown.config.ts b/tsdown.config.ts index 029b6fe..6e0af41 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ entry: [ 'src/index.ts', 'src/core/index.ts', + 'src/core/adapters/index.ts', 'src/adapters/hono/index.ts', 'src/adapters/h3/index.ts', 'src/adapters/elysia/index.ts',