diff --git a/README.md b/README.md index f64f677..6bba518 100644 --- a/README.md +++ b/README.md @@ -262,12 +262,13 @@ withSupabase( ## Framework Adapters -Adapters wrap `withSupabase` for a specific framework's middleware contract. **All adapters are community-maintained** — both Hono and H3 originated as community contributions. They live in this repo and ship with the core package, so a single `npm install @supabase/server` covers the framework you're using. See [`src/adapters/README.md`](src/adapters/README.md) for the maintenance model and the requirements for contributing a new adapter. +Adapters wrap `withSupabase` for a specific framework's middleware contract. **All adapters are community-maintained** — Hono, H3, and Elysia all originated as community contributions. They live in this repo and ship with the core package, so a single `npm install @supabase/server` covers the framework you're using. See [`src/adapters/README.md`](src/adapters/README.md) for the maintenance model and the requirements for contributing a new adapter. -| Framework | Import | Framework version | Docs | -| --------- | -------------------------------- | ----------------- | ---------------------------------------------- | -| Hono | `@supabase/server/adapters/hono` | `^4.0.0` | [docs/adapters/hono.md](docs/adapters/hono.md) | -| H3 / Nuxt | `@supabase/server/adapters/h3` | `^2.0.0` | [docs/adapters/h3.md](docs/adapters/h3.md) | +| Framework | Import | Framework version | Docs | +| --------- | ---------------------------------- | ----------------- | -------------------------------------------------- | +| Hono | `@supabase/server/adapters/hono` | `^4.0.0` | [docs/adapters/hono.md](docs/adapters/hono.md) | +| H3 / Nuxt | `@supabase/server/adapters/h3` | `^2.0.0` | [docs/adapters/h3.md](docs/adapters/h3.md) | +| Elysia | `@supabase/server/adapters/elysia` | `^1.4.0` | [docs/adapters/elysia.md](docs/adapters/elysia.md) | ### Hono @@ -297,6 +298,48 @@ export default { fetch: app.fetch } See [docs/adapters/h3.md](docs/adapters/h3.md) for per-route auth, Nuxt server-middleware patterns, CORS, and more. +### Elysia + +```ts +import { Elysia } from 'elysia' +import { withSupabase } from '@supabase/server/adapters/elysia' + +const app = new Elysia() + // Protected — plugin resolves supabaseContext before handlers run + .use(withSupabase({ auth: 'user' })) + .get('/games', async ({ supabaseContext }) => { + const { data: myGames } = await supabaseContext.supabase + .from('favorite_games') + .select() + return myGames + }) + // Public — no plugin means no auth + .get('/health', () => ({ status: 'ok' })) + +app.listen(3000) +``` + +For per-route auth, use scoped groups: + +```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({ auth: 'user' })) + .get('/profile', async ({ supabaseContext }) => { + return supabaseContext.userClaims + }), + ) + +app.listen(3000) +``` + +The adapter does not handle CORS — use `@elysiajs/cors` for that. + ## Primitives For when you need more control than `withSupabase` provides — multiple routes with different auth, custom response headers, or building your own wrapper. @@ -422,7 +465,7 @@ For other environments, pass overrides via the `env` config option or `resolveEn - **Supabase Edge Functions** — environment variables are auto-injected. Zero config. - **Deno / Bun** — works out of the box with the `export default { fetch }` pattern. -- **Node.js** — use the [Hono adapter](#hono), [H3 adapter](#h3--nuxt), or [core primitives](#primitives) with your framework of choice. +- **Node.js** — use the [Hono adapter](#hono), [H3 adapter](#h3--nuxt), [Elysia adapter](#elysia), or [core primitives](#primitives) with your framework of choice. - **Cloudflare Workers** — enable `nodejs_compat` in `wrangler.toml` or pass env overrides via the `env` config option. - **Nuxt** — use the [H3 adapter](#h3--nuxt) directly as a server middleware. - **Next.js / SvelteKit / Remix** — compose with [`@supabase/ssr`](https://github.com/supabase/ssr): `@supabase/ssr` owns cookies + refresh-token rotation, `@supabase/server` adds verified claims and typed RLS / admin clients on top. See [`docs/ssr-frameworks.md`](docs/ssr-frameworks.md). @@ -433,12 +476,13 @@ No. `@supabase/ssr` handles cookie-based session management for frameworks like ## Exports -| Export | What's in it | -| -------------------------------- | ----------------------------------------------------------------------------------------------------------------- | -| `@supabase/server` | `withSupabase`, `createSupabaseContext` | -| `@supabase/server/core` | `verifyAuth`, `verifyCredentials`, `extractCredentials`, `createContextClient`, `createAdminClient`, `resolveEnv` | -| `@supabase/server/adapters/hono` | `withSupabase` (Hono middleware) | -| `@supabase/server/adapters/h3` | `withSupabase` (H3 / Nuxt middleware) | +| Export | What's in it | +| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `@supabase/server` | `withSupabase`, `createSupabaseContext` | +| `@supabase/server/core` | `verifyAuth`, `verifyCredentials`, `extractCredentials`, `createContextClient`, `createAdminClient`, `resolveEnv` | +| `@supabase/server/adapters/hono` | `withSupabase` (Hono middleware) | +| `@supabase/server/adapters/h3` | `withSupabase` (H3 / Nuxt middleware) | +| `@supabase/server/adapters/elysia` | `withSupabase` (Elysia plugin) | ## Documentation @@ -449,6 +493,7 @@ No. `@supabase/ssr` handles cookie-based session management for frameworks like | Which framework adapters exist? How do I contribute one? | [`src/adapters/README.md`](src/adapters/README.md) | | How do I use this with Hono? | [`docs/adapters/hono.md`](docs/adapters/hono.md) | | How do I use this with H3 / Nuxt? | [`docs/adapters/h3.md`](docs/adapters/h3.md) | +| How do I use this with Elysia ? | [`docs/adapters/elysia.md`](docs/adapters/elysia.md) | | How do I use low-level primitives for custom flows? | [`docs/core-primitives.md`](docs/core-primitives.md) | | How do environment variables work across runtimes? | [`docs/environment-variables.md`](docs/environment-variables.md) | | How do I handle errors? What codes exist? | [`docs/error-handling.md`](docs/error-handling.md) | diff --git a/docs/adapters/elysia.md b/docs/adapters/elysia.md new file mode 100644 index 0000000..3b19a8e --- /dev/null +++ b/docs/adapters/elysia.md @@ -0,0 +1,141 @@ +# Elysia Adapter + +## Setup + +Install Elysia as a peer dependency: + +```bash +pnpm add elysia +``` + +The adapter exports its own `withSupabase` that returns an Elysia plugin instead of a fetch handler. + +## Basic app with auth + +```ts +import { Elysia } from 'elysia' +import { withSupabase } from '@supabase/server/adapters/elysia' + +const app = new Elysia() + .use(withSupabase({ auth: 'user' })) + .get('/todos', async ({ supabaseContext }) => { + const { data } = await supabaseContext.supabase.from('todos').select() + return data + }) + +app.listen(3000) +``` + +The context is available as `supabaseContext` in your route handlers and contains the same `SupabaseContext` fields as the main `withSupabase` wrapper: `supabase`, `supabaseAdmin`, `userClaims`, `jwtClaims`, and `authMode`. + +## Per-route auth + +Apply different auth modes to different routes by using the plugin on scoped route groups: + +```ts +import { Elysia } from 'elysia' +import { withSupabase } from '@supabase/server/adapters/elysia' + +const app = new Elysia() + // Public route — no auth + .get('/health', () => ({ status: 'ok' })) + // User-authenticated routes + .group('/api', (app) => + app + .use(withSupabase({ auth: 'user' })) + .get('/todos', async ({ supabaseContext }) => { + const { data } = await supabaseContext.supabase.from('todos').select() + return data + }), + ) + // Secret-key-protected admin routes + .group('/admin', (app) => + app + .use(withSupabase({ auth: 'secret' })) + .post('/sync', async ({ supabaseContext }) => { + const { data } = await supabaseContext.supabaseAdmin + .from('audit_log') + .insert({ action: 'sync' }) + return data + }), + ) + +app.listen(3000) +``` + +## Skip behavior + +If a previous plugin already resolved `supabaseContext`, subsequent `withSupabase` calls skip auth. This allows chaining plugins without redundant work. + +**Important:** The plugin calls `.as('scoped')` so its `resolve` hook propagates one level up to the parent app — routes registered after `.use(withSupabase(...))` will see `supabaseContext`. The skip-if-set pattern cannot make a route stricter than an already-resolved context. + +For routes that need different auth than the rest of the app, use scoped `.group()` with `.use(withSupabase(...))` without an app-wide plugin (see the "Per-route auth" section above). + +## CORS + +The Elysia adapter does not handle CORS — the `cors` option is excluded from its config type. Use Elysia's CORS plugin: + +```ts +import { Elysia } from 'elysia' +import { cors } from '@elysiajs/cors' +import { withSupabase } from '@supabase/server/adapters/elysia' + +const app = new Elysia() + .use(cors()) + .use(withSupabase({ auth: 'user' })) + .get('/todos', async ({ supabaseContext }) => { + const { data } = await supabaseContext.supabase.from('todos').select() + return data + }) + +app.listen(3000) +``` + +## Error handling + +When auth fails, the plugin throws an error with the correct HTTP status code set. The original `AuthError` is available via `error.cause` in an `onError` handler: + +```ts +import { Elysia } from 'elysia' +import { withSupabase } from '@supabase/server/adapters/elysia' + +const app = new Elysia() + .use(withSupabase({ auth: 'user' })) + .onError(({ code, error, status }) => { + if (code !== 'SupabaseAuthError') return + const cause = error.cause as { code?: string; status?: number } | undefined + return status((cause?.status as 401) ?? 500, { + error: error.message, + code: cause?.code, + }) + }) + .get('/todos', async ({ supabaseContext }) => { + const { data } = await supabaseContext.supabase.from('todos').select() + return data + }) + +app.listen(3000) +``` + +Without a custom `onError`, Elysia uses the `status` property on the thrown error to set the response status automatically (401 for auth failures, 500 for internal errors). + +## Environment overrides + +Pass `env` to override auto-detected environment variables, same as the main wrapper: + +```ts +app.use(withSupabase({ auth: 'user', env: { url: 'http://localhost:54321' } })) +``` + +## Supabase client options + +Forward options to the underlying `createClient()` calls: + +```ts +app.use( + withSupabase({ + auth: 'user', + supabaseOptions: { db: { schema: 'api' } }, + }), +) +``` diff --git a/docs/api-reference.md b/docs/api-reference.md index 5fdf7c0..8644251 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -134,6 +134,38 @@ Defaults to `auth: 'user'` when config is omitted. --- +## @supabase/server/adapters/h3 + +### withSupabase (H3) + +```ts +function withSupabase(config?: Omit): Middleware +``` + +H3 middleware. Sets `event.context.supabaseContext` on the H3 event. Throws `HTTPError` on auth failure with `cause: AuthError`. + +Skips if `event.context.supabaseContext` is already set (enables chained middleware). + +Defaults to `auth: 'user'` when config is omitted. + +--- + +## @supabase/server/adapters/elysia + +### withSupabase (Elysia) + +```ts +function withSupabase(config?: Omit): Elysia +``` + +Elysia plugin that resolves `supabaseContext` into the request context. Throws an error on auth failure with `cause: AuthError`. + +Skips if `supabaseContext` is already resolved by a prior plugin. + +Defaults to `auth: 'user'` when config is omitted. + +--- + ## Types ### AuthMode diff --git a/jsr.json b/jsr.json index f39153a..90a3068 100644 --- a/jsr.json +++ b/jsr.json @@ -4,17 +4,12 @@ "exports": { ".": "./src/index.ts", "./core": "./src/core/index.ts", - "./adapters/hono": "./src/adapters/hono/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 2ce1dc0..26b4278 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,11 @@ "import": "./dist/adapters/h3/index.mjs", "require": "./dist/adapters/h3/index.cjs" }, + "./adapters/elysia": { + "types": "./dist/adapters/elysia/index.d.mts", + "import": "./dist/adapters/elysia/index.mjs", + "require": "./dist/adapters/elysia/index.cjs" + }, "./package.json": "./package.json" }, "main": "./dist/index.cjs", @@ -76,7 +81,8 @@ "peerDependencies": { "@supabase/supabase-js": "^2.0.0", "h3": "^2.0.0", - "hono": "^4.0.0" + "hono": "^4.0.0", + "elysia": "^1.4.0" }, "peerDependenciesMeta": { "h3": { @@ -84,6 +90,9 @@ }, "hono": { "optional": true + }, + "elysia": { + "optional": true } }, "devDependencies": { @@ -91,6 +100,7 @@ "@commitlint/config-conventional": "^20.4.2", "@supabase/supabase-js": "^2.98.0", "eslint": "^10.0.2", + "elysia": "^1.0.0", "h3": "2.0.1-rc.20", "hono": "^4.12.5", "prettier": "3.8.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d17c23..1b08dee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,9 @@ importers: '@supabase/supabase-js': specifier: ^2.98.0 version: 2.98.0 + elysia: + specifier: ^1.0.0 + version: 1.4.28(@sinclair/typebox@0.34.49)(exact-mirror@1.0.0)(file-type@22.0.1)(openapi-types@12.1.3)(typescript@5.9.3) eslint: specifier: ^10.0.2 version: 10.0.2(jiti@2.6.1) @@ -86,6 +89,9 @@ packages: resolution: {integrity: sha512-ubmJ6TShyaD69VE9DQrlXcdkvJbmwWPB8qYj0H2kaJi29O7vJT9ajSdBd2W8CG34pwL9pYA74fi7RHC1qbLoVQ==} engines: {node: ^20.19.0 || >=22.12.0} + '@borewit/text-codec@0.2.2': + resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + '@commitlint/cli@20.4.2': resolution: {integrity: sha512-YjYSX2yj/WsVoxh9mNiymfFS2ADbg2EK4+1WAsMuckwKMCqJ5PDG0CJU/8GvmHWcv4VRB2V02KqSiecRksWqZQ==} engines: {node: '>=v18'} @@ -430,24 +436,28 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.3': resolution: {integrity: sha512-Z03/wrqau9Bicfgb3Dbs6SYTHliELk2PM2LpG2nFd+cGupTMF5kanLEcj2vuuJLLhptNyS61rtk7SOZ+lPsTUA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.3': resolution: {integrity: sha512-iSXXZsQp08CSilff/DCTFZHSVEpEwdicV3W8idHyrByrcsRDVh9sGC3sev6d8BygSGj3vt8GvUKBPCoyMA4tgQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.3': resolution: {integrity: sha512-qaj+MFudtdCv9xZo9znFvkgoajLdc+vwf0Kz5N44g+LU5XMe+IsACgn3UG7uTRlCCvhMAGXm1XlpEA5bZBrOcw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.3': resolution: {integrity: sha512-U662UnMETyjT65gFmG9ma+XziENrs7BBnENi/27swZPYagubfHRirXHG2oMl+pEax2WvO7Kb9gHZmMakpYqBHQ==} @@ -509,66 +519,79 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -615,6 +638,9 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@sinclair/typebox@0.34.49': + resolution: {integrity: sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -642,6 +668,13 @@ packages: resolution: {integrity: sha512-Ohc97CtInLwZyiSASz7tT9/Abm/vqnIbO9REp+PivVUII8UZsuI3bngRQnYgJdFoOIwvaEII1fX1qy8x0CyNiw==} engines: {node: '>=20.0.0'} + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -858,6 +891,10 @@ packages: engines: {node: '>=18'} hasBin: true + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + cosmiconfig-typescript-loader@6.2.0: resolution: {integrity: sha512-GEN39v7TgdxgIoNcdkRE3uiAzQt3UXLyHbRHD6YoL048XAeOomyxaP+Hh/+2C6C2wYjxJ2onhJcsQp+L4YEkVQ==} engines: {node: '>=v18'} @@ -911,6 +948,21 @@ packages: oxc-resolver: optional: true + elysia@1.4.28: + resolution: {integrity: sha512-Vrx8sBnvq8squS/3yNBzR1jBXI+SgmnmvwawPjNuEHndUe5l1jV2Gp6JJ4ulDkEB8On6bWmmuyPpA+bq4t+WYg==} + peerDependencies: + '@sinclair/typebox': '>= 0.34.0 < 1' + '@types/bun': '>= 1.2.0' + exact-mirror: '>= 0.0.9' + file-type: '>= 20.0.0' + openapi-types: '>= 12.0.0' + typescript: '>= 5.0.0' + peerDependenciesMeta: + '@types/bun': + optional: true + typescript: + optional: true + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -990,10 +1042,21 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + exact-mirror@1.0.0: + resolution: {integrity: sha512-tB6QSwlyUDZh22vS4ytBjmTvpMJ7eNNqSUtH4w7TpQsE7//V+MsdWUhO0B1UptzStDFHQBCxfJPtDDiVaFfRyQ==} + peerDependencies: + typebox: '>= 1.1.0' + peerDependenciesMeta: + typebox: + optional: true + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1019,6 +1082,10 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + file-type@22.0.1: + resolution: {integrity: sha512-ww5Mhre0EE+jmBvOXTmXAbEMuZE7uX4a3+oRCQFNj8w++g3ev913N6tXQz0XTXbueQ5TWQfm6BdaViEHHn8bhA==} + engines: {node: '>=22'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -1077,6 +1144,9 @@ packages: resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==} engines: {node: '>=20.0.0'} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1212,6 +1282,9 @@ packages: mdurl@2.0.0: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + memoirist@0.4.0: + resolution: {integrity: sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg==} + meow@12.1.1: resolution: {integrity: sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==} engines: {node: '>=16.10'} @@ -1220,10 +1293,6 @@ packages: resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} engines: {node: '>=18'} - minimatch@10.2.4: - resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} - engines: {node: 18 || 20 || >=22} - minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -1249,6 +1318,9 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1416,6 +1488,10 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strtok3@10.3.5: + resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} + engines: {node: '>=18'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1434,6 +1510,10 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + token-types@6.1.2: + resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} + engines: {node: '>=14.16'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -1498,6 +1578,10 @@ packages: uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + unconfig-core@7.5.0: resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} @@ -1674,6 +1758,8 @@ snapshots: '@babel/helper-string-parser': 8.0.0-rc.2 '@babel/helper-validator-identifier': 8.0.0-rc.1 + '@borewit/text-codec@0.2.2': {} + '@commitlint/cli@20.4.2(@types/node@25.3.0)(typescript@5.9.3)': dependencies: '@commitlint/format': 20.4.0 @@ -1888,7 +1974,7 @@ snapshots: dependencies: '@eslint/object-schema': 3.0.2 debug: 4.4.3 - minimatch: 10.2.4 + minimatch: 10.2.5 transitivePeerDependencies: - supports-color @@ -2093,6 +2179,8 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@sinclair/typebox@0.34.49': {} + '@standard-schema/spec@1.1.0': {} '@supabase/auth-js@2.98.0': @@ -2133,6 +2221,15 @@ snapshots: - bufferutil - utf-8-validate + '@tokenizer/inflate@0.4.1': + dependencies: + debug: 4.4.3 + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -2236,7 +2333,7 @@ snapshots: '@typescript-eslint/types': 8.56.1 '@typescript-eslint/visitor-keys': 8.56.1 debug: 4.4.3 - minimatch: 10.2.4 + minimatch: 10.2.5 semver: 7.7.4 tinyglobby: 0.2.15 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -2382,6 +2479,8 @@ snapshots: dependencies: meow: 13.2.0 + cookie@1.1.1: {} + cosmiconfig-typescript-loader@6.2.0(@types/node@25.3.0)(cosmiconfig@9.0.0(typescript@5.9.3))(typescript@5.9.3): dependencies: '@types/node': 25.3.0 @@ -2420,6 +2519,18 @@ snapshots: dts-resolver@2.1.3: {} + elysia@1.4.28(@sinclair/typebox@0.34.49)(exact-mirror@1.0.0)(file-type@22.0.1)(openapi-types@12.1.3)(typescript@5.9.3): + dependencies: + '@sinclair/typebox': 0.34.49 + cookie: 1.1.1 + exact-mirror: 1.0.0 + fast-decode-uri-component: 1.0.1 + file-type: 22.0.1 + memoirist: 0.4.0 + openapi-types: 12.1.3 + optionalDependencies: + typescript: 5.9.3 + emoji-regex@8.0.0: {} empathic@2.0.0: {} @@ -2507,7 +2618,7 @@ snapshots: imurmurhash: 0.1.4 is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 - minimatch: 10.2.4 + minimatch: 10.2.5 natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: @@ -2537,8 +2648,12 @@ snapshots: esutils@2.0.3: {} + exact-mirror@1.0.0: {} + expect-type@1.3.0: {} + fast-decode-uri-component@1.0.1: {} + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} @@ -2555,6 +2670,15 @@ snapshots: dependencies: flat-cache: 4.0.1 + file-type@22.0.1: + dependencies: + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.5 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -2601,6 +2725,8 @@ snapshots: iceberg-js@0.8.1: {} + ieee754@1.2.1: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -2704,14 +2830,12 @@ snapshots: mdurl@2.0.0: {} + memoirist@0.4.0: {} + meow@12.1.1: {} meow@13.2.0: {} - minimatch@10.2.4: - dependencies: - brace-expansion: 5.0.5 - minimatch@10.2.5: dependencies: brace-expansion: 5.0.5 @@ -2728,6 +2852,8 @@ snapshots: obug@2.1.1: {} + openapi-types@12.1.3: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -2904,6 +3030,10 @@ snapshots: dependencies: ansi-regex: 5.0.1 + strtok3@10.3.5: + dependencies: + '@tokenizer/token': 0.3.0 + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -2917,6 +3047,12 @@ snapshots: tinyrainbow@3.0.3: {} + token-types@6.1.2: + dependencies: + '@borewit/text-codec': 0.2.2 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + tree-kill@1.2.2: {} ts-api-utils@2.4.0(typescript@5.9.3): @@ -2980,6 +3116,8 @@ snapshots: uc.micro@2.1.0: {} + uint8array-extras@1.5.0: {} + unconfig-core@7.5.0: dependencies: '@quansync/fs': 1.0.0 diff --git a/src/adapters/README.md b/src/adapters/README.md index 742ed35..4130c43 100644 --- a/src/adapters/README.md +++ b/src/adapters/README.md @@ -4,16 +4,17 @@ You're in the adapter source folder. Framework adapters wrap `withSupabase` and ## Available adapters -| Framework | Import | Framework version | Docs | -| --------- | -------------------------------- | ----------------- | ---------------------------------------------------- | -| Hono | `@supabase/server/adapters/hono` | `^4.0.0` | [docs/adapters/hono.md](../../docs/adapters/hono.md) | -| H3 / Nuxt | `@supabase/server/adapters/h3` | `^2.0.0` | [docs/adapters/h3.md](../../docs/adapters/h3.md) | +| Framework | Import | Framework version | Docs | +| --------- | ---------------------------------- | ----------------- | -------------------------------------------------------- | +| Hono | `@supabase/server/adapters/hono` | `^4.0.0` | [docs/adapters/hono.md](../../docs/adapters/hono.md) | +| H3 / Nuxt | `@supabase/server/adapters/h3` | `^2.0.0` | [docs/adapters/h3.md](../../docs/adapters/h3.md) | +| Elysia | `@supabase/server/adapters/elysia` | `^1.4.0` | [docs/adapters/elysia.md](../../docs/adapters/elysia.md) | The framework version reflects what the adapter is tested against. It must match the corresponding entry in [`package.json#peerDependencies`](../../package.json) — if you bump the peer-dep range, update this table too. ## Community-maintained -**Every adapter listed above is community-maintained.** Both Hono and H3 originated as community contributions. Adapters live in this repo and ship with the core package, so users get them with a single `npm install @supabase/server` — no separate package per framework. +**Every adapter listed above is community-maintained.** Hono, H3, and Elysia all originated as community contributions. Adapters live in this repo and ship with the core package, so users get them with a single `npm install @supabase/server` — no separate package per framework. The Supabase team reviews PRs, runs security and regression triage, and ships releases. The original contributor of an adapter is the de-facto domain expert and is expected to be the first responder on framework-version bumps and bug reports for that adapter. @@ -35,4 +36,4 @@ The Supabase team will review the PR against these requirements. Once merged, th ## Designing an adapter -The existing adapters at [`hono/middleware.ts`](hono/middleware.ts) and [`h3/middleware.ts`](h3/middleware.ts) (siblings of this README) are the canonical templates. The shape every adapter exposes is `withSupabase(config, handler)` returning a framework-native middleware. Keep all auth logic in `@supabase/server/core` — adapters should only translate request/response shapes between the framework and the core primitives. +The existing adapters at [`hono/middleware.ts`](hono/middleware.ts), [`h3/middleware.ts`](h3/middleware.ts), and [`elysia/plugin.ts`](elysia/plugin.ts) (siblings of this README) are the canonical templates. The shape every adapter exposes is `withSupabase(config, handler)` returning a framework-native middleware. Keep all auth logic in `@supabase/server/core` — adapters should only translate request/response shapes between the framework and the core primitives. diff --git a/src/adapters/elysia/index.ts b/src/adapters/elysia/index.ts new file mode 100644 index 0000000..9b61794 --- /dev/null +++ b/src/adapters/elysia/index.ts @@ -0,0 +1,7 @@ +/** + * Elysia framework adapter for `@supabase/server`. + * + * @packageDocumentation + */ + +export { withSupabase } from './plugin.js' diff --git a/src/adapters/elysia/plugin.test.ts b/src/adapters/elysia/plugin.test.ts new file mode 100644 index 0000000..759ca17 --- /dev/null +++ b/src/adapters/elysia/plugin.test.ts @@ -0,0 +1,90 @@ +import { Elysia } from 'elysia' +import { describe, expect, it } from 'vitest' + +import { withSupabase } from './plugin.js' + +describe('elysia supabase plugin', () => { + const env = { + url: 'https://test.supabase.co', + publishableKeys: { default: 'sb_publishable_xyz' }, + secretKeys: { default: 'sb_secret_xyz' }, + jwks: null, + } + + it('sets supabase context on successful auth', async () => { + const app = new Elysia() + .use(withSupabase({ auth: 'none', env })) + .get('/', ({ supabaseContext }) => ({ + authMode: supabaseContext.authMode, + hasSupabase: !!supabaseContext.supabase, + hasAdmin: !!supabaseContext.supabaseAdmin, + })) + + const res = await app.handle(new Request('http://localhost/')) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.authMode).toBe('none') + expect(body.hasSupabase).toBe(true) + expect(body.hasAdmin).toBe(true) + }) + + it('throws error on auth failure', async () => { + const app = new Elysia() + .use(withSupabase({ auth: 'user', env })) + .get('/', () => ({ ok: true })) + + const res = await app.handle(new Request('http://localhost/')) + expect(res.status).toBe(401) + const body = await res.text() + expect(body).toBeTruthy() + }) + + it('exposes AuthError via cause in onError', async () => { + const app = new Elysia() + .use(withSupabase({ auth: 'user', env })) + .onError(({ code, error, status }) => { + if (code !== 'SupabaseAuthError') return + const cause = error.cause as + | { code?: string; status?: number } + | undefined + return status((cause?.status as 401) ?? 500, { + error: error.message, + code: cause?.code, + }) + }) + .get('/', () => ({ ok: true })) + + const res = await app.handle(new Request('http://localhost/')) + expect(res.status).toBe(401) + const body = await res.json() + expect(body.error).toBeDefined() + expect(body.code).toBeDefined() + }) + + it('skips if context is already set by prior plugin', async () => { + const app = new Elysia() + // First plugin sets context with 'none' auth + .use(withSupabase({ auth: 'none', env })) + // Second plugin would require 'secret' — but should skip + .use(withSupabase({ auth: 'secret', env })) + .get('/', ({ supabaseContext }) => ({ + authMode: supabaseContext.authMode, + })) + + // No apikey header — would fail 'secret' if it ran + const res = await app.handle(new Request('http://localhost/')) + expect(res.status).toBe(200) + const body = await res.json() + // First plugin's auth mode is preserved + expect(body.authMode).toBe('none') + }) + + it('does not add CORS headers', async () => { + const app = new Elysia() + .use(withSupabase({ auth: 'none', env })) + .get('/', () => ({ ok: true })) + + const res = await app.handle(new Request('http://localhost/')) + expect(res.headers.get('Access-Control-Allow-Origin')).toBeNull() + }) +}) diff --git a/src/adapters/elysia/plugin.ts b/src/adapters/elysia/plugin.ts new file mode 100644 index 0000000..58fd230 --- /dev/null +++ b/src/adapters/elysia/plugin.ts @@ -0,0 +1,73 @@ +import { Elysia } from 'elysia' + +import { createSupabaseContext } from '../../create-supabase-context.js' +import type { SupabaseContext, WithSupabaseConfig } from '../../types.js' + +class SupabaseAuthError extends Error { + status: number + constructor(message: string, status: number, cause: unknown) { + super(message, { cause }) + this.status = status + } +} + +/** + * 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 an error with the correct HTTP status on auth failure. The original `AuthError` is + * available via `error.cause` in an `onError` handler. + * + * @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) + * ``` + */ +export function withSupabase(config?: Omit) { + return new Elysia() + .error({ SupabaseAuthError }) + .resolve(async (ctx): Promise<{ supabaseContext: SupabaseContext }> => { + const existing = (ctx as { supabaseContext?: SupabaseContext }) + .supabaseContext + if (existing) return { supabaseContext: existing } + + const { data, error } = await createSupabaseContext(ctx.request, config) + if (error) { + throw new SupabaseAuthError(error.message, error.status, error) + } + + return { supabaseContext: data } + }) + .as('scoped') +} diff --git a/tsdown.config.ts b/tsdown.config.ts index 625dd8f..029b6fe 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -6,8 +6,9 @@ export default defineConfig({ 'src/core/index.ts', 'src/adapters/hono/index.ts', 'src/adapters/h3/index.ts', + 'src/adapters/elysia/index.ts', ], format: ['esm', 'cjs'], dts: true, - external: ['@supabase/supabase-js', 'hono', 'h3'], + external: ['@supabase/supabase-js', 'hono', 'h3', 'elysia'], }) diff --git a/typedoc.json b/typedoc.json index 69b5788..41e0e39 100644 --- a/typedoc.json +++ b/typedoc.json @@ -4,7 +4,8 @@ "src/index.ts", "src/core/index.ts", "src/adapters/hono/index.ts", - "src/adapters/h3/index.ts" + "src/adapters/h3/index.ts", + "src/adapters/elysia/index.ts" ], "out": "api-docs", "json": "api-docs/spec.json",