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/README.md b/README.md index ddd27e8..f61e85a 100644 --- a/README.md +++ b/README.md @@ -301,20 +301,13 @@ For per-route auth, use scoped groups: 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 - }), - ) +const app = new H3() +app.use(withSupabase({ auth: 'user' })) -app.listen(3000) +export default { fetch: app.fetch } ``` -The adapter does not handle CORS — use `@elysiajs/cors` for that. +See [docs/adapters/h3.md](docs/adapters/h3.md) for per-route auth, Nuxt server-middleware patterns, CORS, and more. ## Primitives @@ -458,13 +451,12 @@ 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) | -| `@supabase/server/adapters/elysia` | `withSupabase` (Elysia plugin) | +| 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) | ## Documentation @@ -475,7 +467,6 @@ 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 index 7b24d96..077277d 100644 --- a/docs/adapters/elysia.md +++ b/docs/adapters/elysia.md @@ -8,7 +8,10 @@ Install Elysia as a peer dependency: pnpm add elysia ``` -The adapter exports its own `withSupabase` that returns an Elysia plugin instead of a fetch handler. +The adapter exports `withSupabase` with two call shapes: + +- **One arg** — `withSupabase(config)` — returns an Elysia plugin. Everything in this document describes this form. +- **Two args** — `withSupabase(config, handler)` — the base `withSupabase` from `@supabase/server`, re-exported here for ergonomics. Returns a Web Fetch handler. Use it when you want to compose with [gates](../../src/core/gates/README.md). See the "Composing with gates" section at the bottom. ## Basic app with auth @@ -138,3 +141,32 @@ app.use( }), ) ``` + +## Composing with gates + +For routes that compose with a [gate](../../src/core/gates/README.md), call `withSupabase` with two args. That form returns a dual-mode handler — it accepts either an Elysia route context (when mounted on a route) or a plain `Request` (Web Fetch) — and extracts the underlying Request automatically. Mount directly with `.all(path, withSupabase(...))`: + +```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) +``` + +Routes that don't need a gate continue to use the one-arg plugin form documented above. The two coexist in one app; each route picks the form that fits. diff --git a/docs/adapters/h3.md b/docs/adapters/h3.md index c45563c..aee493a 100644 --- a/docs/adapters/h3.md +++ b/docs/adapters/h3.md @@ -8,7 +8,10 @@ Install H3 as a peer dependency: pnpm add h3 ``` -The adapter exports its own `withSupabase` that returns H3 middleware instead of a fetch handler. Works with standalone H3 servers and Nuxt server routes (which run on H3 under the hood). +The adapter exports `withSupabase` with two call shapes: + +- **One arg** — `withSupabase(config)` — returns H3 middleware. Everything in this document describes this form. Works with standalone H3 servers and Nuxt server routes (which run on H3 under the hood). +- **Two args** — `withSupabase(config, handler)` — the base `withSupabase` from `@supabase/server`, re-exported here for ergonomics. Returns a Web Fetch handler. Use it when you want to compose with [gates](../../src/core/gates/README.md). See the "Composing with gates" section at the bottom. ## Typing `event.context.supabaseContext` @@ -192,3 +195,29 @@ app.use( }), ) ``` + +## Composing with gates + +For routes that compose with a [gate](../../src/core/gates/README.md), call `withSupabase` with two args. That form returns a dual-mode handler — it accepts either an `H3Event` (when mounted on a route) or a plain `Request` (Web Fetch) — and extracts the underlying Request automatically. Mount directly with `app.all(path, withSupabase(...))`: + +```ts +import { H3 } from 'h3' +import { withSupabase } from '@supabase/server/adapters/h3' +import { withFeatureFlag } from '@supabase/server/gates/feature-flag' + +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 }), + ), + ), +) +``` + +Routes that don't need a gate continue to use the one-arg middleware form documented above. The two coexist in one app; each route picks the form that fits. diff --git a/docs/adapters/hono.md b/docs/adapters/hono.md index 78d5119..77304fb 100644 --- a/docs/adapters/hono.md +++ b/docs/adapters/hono.md @@ -8,7 +8,10 @@ Install Hono as a peer dependency: pnpm add hono ``` -The adapter exports its own `withSupabase` that returns Hono middleware instead of a fetch handler. +The adapter exports `withSupabase` with two call shapes: + +- **One arg** — `withSupabase(config)` — returns Hono middleware. Everything in this document describes this form. +- **Two args** — `withSupabase(config, handler)` — the base `withSupabase` from `@supabase/server`, re-exported here for ergonomics. Returns a Web Fetch handler. Use it when you want to compose with [gates](../../src/core/gates/README.md). See the "Composing with gates" section at the bottom. ## Basic app with auth @@ -172,3 +175,29 @@ app.use( }), ) ``` + +## Composing with gates + +For routes that compose with a [gate](../../src/core/gates/README.md), call `withSupabase` with two args. That form returns a dual-mode handler — it accepts either a Hono `Context` (when mounted on a route) or a plain `Request` (Web Fetch) — and extracts the underlying Request automatically. Mount directly with `app.all(path, withSupabase(...))`: + +```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 }), + ), + ), +) +``` + +Routes that don't need a gate continue to use the one-arg middleware form documented above. The two coexist in one app; each route picks the form that fits. diff --git a/jsr.json b/jsr.json index 77dbe1e..a37614b 100644 --- a/jsr.json +++ b/jsr.json @@ -4,19 +4,15 @@ "exports": { ".": "./src/index.ts", "./core": "./src/core/index.ts", + "./core/gates": "./src/core/gates/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" + "./adapters/elysia": "./src/adapters/elysia/index.ts", + "./gates/feature-flag": "./src/gates/feature-flag/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..6d996f9 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,16 @@ "import": "./dist/core/index.mjs", "require": "./dist/core/index.cjs" }, + "./core/gates": { + "types": "./dist/core/gates/index.d.mts", + "import": "./dist/core/gates/index.mjs", + "require": "./dist/core/gates/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", @@ -39,6 +49,11 @@ "import": "./dist/adapters/h3/index.mjs", "require": "./dist/adapters/h3/index.cjs" }, + "./gates/feature-flag": { + "types": "./dist/gates/feature-flag/index.d.mts", + "import": "./dist/gates/feature-flag/index.mjs", + "require": "./dist/gates/feature-flag/index.cjs" + }, "./adapters/elysia": { "types": "./dist/adapters/elysia/index.d.mts", "import": "./dist/adapters/elysia/index.mjs", diff --git a/src/adapters/README.md b/src/adapters/README.md index 4130c43..5ec958f 100644 --- a/src/adapters/README.md +++ b/src/adapters/README.md @@ -27,7 +27,7 @@ Before you start, **read [`CONTRIBUTING.md`](../../CONTRIBUTING.md) and agree wi - **Tests for every auth mode.** Cover `'user'`, `'publishable'`, `'secret'`, `'none'`, the array form, and the failure paths (missing token, invalid JWT, missing apikey). The Hono adapter's [`hono/middleware.test.ts`](hono/middleware.test.ts) is the canonical reference — your test file should look structurally similar. - **Strict TypeScript.** No `any`, no `// @ts-ignore`. Public types must be exported from the adapter's `index.ts` so consumers can extend them. - **No new runtime dependencies** beyond the framework you're adapting. The framework itself goes in `peerDependencies` (and `peerDependenciesMeta` if optional). Don't pull in a wrapper, polyfill, or utility lib just to make the adapter shorter. -- **Match the existing adapter shape.** Export `withSupabase(config, handler)` returning the framework's native middleware/handler type. Use `verifyAuth`, `createContextClient`, and `createAdminClient` from `@supabase/server/core` — never re-implement auth or env handling inside an adapter. +- **Match the existing adapter shape.** Export `withSupabase` with two call forms — `withSupabase(config)` returning the framework's native middleware/plugin, and `withSupabase(config, handler)` returning a dual-mode route handler built via [`defineAdapter`](../core/adapters/define-adapter.ts) (see [Designing an adapter](#designing-an-adapter) below). Use `verifyAuth`, `createContextClient`, and `createAdminClient` from `@supabase/server/core` — never re-implement auth or env handling inside an adapter. - **Wire up the build outputs.** Add the adapter entry to `package.json#exports`, `jsr.json` (if applicable), and `tsdown.config.ts#entry` so it ships in the published artifact. - **Docs are required.** Add `docs/adapters/.md` mirroring the structure of [`docs/adapters/hono.md`](../../docs/adapters/hono.md) — at minimum: setup, basic example, per-route auth, CORS note. - **Update both adapter tables.** Add a row to the table in this `src/adapters/README.md` _and_ the mirror table in the top-level [`README.md`](../../README.md). Keep the framework-version column accurate against `package.json#peerDependencies`. PRs that touch an existing adapter must update the version column if the peer-dep range changed. @@ -36,4 +36,63 @@ 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), [`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. +Every adapter has two call forms. They share a name (`withSupabase`) but solve different problems and are implemented differently: + +### One-arg form — bespoke per framework + +`withSupabase(config)` returns framework-native middleware/plugin (e.g. Hono `MiddlewareHandler`, H3 `Middleware`, Elysia plugin). This is the form users apply with `app.use(...)`. Each framework has its own: + +- middleware/plugin construction (`createMiddleware`, `defineMiddleware`, `new Elysia().resolve(...)`), +- context-population idiom (`c.set('supabaseContext', ctx)`, `event.context.supabaseContext = ctx`, `.resolve(() => ({ supabaseContext: ctx }))`), +- error-throw shape (`HTTPException`, `HTTPError`, a registered custom error class for Elysia). + +There's no useful shared abstraction here — the divergence is structural. Mirror the existing adapter that's closest to your framework's idiom. + +Common contract every one-arg implementation must uphold: + +- **Skip if a previous middleware already set `supabaseContext`.** Enables route-level overrides via scoped/grouped middleware. See [`hono/middleware.ts`](hono/middleware.ts) for the canonical check. +- **Throw a framework-native error on auth failure**, not a returned Response. The error must carry the original `AuthError` as `.cause` so users can discriminate on `cause.code` / `cause.status` in their `onError` hook. +- **Exclude `cors` from the config type** (`Omit`). CORS belongs to the framework's CORS middleware/plugin, not to the adapter. + +### Two-arg form — use `defineAdapter` + +`withSupabase(config, handler)` returns a dual-mode route handler that accepts either a `Request` (Web Fetch use) or the framework's native route input (`Context`, `H3Event`, Elysia args), extracts the underlying Request, and runs base `withSupabase` against it. Mountable directly via `app.all(path, withSupabase(config, handler))`. + +Don't hand-roll this — [`defineAdapter`](../core/adapters/define-adapter.ts) (exported publicly as `@supabase/server/core/adapters`) encapsulates the entire dual-mode contract, including: + +- Request extraction from the framework's native input. +- `cors: false` forced on the base call (the framework owns CORS). +- Optional skip-if-set: when an upstream middleware already populated `supabaseContext`, the inner handler runs with that context instead of re-verifying. +- Optional `throwAuthError`: surfaces auth failures through the framework's error pipeline, matching the one-arg form's behavior. + +Wire it up at the top of your adapter file: + +```ts +// In-tree (bundled adapters in this repo): +import { defineAdapter } from '../../core/adapters/index.js' + +// Third-party adapter published as its own npm package: +// import { defineAdapter } from '@supabase/server/core/adapters' + +const adapterWithSupabase = defineAdapter({ + name: 'my-framework', + extractRequest: (ctx) => ctx.request, // required + getExistingContext: (ctx) => ctx.var?.supabaseContext, // optional: skip-if-set + throwAuthError: (error) => { + throw new MyFrameworkError(error) // optional: framework-native errors + }, +}) +``` + +Then in your `withSupabase` implementation, the two-arg branch is one line: + +```ts +if (handler) return adapterWithSupabase(config!, handler) +``` + +The two-arg overload's config type is `Omit` — `defineAdapter` controls both internally. See [`hono/middleware.ts`](hono/middleware.ts) for the canonical pattern. + +### Shared rules across both forms + +- Keep all auth logic in `@supabase/server/core` — adapters only translate request/response shapes between the framework and the core primitives. +- The one-arg and two-arg forms must agree on behavior: same skip semantics, same framework-native error on auth failure, same CORS exclusion. `defineAdapter`'s hooks exist specifically to keep them in sync. diff --git a/src/adapters/adapter-declarations.test.ts b/src/adapters/adapter-declarations.test.ts new file mode 100644 index 0000000..4efe56b --- /dev/null +++ b/src/adapters/adapter-declarations.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' + +// Vite/vitest provides `import.meta.glob` for build-time module discovery. +// We declare the slot here rather than reach for `/// ` +// (vite isn't a direct dependency) or pull in `@types/node` for tsconfig. +declare global { + interface ImportMeta { + glob(pattern: string): Record Promise> + } +} + +/** + * Tooling consistency check: every adapter discovered in `src/adapters/*` + * must also be declared in both `jsr.json` and `package.json` exports. + * + * Adding an adapter to the source tree without wiring up its export + * entries (or vice versa) silently produces an installable but + * un-importable adapter. This test catches that at CI time. + * + * Runtime delegation of the two-arg form is enforced by + * {@link file://./../core/define-adapter.test.ts}, and per-adapter + * end-to-end behavior is pinned by each adapter's own `*.test.ts`. This + * file only checks declarations. + */ + +interface AdapterModule { + withSupabase: unknown +} + +const adapters = import.meta.glob('./*/index.ts') +const adapterEntries = Object.entries(adapters) + +describe('adapter declarations', () => { + it('jsr.json, package.json, and the source tree declare the same adapter set', async () => { + const [jsrConfig, pkgConfig] = await Promise.all([ + import('../../jsr.json', { with: { type: 'json' } }).then( + (m) => m.default, + ), + import('../../package.json', { with: { type: 'json' } }).then( + (m) => m.default, + ), + ]) + + const fromGlob = adapterEntries + .map(([path]) => path.replace(/^\.\/(.+)\/index\.ts$/, './adapters/$1')) + .sort() + const fromJsr = Object.keys(jsrConfig.exports) + .filter((k) => k.startsWith('./adapters/')) + .sort() + const fromPkg = Object.keys(pkgConfig.exports) + .filter((k) => k.startsWith('./adapters/')) + .sort() + + expect(fromJsr).toEqual(fromGlob) + expect(fromPkg).toEqual(fromGlob) + }) +}) diff --git a/src/adapters/elysia/plugin.test.ts b/src/adapters/elysia/plugin.test.ts index a264aa6..f9e3e9e 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,117 @@ 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 exposes the full ctx to the inner handler', async () => { + const { withFeatureFlag } = + await import('../../gates/feature-flag/index.js') + + const app = new Elysia().all( + '/beta', + withSupabase( + { auth: 'none', env }, + withFeatureFlag( + { name: 'beta', evaluate: (req) => req.headers.has('x-beta') }, + async (_req, ctx) => + Response.json({ + authMode: ctx.authMode, + flag: ctx.featureFlag.name, + enabled: ctx.featureFlag.enabled, + }), + ), + ), + ) + + const res = await app.handle( + new Request('http://localhost/beta', { headers: { 'x-beta': '1' } }), + ) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + authMode: 'none', + flag: 'beta', + enabled: true, + }) + }) + + it("returns the gate's response in place of the inner handler", async () => { + const { withFeatureFlag } = + await import('../../gates/feature-flag/index.js') + + const app = new Elysia().all( + '/beta', + withSupabase( + { auth: 'none', env }, + withFeatureFlag({ name: 'beta', evaluate: () => false }, async () => + Response.json({ reached: true }), + ), + ), + ) + + const res = await app.handle(new Request('http://localhost/beta')) + expect(res.status).toBe(404) + expect(await res.json()).toEqual({ + error: 'feature_disabled', + flag: 'beta', + }) + }) + + 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..79770f9 100644 --- a/src/adapters/elysia/plugin.ts +++ b/src/adapters/elysia/plugin.ts @@ -1,6 +1,7 @@ import { Elysia, type ExtractErrorFromHandle } from 'elysia' import { createSupabaseContext } from '../../create-supabase-context.js' +import { defineAdapter } from '../../core/adapters/index.js' import type { AuthError } from '../../errors.js' import type { SupabaseContext, WithSupabaseConfig } from '../../types.js' @@ -13,6 +14,18 @@ export class SupabaseError extends Error { } } +const adapterWithSupabase = defineAdapter<{ + request: Request + supabaseContext?: SupabaseContext +}>({ + name: 'elysia', + extractRequest: (ctx) => ctx.request, + getExistingContext: (ctx) => ctx.supabaseContext, + throwAuthError: (error) => { + throw new SupabaseError(error) + }, +}) + /** * Elysia plugin that creates a {@link SupabaseContext} and makes it available in route handlers. * @@ -87,8 +100,63 @@ export function withSupabase(config?: Omit): Elysia< standaloneSchema: {} response: {} } -> { - /* eslint-enable @typescript-eslint/no-empty-object-type */ +> +/* eslint-enable @typescript-eslint/no-empty-object-type */ +/** + * Two-arg form — 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 with `.all(path, withSupabase(config, handler))` — no + * `{ request }` destructuring needed. Use this form to compose with + * gates from `@supabase/server/gates/*`. See + * [gates README](../../core/gates/README.md) for the pattern. + * + * Behavior matches the one-arg plugin form: + * - **Auth failures throw `SupabaseError`**, flowing into Elysia's + * `onError` (not returned as a JSON response). Discriminate via + * `code === 'SupabaseError'`; the original {@link AuthError} is on + * `.cause` and `.status` is on the error directly. + * - **Skips re-running auth when an upstream plugin has already + * resolved `supabaseContext`** — the inner handler runs with that + * existing context. Useful when `.use(withSupabase(...))` is wired + * app-wide and a route adds gates on top. + * - **CORS is excluded from the config** (`Omit<…, 'cors'>`). Use + * Elysia's CORS plugin. + * + * @example + * ```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 function withSupabase( + config: Omit, + handler: (req: Request, ctx: SupabaseContext) => Promise, +): (input: Request | { request: Request }) => Promise +export function withSupabase( + config: Omit, + handler: (req: Request, ctx: SupabaseContext) => Promise, +): (input: Request | { request: Request }) => Promise +export function withSupabase( + config?: Omit, + handler?: (req: Request, ctx: SupabaseContext) => Promise, +): unknown { + if (handler) return adapterWithSupabase(config!, handler) return new Elysia() .error({ SupabaseError }) .resolve(async (ctx): Promise<{ supabaseContext: SupabaseContext }> => { diff --git a/src/adapters/h3/middleware.test.ts b/src/adapters/h3/middleware.test.ts index d5c7e7d..daa8e2d 100644 --- a/src/adapters/h3/middleware.test.ts +++ b/src/adapters/h3/middleware.test.ts @@ -103,3 +103,121 @@ 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 exposes the full ctx to the inner handler', async () => { + const { withFeatureFlag } = + await import('../../gates/feature-flag/index.js') + + const app = new H3() + app.all( + '/beta', + withSupabase( + { auth: 'none', env }, + withFeatureFlag( + { name: 'beta', evaluate: (req) => req.headers.has('x-beta') }, + async (_req, ctx) => + Response.json({ + authMode: ctx.authMode, + flag: ctx.featureFlag.name, + enabled: ctx.featureFlag.enabled, + }), + ), + ), + ) + + const res = await app.request('/beta', { headers: { 'x-beta': '1' } }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + authMode: 'none', + flag: 'beta', + enabled: true, + }) + }) + + it("returns the gate's response in place of the inner handler", async () => { + const { withFeatureFlag } = + await import('../../gates/feature-flag/index.js') + + const app = new H3() + app.all( + '/beta', + withSupabase( + { auth: 'none', env }, + withFeatureFlag({ name: 'beta', evaluate: () => false }, async () => + Response.json({ reached: true }), + ), + ), + ) + + const res = await app.request('/beta') + expect(res.status).toBe(404) + expect(await res.json()).toEqual({ + error: 'feature_disabled', + flag: 'beta', + }) + }) + + 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..54b03a1 100644 --- a/src/adapters/h3/middleware.ts +++ b/src/adapters/h3/middleware.ts @@ -1,9 +1,20 @@ import { defineMiddleware, HTTPError } from 'h3' -import type { Middleware } from 'h3' +import type { H3Event, Middleware } from 'h3' import { createSupabaseContext } from '../../create-supabase-context.js' +import { defineAdapter } from '../../core/adapters/index.js' import type { SupabaseContext, WithSupabaseConfig } from '../../types.js' +const 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 }) + }, +}) + /** * H3 middleware that creates a {@link SupabaseContext} and stores it in `event.context.supabaseContext`. * @@ -45,7 +56,61 @@ import type { SupabaseContext, WithSupabaseConfig } from '../../types.js' */ export function withSupabase( config?: Omit, -): Middleware { +): Middleware +/** + * Two-arg form — 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 with `app.all(path, withSupabase(config, handler))` — + * no `event.req` extraction needed. Use this form to compose with + * gates from `@supabase/server/gates/*`. See + * [gates README](../../core/gates/README.md) for the pattern. + * + * Behavior matches the one-arg middleware form: + * - **Auth failures throw `HTTPError`**, flowing into H3's `onError` + * hook (not returned as a JSON response). Discriminate via the + * original {@link AuthError} on `err.cause`. + * - **Skips re-running auth when an upstream middleware has already + * set `event.context.supabaseContext`** — the inner handler runs + * with that existing context. Useful when `app.use(withSupabase(...))` + * is wired app-wide and a route adds gates on top. + * - **CORS is excluded from the config** (`Omit<…, 'cors'>`). Use + * H3's CORS utilities. + * + * @example + * ```ts + * import { H3 } from 'h3' + * import { withSupabase } from '@supabase/server/adapters/h3' + * import { withFeatureFlag } from '@supabase/server/gates/feature-flag' + * + * 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, + handler: (req: Request, ctx: SupabaseContext) => Promise, +): (input: Request | H3Event) => Promise +export function withSupabase( + config: Omit, + handler: (req: Request, ctx: SupabaseContext) => Promise, +): (input: Request | H3Event) => Promise +export function withSupabase( + config?: Omit, + handler?: (req: Request, ctx: SupabaseContext) => Promise, +): Middleware | ((input: Request | H3Event) => Promise) { + if (handler) return adapterWithSupabase(config!, handler) return defineMiddleware(async (event, next) => { const context = event.context as { supabaseContext?: SupabaseContext } if (context.supabaseContext) return next() diff --git a/src/adapters/hono/middleware.test.ts b/src/adapters/hono/middleware.test.ts index e37fcdc..0bf94d8 100644 --- a/src/adapters/hono/middleware.test.ts +++ b/src/adapters/hono/middleware.test.ts @@ -96,3 +96,161 @@ 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 exposes the full ctx to the inner handler', async () => { + const { withFeatureFlag } = + await import('../../gates/feature-flag/index.js') + + const app = new Hono() + app.all( + '/beta', + withSupabase( + { auth: 'none', env }, + withFeatureFlag( + { name: 'beta', evaluate: (req) => req.headers.has('x-beta') }, + async (_req, ctx) => + Response.json({ + authMode: ctx.authMode, + flag: ctx.featureFlag.name, + enabled: ctx.featureFlag.enabled, + }), + ), + ), + ) + + const res = await app.request('/beta', { headers: { 'x-beta': '1' } }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + authMode: 'none', + flag: 'beta', + enabled: true, + }) + }) + + it("returns the gate's response in place of the inner handler", async () => { + const { withFeatureFlag } = + await import('../../gates/feature-flag/index.js') + + const app = new Hono() + app.all( + '/beta', + withSupabase( + { auth: 'none', env }, + withFeatureFlag({ name: 'beta', evaluate: () => false }, async () => + Response.json({ reached: true }), + ), + ), + ) + + const res = await app.request('/beta') + expect(res.status).toBe(404) + expect(await res.json()).toEqual({ + error: 'feature_disabled', + flag: 'beta', + }) + }) + + 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() + // HTTPException carries the original AuthError as cause. + 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 } }>() + // Upstream: app-wide auth runs once via the one-arg middleware + app.use('*', withSupabase({ auth: 'none', env })) + + let innerHandlerCalls = 0 + app.all( + '/protected', + // Two-arg form: would re-verify if not for skip-if-set + withSupabase({ auth: 'secret', env }, async (_req, ctx) => { + innerHandlerCalls++ + return Response.json({ authMode: ctx.authMode }) + }), + ) + + // No apikey header — would fail 'secret' if it actually ran + const res = await app.request('/protected') + expect(res.status).toBe(200) + const body = await res.json() + // The upstream middleware's auth mode is preserved + 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 }) + }) + + it('composes a gate against the upstream-set ctx on the skip-if-set path', async () => { + // Upstream one-arg middleware populates c.var.supabaseContext. + // Two-arg form skips base, but the gate still runs against the + // upstream-set ctx and contributes its own slot. The handler sees + // the full intersection — fields from upstream + gate contribution. + const { withFeatureFlag } = + await import('../../gates/feature-flag/index.js') + + const app = new Hono<{ Variables: { supabaseContext: SupabaseContext } }>() + app.use('*', withSupabase({ auth: 'none', env })) + + app.all( + '/beta', + withSupabase( + { auth: 'secret', env }, // would fail if base actually re-ran + withFeatureFlag( + { name: 'beta', evaluate: (req) => req.headers.has('x-beta') }, + async (_req, ctx) => + Response.json({ + // from upstream withSupabase (skipped, but ctx reused) + authMode: ctx.authMode, + // from the gate (still ran against the upstream-set ctx) + flag: ctx.featureFlag.name, + enabled: ctx.featureFlag.enabled, + }), + ), + ), + ) + + const res = await app.request('/beta', { headers: { 'x-beta': '1' } }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + authMode: 'none', + flag: 'beta', + enabled: true, + }) + }) +}) diff --git a/src/adapters/hono/middleware.ts b/src/adapters/hono/middleware.ts index 33581d4..0cb1df3 100644 --- a/src/adapters/hono/middleware.ts +++ b/src/adapters/hono/middleware.ts @@ -1,10 +1,24 @@ -import type { MiddlewareHandler } from 'hono' +import type { Context, MiddlewareHandler, Next } from 'hono' import { HTTPException } from 'hono/http-exception' import { createMiddleware } from 'hono/factory' import { createSupabaseContext } from '../../create-supabase-context.js' +import { defineAdapter } from '../../core/adapters/index.js' import type { SupabaseContext, WithSupabaseConfig } from '../../types.js' +const adapterWithSupabase = defineAdapter({ + 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, + }) + }, +}) + /** * Hono middleware that creates a {@link SupabaseContext} and stores it in `c.var.supabaseContext`. * @@ -33,7 +47,63 @@ import type { SupabaseContext, WithSupabaseConfig } from '../../types.js' */ export function withSupabase( config?: Omit, -): MiddlewareHandler<{ Variables: { supabaseContext: SupabaseContext } }> { +): MiddlewareHandler<{ Variables: { supabaseContext: SupabaseContext } }> +/** + * Two-arg form — 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 with `app.all(path, withSupabase(config, handler))` + * — no `c.req.raw` extraction needed. Use this form to compose with + * gates from `@supabase/server/gates/*`. See + * [gates README](../../core/gates/README.md) for the pattern. + * + * Behavior matches the one-arg middleware form: + * - **Auth failures throw `HTTPException`**, flowing into `app.onError` + * (not returned as a JSON response). Discriminate via the original + * {@link AuthError} on `err.cause`. + * - **Skips re-running auth when an upstream middleware has already + * set `c.var.supabaseContext`** — the inner handler runs with that + * existing context. Useful when `app.use('*', withSupabase(...))` is + * wired app-wide and a route adds gates on top. + * - **CORS is excluded from the config** (`Omit<…, 'cors'>`). Use + * Hono's `cors()` middleware. + * + * @example + * ```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, + handler: (req: Request, ctx: SupabaseContext) => Promise, +): (input: Request | Context, next?: Next) => Promise +export function withSupabase( + config: Omit, + handler: (req: Request, ctx: SupabaseContext) => Promise, +): (input: Request | Context, next?: Next) => Promise +export function withSupabase( + config?: Omit, + handler?: (req: Request, ctx: SupabaseContext) => Promise, +): + | MiddlewareHandler<{ Variables: { supabaseContext: SupabaseContext } }> + | ((input: Request | Context, next?: Next) => Promise) { + if (handler) return adapterWithSupabase(config!, handler) return createMiddleware<{ Variables: { supabaseContext: SupabaseContext } }>(async (c, 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..169be8a --- /dev/null +++ b/src/core/adapters/define-adapter.test.ts @@ -0,0 +1,190 @@ +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 fakeAdapter = defineAdapter({ + name: 'fake', + extractRequest: (ctx) => ctx.request, +}) + +describe('defineAdapter', () => { + it('forwards config and handler to base, augmenting cors: false', () => { + baseMock.withSupabase.mockReturnValueOnce(async () => new Response()) + + const config: WithSupabaseConfig = { auth: 'user' } + const handler = async () => Response.json({}) + + fakeAdapter(config, handler) + + // The adapter forces cors off so the framework owns CORS, and + // forwards every other field of the user's config. + 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 = fakeAdapter({ 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 = fakeAdapter({ 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 = fakeAdapter({ 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') + } + }) + + it('throws when extractRequest returns a non-Request', () => { + baseMock.withSupabase.mockReturnValueOnce(async () => new Response()) + + const looseAdapter = defineAdapter<{ request?: unknown }>({ + name: 'loose', + extractRequest: (ctx) => ctx.request as Request | undefined, + }) + + const wrapped = looseAdapter({ auth: 'user' }, async () => new Response()) + + expect(() => wrapped({ request: 'not a request' })).toThrow(TypeError) + expect(() => wrapped({})).toThrow(TypeError) + }) + + describe('getExistingContext (skip-if-set)', () => { + const skipAdapter = defineAdapter({ + name: 'skip-fake', + extractRequest: (ctx) => ctx.request, + getExistingContext: (ctx) => ctx.supabaseContext, + }) + + 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 = skipAdapter({ 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 = skipAdapter({ 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 = skipAdapter({ auth: 'user' }, userHandler) + + const req = new Request('https://example.test/') + await wrapped(req) + + expect(userHandler).not.toHaveBeenCalled() + expect(inner).toHaveBeenCalledWith(req) + }) + }) + + describe('throwAuthError', () => { + class FrameworkError extends Error { + readonly cause: AuthError + constructor(error: AuthError) { + super('framework-native') + this.cause = error + } + } + + const throwingAdapter = defineAdapter({ + name: 'throw-fake', + extractRequest: (ctx) => ctx.request, + throwAuthError: (error) => { + throw new FrameworkError(error) + }, + }) + + it('passes throwAuthError as onAuthError on the base config', () => { + baseMock.withSupabase.mockReturnValueOnce(async () => new Response()) + + throwingAdapter({ 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()) + + fakeAdapter({ 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..c9c9cde --- /dev/null +++ b/src/core/adapters/define-adapter.ts @@ -0,0 +1,142 @@ +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 `withSupabase` accepts `Request | NativeContext` and + * extracts the underlying Request via {@link extractRequest}. + */ +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 +} + +/** + * Build the two-arg `withSupabase` form for a framework adapter. + * + * The returned function accepts either a plain `Request` (Web Fetch + * use) or the framework's native route input (`NativeContext`), + * extracts the underlying Request, and runs base `withSupabase` + * against it. This lets the adapter's two-arg form be mounted directly + * on the framework's route registrar — e.g. + * `app.all(path, withSupabase(config, handler))` — without a wrapping + * arrow that extracts `c.req.raw` / `event.req` / `ctx.request`. + * + * CORS is forced **off** on the underlying base call (`cors: false`): + * the two-arg form's config type excludes `cors`, and any CORS + * handling is the framework's responsibility (same as the one-arg + * middleware/plugin form). This keeps the two surfaces consistent — + * a user who wires up the framework's CORS app-wide never gets + * double-handled or duplicate CORS headers from a gated route. + * + * Optional spec hooks unify additional behavior with the one-arg + * form: + * + * - {@link AdapterSpec.getExistingContext} — skip base auth when a + * prior middleware already set `c.var.supabaseContext` (or + * equivalent). + * - {@link AdapterSpec.throwAuthError} — surface auth failures + * through the framework's error pipeline. + * + * The one-arg framework-native middleware/plugin form is each + * adapter's responsibility; `defineAdapter` only covers the two-arg + * form. + * + * @example Third-party adapter package + * ```ts + * import type { Context } from 'hono' + * import { HTTPException } from 'hono/http-exception' + * import { defineAdapter } from '@supabase/server/core/adapters' + * + * const adapterWithSupabase = defineAdapter({ + * 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, + * }) + * }, + * }) + * ``` + */ +export function defineAdapter(spec: AdapterSpec) { + return function withSupabase( + 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 = spec.extractRequest(input) + if (!(req instanceof Request)) { + throw new TypeError(buildErrorMessage(spec.name, input)) + } + + const existing = spec.getExistingContext?.(input) + if (existing) { + return handler(req, existing as SupabaseContext) + } + + return inner(req) + } + } +} + +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 with \`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..c53a644 --- /dev/null +++ b/src/core/adapters/index.ts @@ -0,0 +1 @@ +export { defineAdapter, type AdapterSpec } from './define-adapter.js' diff --git a/src/core/gates/README.md b/src/core/gates/README.md new file mode 100644 index 0000000..c843818 --- /dev/null +++ b/src/core/gates/README.md @@ -0,0 +1,232 @@ +# `@supabase/server/core/gates` + +Similar to how `withSupabase(config, handler)` takes a config and a handler and hands the handler a `ctx` (with `ctx.supabase`, `ctx.userClaims`, …), a **gate** is a wrapper of the same shape — `withFoo(config, handler)` — that runs against the inbound `Request` and contributes its own typed key to `ctx`. Stack gates by direct nesting; the innermost handler sees a flat `ctx` aggregated from every wrapper around it. No separate composer. + +Gates are how `@supabase/server` is extended past auth. Anyone can publish one as a standalone npm package; the built-in `withFeatureFlag` sits alongside third-party gates with no special status, all built on the same `defineGate` primitive. And because every gate is a plain `(req, ctx) => Response` wrapper over the Web Fetch API, the same gate runs unchanged across every runtime `@supabase/server` supports — Workers, Deno, Bun, Node — and inside every framework adapter (Hono, H3, Elysia) via the adapter's two-arg `withSupabase(config, handler)` form. See [Using gates with framework adapters](#using-gates-with-framework-adapters) below. + +This module exports: + +- **`defineGate`** — for _gate authors_ writing a new integration. + +## Quick start (consumer) + +```ts +import { withSupabase } from '@supabase/server' +import { withFeatureFlag } from '@supabase/server/gates/feature-flag' + +export default { + fetch: withSupabase( + { auth: 'user' }, + withFeatureFlag( + { name: 'beta', evaluate: (req) => req.headers.has('x-beta') }, + async (req, ctx) => { + // ctx.supabase, ctx.userClaims — from withSupabase + // ctx.featureFlag — from withFeatureFlag + return Response.json({ + user: ctx.userClaims!.id, + variant: ctx.featureFlag.variant, + }) + }, + ), + ), +} +``` + +Standalone (no `withSupabase`): + +```ts +export default { + fetch: withFeatureFlag( + { name: 'beta', evaluate: (req) => req.headers.has('x-beta') }, + async (req, ctx) => Response.json({ flag: ctx.featureFlag.name }), + ), +} +``` + +## The `ctx` shape + +Inside a gated handler, ctx is a flat intersection — each gate contributes a typed key: + +| Key | Set by | Mutability | +| -------------------------------------------------------- | ------------------------------ | ----------------------- | +| `ctx.supabase`, `ctx.userClaims`, etc. | `withSupabase` (when wrapping) | read-only by convention | +| `ctx.` (e.g. `ctx.featureFlag`, `ctx.payment`) | the corresponding gate | read-only by convention | + +Two type-level guarantees: + +- **Collision detection.** If a gate tries to compose where the upstream already has its key, the gate's call returns a `Conflict` sentinel string. Using the result where a fetch handler is expected fails to typecheck — error surfaces at the offending gate's call site. +- **Prerequisite enforcement.** Gates declare the upstream shape they require via `In`. The wrapper constrains `Base extends In`. Composing the gate where the upstream doesn't provide those keys is a type error. A gate that declares prerequisites can't be the top-level handler — it has to be nested inside a wrapper (e.g. `withSupabase`, or another gate) that supplies those keys. + +## Composition rules + +Two things to know when stacking gates: + +1. **Outer runs first.** Each gate is a fetch-handler wrapper, so the outermost wrapper sees the request first and its contribution appears on `ctx` for everything it wraps. Reverse the order and any inner gate that declared an outer's key as a prerequisite won't compile. + +2. **Either a `Response` or a contribution — not both.** A gate's `run` returns either a `Response` (handed back to the caller in place of the inner handler) or a contribution `{ [key]: … }` (fall through). A returned `Response` isn't a "rejection" or an error — it can be any status (200, 302, 404, 503, …). Gates don't observe or wrap the inner handler's response either. Anything response-shaped — rate-limit headers, CORS, response envelopes — is the handler's job: it reads what it needs from `ctx` and `req` and builds the response itself. This keeps each gate's surface small and the response shape under one owner. + +## Using gates with framework adapters + +Each framework adapter (`@supabase/server/adapters/hono`, `/h3`, `/elysia`) exports `withSupabase` with two call shapes: + +- **One arg** — `withSupabase(config)` — the framework-native middleware/plugin. See the per-adapter docs (`docs/adapters/*.md`). +- **Two args** — `withSupabase(config, handler)` — a dual-mode handler that accepts either a plain `Request` or the framework's native route context, extracts the underlying Request, and runs base `withSupabase` against it. Mount directly with `app.all(path, withSupabase(config, gate))` — no manual `c.req.raw` / `event.req` / `({ request })` extraction needed. + +The two forms can coexist in one app — routes that just need auth use the one-arg middleware, routes that compose with gates use the two-arg handler. + +```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 }), + ), + ), +) +``` + +The call site is identical across frameworks — H3 uses `app.all('/beta', withSupabase(...))`, Elysia uses `.all('/beta', withSupabase(...))`. Only the framework's own routing call varies. The gate stack itself is unchanged. + +`Base` (the upstream ctx shape) is inferred through the gate's `Wrapped` signature, so the inner handler sees the full intersection `SupabaseContext & { gateA: … } & { gateB: … }`. + +## Authoring a gate (`defineGate`) + +A gate has a _key_ (its slot on `ctx`), an optional `In` (upstream prerequisites), a _contribution_ shape, and a _run_ function. + +### No prerequisites + +```ts +import { defineGate } from '@supabase/server/core/gates' + +export interface FlagConfig { + name: string + evaluate: (req: Request) => boolean +} + +export interface FlagState { + enabled: boolean +} + +export const withFeatureFlag = defineGate< + 'featureFlag', // Key + FlagConfig, // Config + {}, // In: no upstream prerequisites + FlagState // Contribution: shape under ctx.featureFlag +>({ + key: 'featureFlag', + run: (config) => async (req) => { + const enabled = config.evaluate(req) + if (!enabled) { + return Response.json({ error: 'feature_disabled' }, { status: 404 }) + } + return { featureFlag: { enabled } } // ← keyed slot, visible at ctx.featureFlag + }, +}) +``` + +Used as: + +```ts +withFeatureFlag({ name: 'beta', evaluate: ... }, async (req, ctx) => { + return Response.json({ enabled: ctx.featureFlag.enabled }) +}) +``` + +### `run`'s shape + +```ts +run: (config: Config) => (req: Request, ctx: In) => + Promise +``` + +The outer `(config) =>` is invoked once when the consumer constructs the gate. Initialize per-instance state (stores, clients, computed config) here. The inner `(req, ctx) =>` is invoked per-request. + +Return a `Response` to short-circuit, or a single-key object `{ [key]: contribution }` to fall through. The runtime picks `result[key]` and ignores any other fields. + +### Declaring upstream prerequisites + +A gate that depends on upstream data declares it in `In`: + +```ts +import type { UserClaims } from '@supabase/server' + +export const withSubscription = defineGate< + 'subscription', + { lookup: (userId: string) => Promise }, + { userClaims: UserClaims | null }, // In: requires userClaims upstream + { plan: Plan } +>({ + key: 'subscription', + run: (config) => async (_req, ctx) => { + if (!ctx.userClaims) { + return Response.json({ error: 'unauthenticated' }, { status: 401 }) + } + const plan = await config.lookup(ctx.userClaims.id) + if (!plan) { + return Response.json({ error: 'no_plan' }, { status: 402 }) + } + return { subscription: { plan } } + }, +}) +``` + +A consumer using this gate must supply `userClaims` upstream — typically by wrapping with `withSupabase`. Standalone use won't compile. + +### Conflict detection + +Two gates contributing the same key fail to compose. The inner `withFoo` returns `Conflict<'foo'>` (a sentinel string), which can't be used where a fetch handler is expected: + +```ts +withFoo({...}, withFoo({...}, handler)) // type error: Conflict<'foo'> is not callable +``` + +Pick a different key for each gate. Gates that may be applied multiple times can accept a `key` config to override the default. + +### Threading state through nested gates + +When a gate is wrapped by another (e.g. `withSupabase(... withFeatureFlag(... handler))`), the outer's keys land on `Base` for the inner. TypeScript infers that `Base` through the nested fetch-handler signatures, so the handler sees the full accumulated `ctx` without explicit annotations. + +```ts +withSupabase( + { auth: 'user' }, + withFeatureFlag( + { name: 'beta', evaluate: (req) => req.headers.has('x-beta') }, + async (_req, ctx) => { + // ctx.userClaims — from withSupabase + // ctx.featureFlag — from withFeatureFlag + return Response.json({ user: ctx.userClaims!.id }) + }, + ), +) +``` + +For multi-gate stacks, keep nesting directly: + +```ts +withSupabase({ auth: 'user' }, + withFeatureFlag(..., + withMyGate(..., async (_req, ctx) => { + // ctx.userClaims — from withSupabase + // ctx.featureFlag — from withFeatureFlag + // ctx.myGate — from withMyGate + }), + ), +) +``` + +## API + +| Export | Description | +| ------------------------------------- | ---------------------------------------------------------------------- | +| `defineGate(spec)` | Author helper: declare a gate. Returns a `(config, handler)` callable. | +| `Conflict` | Sentinel string returned when a gate would shadow an upstream key. | +| `Gate` | The shape of a gate produced by `defineGate`. | diff --git a/src/core/gates/define-gate.test.ts b/src/core/gates/define-gate.test.ts new file mode 100644 index 0000000..dc41958 --- /dev/null +++ b/src/core/gates/define-gate.test.ts @@ -0,0 +1,281 @@ +import { describe, expect, it, vi } from 'vitest' + +import { withSupabase } from '../../with-supabase.js' +import { withFeatureFlag } from '../../gates/feature-flag/with-feature-flag.js' +import { defineGate } from './define-gate.js' + +const innerOk = async () => Response.json({ ok: true }) + +const passingGate = ( + key: Key, + contribution: C, +) => + defineGate, C>({ + key, + run: () => async () => ({ [key]: contribution }) as { [K in Key]: C }, + }) + +const rejectingGate = (key: Key, status = 401) => + defineGate, Record>({ + key, + run: () => async () => new Response(`rejected by ${key}`, { status }), + }) + +describe('defineGate', () => { + it('runs the gate, contributes its key to ctx, and calls the inner handler', async () => { + const withGreeting = defineGate< + 'greeting', + { who: string }, + Record, + { hello: string } + >({ + key: 'greeting', + run: (config) => async () => ({ greeting: { hello: config.who } }), + }) + + const fetchHandler = withGreeting({ who: 'world' }, async (_req, ctx) => + Response.json({ msg: ctx.greeting.hello }), + ) + + const res = await fetchHandler(new Request('http://localhost/')) + expect(await res.json()).toEqual({ msg: 'world' }) + }) + + it('short-circuits on reject without calling the inner handler', async () => { + const inner = vi.fn(innerOk) + const fetchHandler = rejectingGate('blocker', 402)(undefined, inner) + + const res = await fetchHandler(new Request('http://localhost/')) + expect(res.status).toBe(402) + expect(await res.text()).toBe('rejected by blocker') + expect(inner).not.toHaveBeenCalled() + }) + + it('nests gates: outer contributes, inner sees the merged ctx', async () => { + const withA = passingGate('alpha', { v: 1 }) + const withB = passingGate('beta', { v: 2 }) + + const fetchHandler = withA( + undefined, + withB<{ alpha: { v: number } }>(undefined, async (_req, ctx) => + Response.json({ a: ctx.alpha.v, b: ctx.beta.v }), + ), + ) + + const res = await fetchHandler(new Request('http://localhost/')) + expect(await res.json()).toEqual({ a: 1, b: 2 }) + }) + + it('refuses to compose where the gate would shadow an upstream key', () => { + const withFoo = passingGate('foo', { v: 1 }) + + // When the upstream Base already has the gate's key, the `Base` type + // parameter fails its `NoConflict` constraint and TypeScript + // reports the conflict at the offending gate's call site, citing the + // literal conflict message. + const conflicted = + withFoo(undefined, async () => + Response.json({ ok: true }), + ) + void conflicted + }) + + it('enforces prerequisites: gates with `In` keys require the upstream to provide them', async () => { + interface Upstream { + supabase: { from: (t: string) => { ok: boolean } } + userClaims: { id: string } + } + + const withReportAccess = defineGate< + 'reportAccess', + { reportId: string }, + Upstream, + { allowed: boolean } + >({ + key: 'reportAccess', + run: (config) => async (_req, ctx) => { + // ctx is typed as Upstream — `from` is callable here + const probe = ctx.supabase.from(`reports:${config.reportId}`) + return { + reportAccess: { allowed: probe.ok && ctx.userClaims.id !== '' }, + } + }, + }) + + // Compose with an outer wrapper that provides Upstream: + const fakeUpstream: Upstream = { + supabase: { from: () => ({ ok: true }) }, + userClaims: { id: 'u1' }, + } + + const fetchHandler = withReportAccess( + { reportId: 'r1' }, + async (_req, ctx) => + Response.json({ + allowed: ctx.reportAccess.allowed, + user: ctx.userClaims.id, + }), + ) + + // baseCtx is REQUIRED for gates with prereqs — verifies the type. + const res = await fetchHandler( + new Request('http://localhost/'), + fakeUpstream, + ) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ allowed: true, user: 'u1' }) + }) + + it('reject with prereqs short-circuits before contributing', async () => { + interface Upstream { + tenantId: string + } + + const withTenantOnly = defineGate< + 'tenant', + { allowed: string[] }, + Upstream, + { tenantId: string } + >({ + key: 'tenant', + run: (config) => async (_req, ctx) => { + if (!config.allowed.includes(ctx.tenantId)) { + return Response.json({ error: 'tenant_forbidden' }, { status: 403 }) + } + return { tenant: { tenantId: ctx.tenantId } } + }, + }) + + const inner = vi.fn(innerOk) + const fetchHandler = withTenantOnly({ allowed: ['acme'] }, inner) + + const blocked = await fetchHandler(new Request('http://localhost/'), { + tenantId: 'evil-corp', + }) + expect(blocked.status).toBe(403) + expect(inner).not.toHaveBeenCalled() + + const ok = await fetchHandler(new Request('http://localhost/'), { + tenantId: 'acme', + }) + expect(ok.status).toBe(200) + expect(inner).toHaveBeenCalledOnce() + }) + + it('threads upstream keys through to the inner handler unchanged', async () => { + const withStamp = passingGate('stamp', { at: 42 }) + + const fetchHandler = withStamp<{ tenantId: string }>( + undefined, + async (_req, ctx) => + Response.json({ tenant: ctx.tenantId, stamp: ctx.stamp.at }), + ) + + const res = await fetchHandler(new Request('http://localhost/'), { + tenantId: 'acme', + }) + expect(await res.json()).toEqual({ tenant: 'acme', stamp: 42 }) + }) + + // Unit-level regression test for the load-bearing inference mechanic in + // `Wrapped`. If someone simplifies that type to a single-arity + // `(req, baseCtx?: Base) => ...` form, `ctx.upstream` / `ctx.alpha` on + // the inner handler fail to typecheck — this catches the regression + // without depending on the real Supabase gate stack. + it('infers Base through nested gates when an outer wrapper provides it', async () => { + interface Upstream { + external: string + } + + // Minimal stand-in for a Base-providing outer wrapper (think: + // withSupabase). Its handler position is what gives TS the contextual + // type that propagates Base into the nested gate stack. + const withUpstream = + ( + handler: (req: Request, ctx: Upstream) => Promise, + ): ((req: Request) => Promise) => + async (req) => + handler(req, { external: 'x1' }) + + const withAlpha = passingGate('alpha', { v: 1 }) + const withBeta = passingGate('beta', { v: 2 }) + + const fetchHandler = withUpstream( + withAlpha( + undefined, + withBeta(undefined, async (_req, ctx) => + Response.json({ + ext: ctx.external, + a: ctx.alpha.v, + b: ctx.beta.v, + }), + ), + ), + ) + + const res = await fetchHandler(new Request('http://localhost/')) + expect(await res.json()).toEqual({ ext: 'x1', a: 1, b: 2 }) + }) + + it("throws if run() returns an object missing the gate's key", async () => { + const broken = defineGate< + 'broken', + undefined, + Record, + { v: number } + >({ + key: 'broken', + // Cast around the type system so we can exercise the runtime invariant — + // it catches authoring bugs that slip past excess-property checks via a + // wider-typed return. + run: () => async () => ({ wrongKey: { v: 1 } }) as never, + }) + + const fetchHandler = broken(undefined, innerOk) + + await expect( + fetchHandler(new Request('http://localhost/')), + ).rejects.toThrow(/'broken'/) + }) + + it('infers upstream context through a Supabase gate stack without annotations', () => { + // Second gate built inline so the test exercises a 3-deep stack + // (withSupabase → withFeatureFlag → inline gate → handler) without + // depending on additional published gates. + const withStamp = defineGate< + 'stamp', + undefined, + Record, + { at: number } + >({ + key: 'stamp', + run: () => async () => ({ stamp: { at: Date.now() } }), + }) + + const fetchHandler = withSupabase( + { auth: 'user', cors: false }, + withFeatureFlag( + { + name: 'beta-feedback', + evaluate: () => true, + }, + withStamp(undefined, async (_req, ctx) => { + const userId: string | undefined = ctx.userClaims?.id + const flagName: string = ctx.featureFlag.name + const stampedAt: number = ctx.stamp.at + const authMode: string = ctx.authMode + + void userId + void flagName + void stampedAt + void authMode + + return Response.json({ ok: true }) + }), + ), + ) + + void fetchHandler + }) +}) diff --git a/src/core/gates/define-gate.ts b/src/core/gates/define-gate.ts new file mode 100644 index 0000000..782e284 --- /dev/null +++ b/src/core/gates/define-gate.ts @@ -0,0 +1,213 @@ +import type { Conflict } from './types.js' + +/** + * Defines a gate. + * + * A gate is a small unit that runs against an inbound `Request` and the + * upstream context. It either short-circuits by returning a `Response`, or + * contributes a typed value at `ctx[key]` by returning a single-key object + * `{ [key]: contribution }` — the framework picks `result[key]`, merges it + * into the context, and calls the inner handler. Any other keys on the + * returned object are ignored at runtime, and TypeScript flags them at + * fresh-literal returns via excess-property checks. + * + * The returned gate has the shape `withFoo(config, handler) → fetchHandler`, + * so gates nest the same way `withSupabase` does — no separate composer. + * + * Two type-level guarantees fall out of plain TS constraints: + * + * - **Collision detection.** If the upstream context already has a key + * matching this gate's `key`, the handler position resolves to a + * `Conflict<…>` sentinel string and any function value fails to assign. + * The error surfaces at the offending gate's call site. + * - **Prerequisite enforcement.** The `In` type parameter declares what + * shape the gate requires from upstream. The wrapper constrains + * `Base extends In`, so nesting the gate where the upstream doesn't + * provide those keys is a type error at the call site. Gates with `In` + * keys also require the caller to supply `baseCtx` — they can't be the + * outermost handler unless wrapped. + * + * @typeParam Key - The literal-string key the gate contributes to ctx. + * Cannot collide with any key already on the upstream context. + * @typeParam Config - Configuration object the gate accepts. + * @typeParam In - Structural shape the gate requires from upstream. + * Defaults to `{}` (no prerequisites). Use this to declare cross-gate + * dependencies, e.g. `In = { supabase: SupabaseClient }`. + * @typeParam Contribution - Shape of the value placed at `ctx[Key]`. The + * `run` return type wraps this as `{ [Key]: Contribution }`, so the gate + * author types the slot key directly in the return position. + * + * @example No prerequisites: + * ```ts + * import { defineGate } from '@supabase/server/core/gates' + * + * export const withFeatureFlag = defineGate< + * 'featureFlag', + * { name: string; evaluate: (req: Request) => boolean }, + * {}, + * { name: string; enabled: true } + * >({ + * key: 'featureFlag', + * run: (config) => async (req) => { + * if (!config.evaluate(req)) { + * return Response.json({ error: 'feature_disabled' }, { status: 404 }) + * } + * return { featureFlag: { name: config.name, enabled: true } } + * }, + * }) + * + * // Standalone: + * withFeatureFlag({ name: 'beta', evaluate: ... }, async (req, ctx) => { + * return Response.json({ flag: ctx.featureFlag.name }) + * }) + * ``` + * + * @example Depending on upstream `withSupabase`: + * ```ts + * export const withReportAccess = defineGate< + * 'reportAccess', + * { reportId: string }, + * { supabase: SupabaseClient; userClaims: UserClaims | null }, + * { allowed: boolean } + * >({ + * key: 'reportAccess', + * run: (config) => async (_req, ctx) => { + * // ctx is typed as `{ supabase, userClaims }` — the In shape. + * const allowed = await canRead(ctx.supabase, ctx.userClaims, config.reportId) + * if (!allowed) { + * return Response.json({ error: 'forbidden' }, { status: 403 }) + * } + * return { reportAccess: { allowed } } + * }, + * }) + * + * // Composes only inside `withSupabase` (or a wrapper that provides those keys): + * withSupabase({ auth: 'user' }, + * withReportAccess({ reportId: 'r1' }, async (req, ctx) => { + * ctx.supabase // from withSupabase + * ctx.userClaims // from withSupabase + * ctx.reportAccess // from withReportAccess + * }) + * ) + * ``` + */ +export function defineGate< + const Key extends string, + Config, + In extends object = Record, + Contribution = unknown, +>(spec: { + key: Key + run: ( + config: Config, + ) => ( + req: Request, + ctx: In, + ) => Promise +}): Gate { + return ((config: Config, handler: never) => { + const inner = spec.run(config) + return async (req: Request, baseCtx?: object) => { + const upstream = baseCtx ?? ({} as object) + const result = await inner(req, upstream as In) + if (result instanceof Response) return result + // Defensive: catches authoring bugs the type system can't, e.g. a + // typo in the returned key (`{ flagg: ... }` for key 'flag') that + // slipped past excess-property checks via a wider-typed return. + if ( + result === null || + typeof result !== 'object' || + !(spec.key in result) + ) { + throw new Error( + `defineGate '${spec.key}': run() returned an object missing the gate's key '${spec.key}'`, + ) + } + const ctx = { + ...upstream, + [spec.key]: (result as Record)[spec.key], + } + return ( + handler as unknown as (req: Request, ctx: object) => Promise + )(req, ctx) + } + }) as Gate +} + +/** + * The shape of a gate — a `(config, handler) => fetchHandler` callable that + * {@link defineGate} produces. Two arms: + * + * - **No prerequisites** (`In` keys empty): `baseCtx` is optional, so the + * gate works as a standalone outermost handler. + * - **With prerequisites**: `baseCtx` is required, so the gate can only be + * composed where another wrapper provides the upstream keys. + */ +/** + * True when `T` is exactly `any`. The naive `0 extends 1 & T` formulation + * doesn't fire reliably for TypeParams in deferred-conditional positions; + * the `boolean extends (T extends never ? true : false)` form does, because + * `any` distributes the conditional to both branches and the result becomes + * `boolean` (which `boolean` extends). + */ +type IsAny = boolean extends (T extends never ? true : false) ? true : false + +/** + * The shape of a wrapped fetch handler. + * + * Gates without prerequisites expose both signatures: + * + * - `(req, baseCtx)` for composition, so TypeScript can infer `Base` from the + * outer wrapper's handler context through nested gate calls. + * - `(req)` for standalone handlers, preserving the ergonomic top-level use. + * + * A single optional `baseCtx?: Base` signature looks equivalent at runtime, but + * it prevents the outer context from flowing into nested generic calls because + * the parameter type becomes `Base | undefined`. + */ +type Wrapped = keyof In extends never + ? ((req: Request, baseCtx: Base) => Promise) & + ((req: Request) => Promise) + : (req: Request, baseCtx: Base) => Promise + +/** + * Constraint that surfaces a key collision as a TypeScript error at the + * offending gate's call site. When the upstream `Base` already has the gate's + * `Key`, this resolves to `Conflict` (a sentinel string), which `Base` + * (an `object`) cannot extend — TypeScript reports the conflict citing the + * literal conflict message. + * + * Critically, this constraint sits next to `Base extends In` in the type + * parameter list, *not* in the return-type or handler-parameter position. A + * conditional type wrapping the return or handler types would block contextual + * inference of `Base` from the outer caller. By contrast, a constraint is + * checked but doesn't gate inference flow: TS infers `Base` from the + * contextual handler shape first, then validates the conflict constraint. + * + * This is what lets nested gates pick up their upstream context types + * automatically — no explicit `` annotations needed at each level. + * + * `any` Base (common in tests via `vi.fn` inference) skips the check because + * `keyof any` would false-positive every key. + */ +type NoConflict = + IsAny extends true + ? object + : Key extends keyof Base + ? Conflict + : object + +export interface Gate< + Key extends string, + Config, + In extends object, + Contribution, +> { + >( + config: Config, + handler: ( + req: Request, + ctx: Base & { [K in Key]: Contribution }, + ) => Promise, + ): Wrapped +} diff --git a/src/core/gates/index.ts b/src/core/gates/index.ts new file mode 100644 index 0000000..53283d7 --- /dev/null +++ b/src/core/gates/index.ts @@ -0,0 +1,16 @@ +/** + * Gate composition primitives. + * + * - {@link defineGate} — author-facing helper for declaring a gate. + * + * Gates compose by direct nesting: each `withFoo(config, handler)` is a + * fetch-handler wrapper that runs its check, contributes a flat key to the + * context, and either short-circuits or invokes the inner handler. Nest them + * the same way `withSupabase` nests around a handler. + * + * @packageDocumentation + */ + +export { defineGate } from './define-gate.js' +export type { Gate } from './define-gate.js' +export type { Conflict } from './types.js' diff --git a/src/core/gates/types.ts b/src/core/gates/types.ts new file mode 100644 index 0000000..44ebbd0 --- /dev/null +++ b/src/core/gates/types.ts @@ -0,0 +1,15 @@ +/** + * Type primitives for the gate composition system. + * + * @packageDocumentation + */ + +/** + * Sentinel type used in a gate's wrapper signature to surface a key collision + * with the upstream context as a TypeScript error at the gate's call site. + * + * The literal string is part of the type so it appears in the error message + * (TypeScript prints "Type '…' is not assignable to type 'gate-conflict: …'"). + */ +export type Conflict = + `gate-conflict: key '${Key}' is already present on the upstream context` diff --git a/src/gates/README.md b/src/gates/README.md new file mode 100644 index 0000000..97b359c --- /dev/null +++ b/src/gates/README.md @@ -0,0 +1,120 @@ +# Writing a gate + +This directory holds the **gates** that ship with `@supabase/server`. A gate is a `(config, handler)` fetch-handler wrapper — same shape as `withSupabase` — that runs against the inbound `Request`, contributes a typed key to `ctx`, and either short-circuits with a `Response` or falls through to the inner handler. Anyone can publish a gate as a standalone npm package; the built-ins use the same `defineGate` primitive third-party authors do. + +This README is for **gate authors**. If you just want to _use_ a gate, see [`src/core/gates/README.md`](../core/gates/README.md). + +## The worked example + +[`feature-flag/`](./feature-flag/) is the canonical reference. It is short, well-commented, and exercises every piece of the pattern — config, contribution, prerequisites, short-circuit vs fall-through. Read it alongside this guide. + +``` +src/gates/feature-flag/ +├── README.md ← consumer-facing docs +├── index.ts ← public exports +├── with-feature-flag.ts ← implementation +└── with-feature-flag.test.ts ← behavioural tests +``` + +## Anatomy of a gate + +`defineGate` takes four type parameters and one spec object: + +```ts +defineGate({ key, run }) +``` + +| Parameter | What it is | Example | +| -------------- | ------------------------------------------------------------- | ----------------------------- | +| `Key` | The literal-string slot the gate contributes to `ctx`. | `'featureFlag'` | +| `Config` | The object the consumer passes to `withFoo(config, handler)`. | `WithFeatureFlagConfig` | +| `In` | Upstream prerequisites — what must already be on `ctx`. | `Record` (none) | +| `Contribution` | The shape that lands at `ctx[Key]` after a successful run. | `FeatureFlagState` | + +```ts +export const withFeatureFlag: Gate< + 'featureFlag', // Key + WithFeatureFlagConfig, // Config + Record, // In (no prerequisites) + FeatureFlagState // Contribution +> = defineGate(/* ... */) +``` + +## `run` has two stages + +```ts +run: (config: Config) => (req: Request, ctx: In) => + Promise +``` + +- **Outer `(config) =>`** runs **once** when the consumer constructs the gate. Initialize per-instance state here: clients, computed config, memoized fetches. +- **Inner `(req, ctx) =>`** runs **per request**. It receives the request and the upstream-supplied `ctx` typed as `In`. + +The inner stage returns one of two shapes: + +| Return | Effect | +| ------------------------- | ------------------------------------------------------- | +| `Response` | **Short-circuit.** The inner handler is never invoked. | +| `{ [Key]: Contribution }` | **Fall through.** The contribution lands at `ctx[Key]`. | + +The runtime picks `result[key]` off the contribution object and ignores any other fields, so a single `return { featureFlag: { ... } }` is all the author writes. + +## Authoring rules + +1. **One key per gate.** A gate that wants multiple slots is doing too much — split it. +2. **No response shaping.** Gates don't observe or wrap the inner handler's response. Anything response-shaped — rate-limit headers, CORS, response envelopes — is the handler's job. Keeps each gate's surface small and the response shape under one owner. +3. **Declare prerequisites in `In`.** If your gate needs `ctx.userClaims`, set `In = { userClaims: UserClaims | null }`. Standalone use then fails to compile — a real error, not a runtime surprise. +4. **Pick a unique key.** If two gates contribute the same key, composition fails with a type error at the offending call site (the inner returns the `Conflict` sentinel string). For gates that may be applied multiple times, accept a `key` override in config. + +## Directory layout for a gate in this repo + +Mirror `feature-flag/`: + +``` +src/gates// +├── README.md ← consumer-facing: what it does, config, examples +├── index.ts ← export the gate + its public types +├── with-.ts ← the gate itself +└── with-.test.ts ← vitest, exercises the run stages +``` + +Conventions: + +- Directory name is **kebab-case** (`feature-flag`, `rate-limit`). +- Function is **`withCamelCase`** (`withFeatureFlag`, `withRateLimit`). +- The key on `ctx` is **camelCase** matching the function name minus the `with` prefix (`ctx.featureFlag`, `ctx.rateLimit`). +- Export the config / contribution interfaces alongside the gate so consumers can type their own wrappers. + +## Wiring up a new gate + +To add a gate to this package, three files change in addition to the new directory: + +1. **[`package.json`](../../package.json)** — add an entry to `exports`: + ```json + "./gates/": { + "types": "./dist/gates//index.d.mts", + "import": "./dist/gates//index.mjs", + "require": "./dist/gates//index.cjs" + } + ``` +2. **[`tsdown.config.ts`](../../tsdown.config.ts)** — add `'src/gates//index.ts'` to `entry`. +3. **[`jsr.json`](../../jsr.json)** — add `"./gates/": "./src/gates//index.ts"`. + +A third-party gate published as its own npm package skips all three — it just exports the result of `defineGate` and depends on `@supabase/server` for the primitive. + +## Testing the run stages + +The worked example in [`feature-flag/with-feature-flag.test.ts`](./feature-flag/with-feature-flag.test.ts) shows the cases worth covering for any gate: + +- Admits and contributes the expected `ctx[Key]` shape. +- Short-circuits with the configured status / body on reject. +- Honors override config (custom status, custom body). +- Passes the `Request` through, so author-supplied evaluators see header / IP / method. +- Supports async work inside `run`. + +Use `vi.fn` for the inner handler when you need to assert it was (or wasn't) called. + +## See also + +- [`src/core/gates/README.md`](../core/gates/README.md) — composition rules, `ctx` shape, conflict and prerequisite enforcement. +- [`feature-flag/`](./feature-flag/) — the worked example referenced throughout this guide. diff --git a/src/gates/feature-flag/README.md b/src/gates/feature-flag/README.md new file mode 100644 index 0000000..3dc9871 --- /dev/null +++ b/src/gates/feature-flag/README.md @@ -0,0 +1,90 @@ +# `@supabase/server/gates/feature-flag` + +Provider-agnostic feature-flag gate. Pass any `evaluate` function — the gate calls it per request, admits when the flag is on, rejects otherwise. Use it with PostHog, LaunchDarkly, Statsig, an env-var, a header, a database row — anything that can answer "is this flag enabled for this request?". + +> This is the worked example for gate authors. The implementation is short and well-commented — read [`with-feature-flag.ts`](./with-feature-flag.ts) alongside the [authoring guide](../README.md) to see how each piece of `defineGate` lands in practice. + +```ts +import { withFeatureFlag } from '@supabase/server/gates/feature-flag' + +export default { + fetch: withFeatureFlag( + { + name: 'beta-checkout', + evaluate: (req) => req.headers.get('x-beta') === '1', + }, + async (_req, ctx) => Response.json({ feature: ctx.featureFlag.name }), + ), +} +``` + +## Config + +| Field | Type | Description | +| -------------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| `name` | `string` | Recorded in `ctx.featureFlag.name` and the default rejection body. | +| `evaluate` | `(req) => boolean \| FeatureFlagVerdict \| Promise` | Decide whether the flag is enabled for this request. | +| `rejectStatus` | `number?` | Status when the flag rejects. Default `404` (soft reveal). | +| `rejectBody` | `unknown?` | Body when the flag rejects. Default `{ error: 'feature_disabled', flag: }`. | + +## Returning richer verdicts + +`evaluate` can return a verdict object to capture variant or payload: + +```ts +withFeatureFlag({ + name: 'pricing-experiment', + evaluate: async (req) => { + const variant = await ld.variation('pricing-experiment', userKey, 'control') + return { enabled: variant !== 'off', variant, payload: { rollout: 0.5 } } + }, +}) +``` + +Then the handler reads: + +```ts +ctx.featureFlag.variant // 'a' | 'b' | 'control' | null +ctx.featureFlag.payload // anything you returned +``` + +## Why 404 by default + +Soft reveal. A `403 Forbidden` tells the caller "this exists, but you can't see it" — useful intel for an attacker probing for unreleased endpoints. `404 Not Found` says "there's nothing here." Override via `rejectStatus` if you need stricter or different semantics. + +## Composing with auth-aware flags + +Place `withFeatureFlag` _after_ `withSupabase` to target by user identity: + +```ts +import { withSupabase } from '@supabase/server' +import { withFeatureFlag } from '@supabase/server/gates/feature-flag' + +withSupabase( + { auth: 'user' }, + withFeatureFlag( + { + name: 'beta-checkout', + evaluate: async (req) => { + // Plug in an identity-aware provider; derive the user id from a + // header the auth layer has already validated, or stash it via a + // tiny outer wrapper that runs before this gate. + const userId = req.headers.get('x-user-id') ?? 'anon' + return await posthog.isFeatureEnabled('beta-checkout', userId) + }, + }, + handler, + ), +) +``` + +The current `evaluate` signature only sees the request — for user-aware flags, derive the identity from a request signal the auth layer has already validated, or wait for a future enhancement that threads ctx into the evaluator. + +## Single namespace caveat + +The gate occupies `ctx.featureFlag` — only one `withFeatureFlag` can compose into a stack at a time. For multiple flags on the same route, write a single composite evaluator that returns a richer verdict, or run separate routes per flag. + +## See also + +- [Gate authoring guide](../README.md) +- [Gate composition primitives](../../core/gates/README.md) diff --git a/src/gates/feature-flag/index.ts b/src/gates/feature-flag/index.ts new file mode 100644 index 0000000..fbf506d --- /dev/null +++ b/src/gates/feature-flag/index.ts @@ -0,0 +1,12 @@ +/** + * Feature-flag gate. + * + * @packageDocumentation + */ + +export { withFeatureFlag } from './with-feature-flag.js' +export type { + FeatureFlagState, + FeatureFlagVerdict, + WithFeatureFlagConfig, +} from './with-feature-flag.js' diff --git a/src/gates/feature-flag/with-feature-flag.test.ts b/src/gates/feature-flag/with-feature-flag.test.ts new file mode 100644 index 0000000..96d4145 --- /dev/null +++ b/src/gates/feature-flag/with-feature-flag.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it, vi } from 'vitest' + +import { withFeatureFlag } from './with-feature-flag.js' + +const innerOk = async () => Response.json({ ok: true }) + +describe('withFeatureFlag', () => { + it('admits when evaluate returns true and contributes the flag state', async () => { + const inner = vi.fn(async (_req: Request, ctx) => { + expect(ctx.featureFlag).toEqual({ + name: 'beta', + enabled: true, + variant: null, + payload: null, + }) + return Response.json({ ok: true }) + }) + + const handler = withFeatureFlag( + { name: 'beta', evaluate: () => true }, + inner, + ) + + const res = await handler(new Request('http://localhost/')) + expect(res.status).toBe(200) + expect(inner).toHaveBeenCalledOnce() + }) + + it('rejects with 404 by default when evaluate returns false', async () => { + const handler = withFeatureFlag( + { name: 'beta', evaluate: () => false }, + innerOk, + ) + + const res = await handler(new Request('http://localhost/')) + expect(res.status).toBe(404) + expect(await res.json()).toEqual({ + error: 'feature_disabled', + flag: 'beta', + }) + }) + + it('honors a custom rejectStatus and rejectBody', async () => { + const handler = withFeatureFlag( + { + name: 'beta', + evaluate: () => false, + rejectStatus: 403, + rejectBody: { code: 'NOT_ROLLED_OUT' }, + }, + innerOk, + ) + + const res = await handler(new Request('http://localhost/')) + expect(res.status).toBe(403) + expect(await res.json()).toEqual({ code: 'NOT_ROLLED_OUT' }) + }) + + it('captures variant + payload when evaluate returns a verdict object', async () => { + const inner = vi.fn(async (_req: Request, ctx) => { + expect(ctx.featureFlag.variant).toBe('green') + expect(ctx.featureFlag.payload).toEqual({ rollout: 0.25 }) + return Response.json({ ok: true }) + }) + + const handler = withFeatureFlag( + { + name: 'beta', + evaluate: () => ({ + enabled: true, + variant: 'green', + payload: { rollout: 0.25 }, + }), + }, + inner, + ) + + await handler(new Request('http://localhost/')) + expect(inner).toHaveBeenCalledOnce() + }) + + it('passes the request to evaluate so flags can target by header / IP / user', async () => { + const evaluate = vi.fn((req: Request) => req.headers.get('x-beta') === '1') + + const handler = withFeatureFlag({ name: 'beta', evaluate }, innerOk) + + const off = await handler(new Request('http://localhost/')) + expect(off.status).toBe(404) + + const on = await handler( + new Request('http://localhost/', { headers: { 'x-beta': '1' } }), + ) + expect(on.status).toBe(200) + + expect(evaluate).toHaveBeenCalledTimes(2) + }) + + it('supports async evaluators', async () => { + const handler = withFeatureFlag( + { + name: 'beta', + evaluate: async () => { + await new Promise((r) => setTimeout(r, 1)) + return { enabled: true, variant: 'a' } + }, + }, + async (_req, ctx) => Response.json({ variant: ctx.featureFlag.variant }), + ) + + const res = await handler(new Request('http://localhost/')) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ variant: 'a' }) + }) +}) diff --git a/src/gates/feature-flag/with-feature-flag.ts b/src/gates/feature-flag/with-feature-flag.ts new file mode 100644 index 0000000..6675d77 --- /dev/null +++ b/src/gates/feature-flag/with-feature-flag.ts @@ -0,0 +1,154 @@ +/** + * Feature-flag gate — the canonical example of a `defineGate` implementation. + * + * Provider-agnostic: pass any `evaluate` function (PostHog, LaunchDarkly, + * Statsig, a header check, a database lookup). The gate calls it per request + * and either admits with the verdict at `ctx.featureFlag` or short-circuits + * with a configurable response. + * + * Read alongside `src/gates/README.md` and `src/core/gates/README.md` — this + * file is referenced from both as the worked example of the pattern. + */ + +import { defineGate, type Gate } from '../../core/gates/index.js' + +/** + * Per-instance configuration the consumer passes to `withFeatureFlag(config, handler)`. + * + * Keep this surface small — every field becomes part of the gate's public API. + */ +export interface WithFeatureFlagConfig { + /** Human-readable name for the flag. Echoed back on `ctx.featureFlag.name` and the default rejection body. */ + name: string + + /** + * Decide whether the flag is enabled for this request. + * + * Return `true`/`false` for a simple on-off check, or a {@link FeatureFlagVerdict} + * to also record a variant or provider payload. Async is fine. + */ + evaluate: ( + req: Request, + ) => Promise | boolean | FeatureFlagVerdict + + /** + * HTTP status to use when the flag rejects. Default is 404 — "this feature + * doesn't exist for you yet" — which is a softer reveal than 403 and avoids + * tipping off attackers about the existence of gated functionality. + * + * @defaultValue `404` + */ + rejectStatus?: number + + /** Body to use when the flag rejects. @defaultValue `{ error: 'feature_disabled', flag: }` */ + rejectBody?: unknown +} + +/** + * Richer return shape `evaluate` may produce, in place of a plain boolean, + * when an A/B variant or provider payload is worth carrying through to the + * handler. + */ +export interface FeatureFlagVerdict { + enabled: boolean + /** A/B test variant if applicable. */ + variant?: string | null + /** Provider-specific payload (rollout %, targeting rules, etc.). */ + payload?: unknown +} + +/** + * Shape contributed at `ctx.featureFlag` after a successful evaluation. + * + * `enabled: true` is encoded in the type — the handler only ever sees this + * shape when the flag admitted, so `if (!ctx.featureFlag.enabled)` is a dead + * branch by construction. The contribution shape is the contract this gate + * offers downstream handlers. + */ +export interface FeatureFlagState { + name: string + enabled: true + variant: string | null + payload: unknown +} + +/** + * Feature-flag gate. + * + * @example + * ```ts + * import { withFeatureFlag } from '@supabase/server/gates/feature-flag' + * + * export default { + * fetch: withFeatureFlag( + * { + * name: 'beta-checkout', + * evaluate: (req) => req.headers.get('x-beta') === '1', + * }, + * async (_req, ctx) => Response.json({ feature: ctx.featureFlag.name }), + * ), + * } + * ``` + * + * Pluggable providers — use whatever you like in `evaluate`: + * + * ```ts + * withFeatureFlag({ + * name: 'beta-checkout', + * evaluate: async (req) => { + * const userId = req.headers.get('x-user-id') ?? 'anon' + * return await posthog.isFeatureEnabled('beta-checkout', userId) + * }, + * }) + * ``` + */ +export const withFeatureFlag: Gate< + // 1. Key — the slot this gate contributes to `ctx`. Must be unique in a stack. + 'featureFlag', + // 2. Config — what the consumer passes to `withFeatureFlag(config, handler)`. + WithFeatureFlagConfig, + // 3. In — upstream prerequisites. `Record` = no prerequisites, + // so this gate can be used standalone or anywhere in a stack. + Record, + // 4. Contribution — the shape that lands at `ctx.featureFlag`. + FeatureFlagState +> = defineGate< + 'featureFlag', + WithFeatureFlagConfig, + Record, + FeatureFlagState +>({ + key: 'featureFlag', + /** + * Two-stage function. The outer `(config) =>` runs once when the consumer + * constructs the gate — initialize per-instance state here (clients, + * computed config). The inner `(req, _ctx) =>` runs per request. + * + * Return a `Response` to short-circuit (the inner handler never runs), or a + * single-key object `{ [key]: contribution }` to fall through. The runtime + * picks `result[key]` off the contribution and ignores any other fields. + */ + run: (config) => async (req) => { + const result = await config.evaluate(req) + const verdict: FeatureFlagVerdict = + typeof result === 'boolean' ? { enabled: result } : result + + if (!verdict.enabled) { + // Short-circuit: the inner handler is never invoked. + return Response.json( + config.rejectBody ?? { error: 'feature_disabled', flag: config.name }, + { status: config.rejectStatus ?? 404 }, + ) + } + + // Contribute: fall through to the inner handler with this shape on ctx. + return { + featureFlag: { + name: config.name, + enabled: true, + variant: verdict.variant ?? null, + payload: verdict.payload ?? null, + }, + } + }, +}) diff --git a/src/types.ts b/src/types.ts index 438e11e..76317a9 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,34 @@ 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. + * + * @example + * ```ts + * import { HTTPException } from 'hono/http-exception' + * + * withSupabase({ + * auth: 'user', + * onAuthError: (error) => { + * throw new HTTPException(error.status as 401 | 500, { + * message: error.message, + * cause: error, + * }) + * }, + * }, handler) + * ``` + */ + onAuthError?: (error: AuthError) => never } /** diff --git a/src/with-supabase.test.ts b/src/with-supabase.test.ts index 8eec024..82d5f64 100644 --- a/src/with-supabase.test.ts +++ b/src/with-supabase.test.ts @@ -98,6 +98,78 @@ describe('withSupabase', () => { expect(res.headers.get('Access-Control-Allow-Origin')).toBeNull() }) + describe('onAuthError callback', () => { + it('invokes onAuthError on auth failure', async () => { + const onAuthError = vi.fn((err: Error): never => { + throw err + }) + const handler = withSupabase( + { auth: 'user', env: baseEnv, onAuthError }, + async () => Response.json({ ok: true }), + ) + + const req = new Request('http://localhost') + await expect(handler(req)).rejects.toThrow() + expect(onAuthError).toHaveBeenCalledTimes(1) + const [calledWith] = onAuthError.mock.calls[0] + expect(calledWith).toMatchObject({ + name: 'AuthError', + status: 401, + code: expect.any(String), + message: expect.any(String), + }) + }) + + it('propagates the error thrown by onAuthError', async () => { + class MyError extends Error { + readonly cause: unknown + constructor(cause: unknown) { + super('framework-native') + this.cause = cause + } + } + const handler = withSupabase( + { + auth: 'user', + env: baseEnv, + onAuthError: (error) => { + throw new MyError(error) + }, + }, + async () => Response.json({ ok: true }), + ) + + const req = new Request('http://localhost') + await expect(handler(req)).rejects.toBeInstanceOf(MyError) + }) + + it('falls back to JSON response when onAuthError is absent (unchanged default)', async () => { + const handler = withSupabase({ auth: 'user', env: baseEnv }, async () => + Response.json({ ok: true }), + ) + + const req = new Request('http://localhost') + const res = await handler(req) + expect(res.status).toBe(401) + const body = await res.json() + expect(body.message).toBeDefined() + }) + + it('does not invoke onAuthError on successful auth', async () => { + const onAuthError = vi.fn((): never => { + throw new Error('should not be called') + }) + const handler = withSupabase( + { auth: 'none', env: baseEnv, onAuthError }, + async () => Response.json({ ok: true }), + ) + + const req = new Request('http://localhost') + await handler(req) + expect(onAuthError).not.toHaveBeenCalled() + }) + }) + describe('allow → auth deprecation', () => { beforeEach(() => { _resetAllowDeprecationWarned() diff --git a/src/with-supabase.ts b/src/with-supabase.ts index b155184..416087b 100644 --- a/src/with-supabase.ts +++ b/src/with-supabase.ts @@ -25,6 +25,14 @@ import type { SupabaseContext, WithSupabaseConfig } from './types.js' * } * ``` */ +export function withSupabase( + config: WithSupabaseConfig, + handler: (req: Request, ctx: SupabaseContext) => Promise, +): (req: Request) => Promise +export function withSupabase( + config: WithSupabaseConfig, + handler: (req: Request, ctx: SupabaseContext) => Promise, +): (req: Request) => Promise export function withSupabase( config: WithSupabaseConfig, handler: (req: Request, ctx: SupabaseContext) => Promise, @@ -42,6 +50,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..ee109fc 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -4,8 +4,11 @@ export default defineConfig({ entry: [ 'src/index.ts', 'src/core/index.ts', + 'src/core/gates/index.ts', + 'src/core/adapters/index.ts', 'src/adapters/hono/index.ts', 'src/adapters/h3/index.ts', + 'src/gates/feature-flag/index.ts', 'src/adapters/elysia/index.ts', ], format: ['esm', 'cjs'],