Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
785577e
feat(gates): add composable gate primitives for fetch handlers
johnstonmatt May 2, 2026
44365a9
feat(gates): add x402 paywall gate
johnstonmatt May 2, 2026
d240d9b
feat(gates): add Cloudflare Turnstile gate
johnstonmatt May 2, 2026
eac3548
feat(gates): add Cloudflare Access gate
johnstonmatt May 2, 2026
4318b35
feat(gates): add webhook signature verification gate
johnstonmatt May 2, 2026
9b784b8
feat(gates): add fixed-window rate-limit gate
johnstonmatt May 2, 2026
fe612da
feat(gates): add provider-agnostic feature-flag gate
johnstonmatt May 2, 2026
5e33c35
chore(gates): register flag, rate-limit, and webhook subpath exports
johnstonmatt May 2, 2026
ac38951
refactor(gates)!: replace chain composer with direct nesting
johnstonmatt May 2, 2026
ecd985d
refactor(gates)!: replace pluggable stores with Supabase RPC persistence
johnstonmatt May 2, 2026
7dfb762
Merge branch 'main' of https://github.com/supabase/server into FUNC-5…
johnstonmatt May 2, 2026
b579ea5
refactor(gates)!: replace GateResult union with direct Response or ke…
johnstonmatt May 7, 2026
6508c4a
refactor(gates)!: infer nested ctx without explicit Base annotations
johnstonmatt May 7, 2026
20e2b89
test(gates): add regression tests and runtime check for missing gate key
johnstonmatt May 7, 2026
b65339e
Merge branch 'main' of https://github.com/supabase/server into FUNC-5…
johnstonmatt May 7, 2026
0683053
test(gates): rename authType to authMode in nested ctx test
johnstonmatt May 7, 2026
359f757
refactor(gates): annotate built-in gates with explicit GateFactory types
johnstonmatt May 7, 2026
e62e830
refactor(gates)!: rename GateFactory type to Gate
johnstonmatt May 7, 2026
4b6827f
feat(gates/webhook): add built-in GitHub provider
johnstonmatt May 8, 2026
69eda65
docs(gates): reframe as portable extensibility layer
johnstonmatt May 8, 2026
b5b4851
docs(gates): document composition rules for stacking gates
johnstonmatt May 8, 2026
64af25e
docs(gates): lead README with withSupabase analogy
johnstonmatt May 8, 2026
677efea
docs(gates): tighten README prose and drop internal asides
johnstonmatt May 8, 2026
8ff9455
Merge branch 'main' of https://github.com/supabase/server into FUNC-5…
johnstonmatt May 11, 2026
cbffc47
refactor(gates): consolidate built-ins to feature-flag worked example
johnstonmatt May 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@

## [1.0.0](https://github.com/supabase/server/compare/server-v0.2.0...server-v1.0.0) (2026-05-06)


### Miscellaneous Chores

* release 1.0.0 ([#50](https://github.com/supabase/server/issues/50)) ([67de77f](https://github.com/supabase/server/commit/67de77f00b7ebbf4e1de973489703959c7e3a838))
- release 1.0.0 ([#50](https://github.com/supabase/server/issues/50)) ([67de77f](https://github.com/supabase/server/commit/67de77f00b7ebbf4e1de973489703959c7e3a838))

## [0.2.0](https://github.com/supabase/server/compare/server-v0.1.4...server-v0.2.0) (2026-04-24)

Expand Down
72 changes: 53 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,35 @@ export default { fetch: app.fetch }

See [docs/adapters/h3.md](docs/adapters/h3.md) for per-route auth, Nuxt server-middleware patterns, CORS, and more.

## Gates

The portable extensibility layer for `@supabase/server`. A **gate** is a fetch-handler wrapper that bolts a capability — rate limiting, webhook signature verification, paywalls, feature flags, bot checks — onto a handler and contributes typed data to a flat key on `ctx`. Anyone can publish a gate as a standalone npm package; the built-ins use the same primitive third-party authors do. Because gates are plain wrappers over the Web Fetch API, the same gate runs unchanged across Workers, Deno, Bun, Node, and through every adapter (Hono, H3) — nest them directly the way `withSupabase` does, no separate composer.

```ts
import { withSupabase } from '@supabase/server'
import { withFeatureFlag } from '@supabase/server/gates/feature-flag'

export default {
fetch: withSupabase(
{ auth: 'user' },
withFeatureFlag(
{ name: 'beta-checkout', evaluate: (req) => req.headers.has('x-beta') },
async (_req, ctx) => {
// ctx.supabase, ctx.userClaims — from withSupabase
// ctx.featureFlag — from withFeatureFlag
return Response.json({ feature: ctx.featureFlag.name })
},
),
),
}
```

`withSupabase` is the host wrapper, not a gate — it establishes `SupabaseContext` and hands it to whatever it wraps. Gates nest inside it (or stand alone), and TypeScript infers the accumulated `ctx` shape through the nested wrappers.

- [`@supabase/server/core/gates`](src/core/gates/README.md) — authoring primitives (`defineGate`, ctx rules, prerequisite enforcement, conflict detection).
- [`src/gates/README.md`](src/gates/README.md) — guide for writing your own gate.
- [`@supabase/server/gates/feature-flag`](src/gates/feature-flag/README.md) — `withFeatureFlag`, the worked example gate.

## Primitives

For when you need more control than `withSupabase` provides — multiple routes with different auth, custom response headers, or building your own wrapper.
Expand Down Expand Up @@ -434,28 +463,33 @@ No. `@supabase/ssr` handles cookie-based session management for frameworks like

## Exports

| Export | What's in it |
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `@supabase/server` | `withSupabase`, `createSupabaseContext` |
| `@supabase/server/core` | `verifyAuth`, `verifyCredentials`, `extractCredentials`, `createContextClient`, `createAdminClient`, `resolveEnv` |
| `@supabase/server/adapters/hono` | `withSupabase` (Hono middleware) |
| `@supabase/server/adapters/h3` | `withSupabase` (H3 / Nuxt middleware) |
| Export | What's in it |
| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `@supabase/server` | `withSupabase`, `createSupabaseContext` |
| `@supabase/server/core` | `verifyAuth`, `verifyCredentials`, `extractCredentials`, `createContextClient`, `createAdminClient`, `resolveEnv` |
| `@supabase/server/adapters/hono` | `withSupabase` (Hono middleware) |
| `@supabase/server/adapters/h3` | `withSupabase` (H3 / Nuxt middleware) |
| `@supabase/server/core/gates` | `defineGate` (gate composition primitives) |
| `@supabase/server/gates/feature-flag` | `withFeatureFlag` (provider-agnostic feature-flag gate; worked example for gate authors) |

## Documentation

| Question | Doc file |
| ------------------------------------------------------------------- | ---------------------------------------------------------------- |
| How do I create a basic endpoint? | [`docs/getting-started.md`](docs/getting-started.md) |
| What auth modes are available? Array syntax? Named keys? | [`docs/auth-modes.md`](docs/auth-modes.md) |
| 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 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) |
| How do I get typed database queries? | [`docs/typescript-generics.md`](docs/typescript-generics.md) |
| How do I use this with `@supabase/ssr` (Next.js, SvelteKit, Remix)? | [`docs/ssr-frameworks.md`](docs/ssr-frameworks.md) |
| What's the complete API surface? | [`docs/api-reference.md`](docs/api-reference.md) |
| Question | Doc file |
| ------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| How do I create a basic endpoint? | [`docs/getting-started.md`](docs/getting-started.md) |
| What auth modes are available? Array syntax? Named keys? | [`docs/auth-modes.md`](docs/auth-modes.md) |
| 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 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) |
| How do I get typed database queries? | [`docs/typescript-generics.md`](docs/typescript-generics.md) |
| How do I use this with `@supabase/ssr` (Next.js, SvelteKit, Remix)? | [`docs/ssr-frameworks.md`](docs/ssr-frameworks.md) |
| What's the complete API surface? | [`docs/api-reference.md`](docs/api-reference.md) |
| How do I extend a handler with a gate? | [`src/core/gates/README.md`](src/core/gates/README.md) |
| How do I write my own gate? | [`src/gates/README.md`](src/gates/README.md) |
| How do I gate a route behind a feature flag? | [`src/gates/feature-flag/README.md`](src/gates/feature-flag/README.md) |

## Development

Expand Down
15 changes: 5 additions & 10 deletions jsr.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,12 @@
"exports": {
".": "./src/index.ts",
"./core": "./src/core/index.ts",
"./adapters/hono": "./src/adapters/hono/index.ts"
"./core/gates": "./src/core/gates/index.ts",
"./adapters/hono": "./src/adapters/hono/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"]
}
}
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@
"import": "./dist/core/index.mjs",
"require": "./dist/core/index.cjs"
},
"./core/gates": {
"types": "./dist/core/gates/index.d.mts",
"import": "./dist/core/gates/index.mjs",
"require": "./dist/core/gates/index.cjs"
},
"./adapters/hono": {
"types": "./dist/adapters/hono/index.d.mts",
"import": "./dist/adapters/hono/index.mjs",
Expand All @@ -39,6 +44,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"
},
"./package.json": "./package.json"
},
"main": "./dist/index.cjs",
Expand Down
199 changes: 199 additions & 0 deletions src/core/gates/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
# `@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 through every adapter (Hono, H3).

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.<gate-key>` (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<Key>` 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. **Short-circuit or contribute — not both.** A gate's `run` returns either a `Response` (short-circuit, inner never runs) or a contribution `{ [key]: … }` (fall through). 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: 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.

## 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<Response | { [K in Key]: Contribution }>
```

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<Plan | null> },
{ 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<Key>` | Sentinel string returned when a gate would shadow an upstream key. |
| `Gate<Key, Config, In, Contribution>` | The shape of a gate produced by `defineGate`. |
Loading
Loading