diff --git a/CHANGELOG.md b/CHANGELOG.md index 54667b5..b7079ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index 79ac64d..a03c953 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 diff --git a/jsr.json b/jsr.json index f39153a..4366da3 100644 --- a/jsr.json +++ b/jsr.json @@ -4,17 +4,12 @@ "exports": { ".": "./src/index.ts", "./core": "./src/core/index.ts", - "./adapters/hono": "./src/adapters/hono/index.ts" + "./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"] } } diff --git a/package.json b/package.json index 2ce1dc0..1bbe698 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/src/core/gates/README.md b/src/core/gates/README.md new file mode 100644 index 0000000..e00eb46 --- /dev/null +++ b/src/core/gates/README.md @@ -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.` (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. **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 +``` + +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/with-supabase.ts b/src/with-supabase.ts index b155184..9ae93f8 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, diff --git a/tsdown.config.ts b/tsdown.config.ts index 625dd8f..cbd9c04 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -4,8 +4,10 @@ export default defineConfig({ entry: [ 'src/index.ts', 'src/core/index.ts', + 'src/core/gates/index.ts', 'src/adapters/hono/index.ts', 'src/adapters/h3/index.ts', + 'src/gates/feature-flag/index.ts', ], format: ['esm', 'cjs'], dts: true,