Skip to content
Open
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
15 changes: 6 additions & 9 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
12 changes: 3 additions & 9 deletions jsr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
}
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
79 changes: 78 additions & 1 deletion src/adapters/elysia/plugin.test.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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 })
})
})
161 changes: 99 additions & 62 deletions src/adapters/elysia/plugin.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<string, never>` 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<string, never>`
// would not satisfy the corresponding generic constraints.
/* eslint-disable @typescript-eslint/no-empty-object-type */
export function withSupabase(config?: Omit<WithSupabaseConfig, 'cors'>): Elysia<
type SupabasePlugin = Elysia<
'',
{ decorator: {}; store: {}; derive: {}; resolve: {} },
{ typebox: {}; error: { readonly SupabaseError: SupabaseError } },
Expand All @@ -87,19 +48,95 @@ export function withSupabase(config?: Omit<WithSupabaseConfig, 'cors'>): 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'),
})
Loading
Loading