From 785577e2b289f328cd174165707b568101bfc571 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Sat, 2 May 2026 01:32:20 -0300 Subject: [PATCH 01/22] feat(gates): add composable gate primitives for fetch handlers --- jsr.json | 12 +- package.json | 5 + src/core/gates/README.md | 195 ++++++++++++++++++++++++++++ src/core/gates/chain.test.ts | 231 ++++++++++++++++++++++++++++++++++ src/core/gates/chain.ts | 73 +++++++++++ src/core/gates/define-gate.ts | 52 ++++++++ src/core/gates/index.ts | 21 ++++ src/core/gates/types.ts | 95 ++++++++++++++ tsdown.config.ts | 1 + 9 files changed, 676 insertions(+), 9 deletions(-) create mode 100644 src/core/gates/README.md create mode 100644 src/core/gates/chain.test.ts create mode 100644 src/core/gates/chain.ts create mode 100644 src/core/gates/define-gate.ts create mode 100644 src/core/gates/index.ts create mode 100644 src/core/gates/types.ts diff --git a/jsr.json b/jsr.json index aa2c69d..5ed82d7 100644 --- a/jsr.json +++ b/jsr.json @@ -4,17 +4,11 @@ "exports": { ".": "./src/index.ts", "./core": "./src/core/index.ts", + "./core/gates": "./src/core/gates/index.ts", "./adapters/hono": "./src/adapters/hono/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 96b5199..9257664 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", diff --git a/src/core/gates/README.md b/src/core/gates/README.md new file mode 100644 index 0000000..c2720fe --- /dev/null +++ b/src/core/gates/README.md @@ -0,0 +1,195 @@ +# @supabase/server/core/gates + +Composable preconditions for fetch handlers. A **gate** is a small unit that runs against an inbound `Request` and either short-circuits with a `Response` or contributes typed data to `ctx.state[namespace]` for the handler. + +This module exports two helpers: + +- **`defineGate`** — for _gate authors_ writing a new integration. +- **`chain`** — for _gate consumers_ composing gates into a fetch handler. + +`withSupabase` is **not** a gate. It's a fetch-handler wrapper that establishes `SupabaseContext`. Gates compose _inside_ it (or standalone). + +## Quick start (consumer) + +```ts +import { withSupabase } from '@supabase/server' +import { chain } from '@supabase/server/core/gates' +import { withPayment } from '@supabase/server/gates/x402' + +export default { + fetch: withSupabase( + { allow: 'user' }, + chain(withPayment({ stripe, amountCents: 5 }))(async (req, ctx) => { + // ctx.supabase, ctx.userClaims — from withSupabase + // ctx.state.payment.intentId — from withPayment + // ctx.locals.foo = 'bar' — free per-request scratch + return Response.json({ paid: ctx.state.payment.intentId }) + }), + ), +} +``` + +Standalone (no `withSupabase`): + +```ts +export default { + fetch: chain(withPayment({ stripe, amountCents: 1 }))(async (req, ctx) => { + return Response.json({ paid: ctx.state.payment.intentId }) + }), +} +``` + +## The `ctx` shape + +Inside a chain handler: + +| Path | Set by | Mutability | +| -------------------------------------- | ------------------------------ | ---------- | +| `ctx.supabase`, `ctx.userClaims`, etc. | `withSupabase` (when wrapping) | read-only | +| `ctx.state.` | gates via `chain` | read-only | +| `ctx.locals` | anyone (handler, helpers) | mutable | +| `ctx.foo` (top-level, anything else) | — | type error | + +Three rules: + +- **`ctx.state` is gate-owned.** Each gate owns exactly one slot, named by its namespace. Slots are read-only from the handler's view. +- **`ctx.locals` is everyone-else's.** Per-request scratch space. `Record`. Mutate freely. +- **The top level is closed.** `withSupabase` populates the established host keys; everything else is a type error. Use `state` or `locals`. + +## Authoring a gate (`defineGate`) + +A gate has a _namespace_ (its slot under `ctx.state`), a _contribution shape_ (the typed value placed there), and a _run_ function. + +```ts +import { defineGate } from '@supabase/server/core/gates' + +export interface FlagConfig { + name: string +} + +export interface FlagState { + enabled: boolean +} + +export const withFlag = defineGate< + 'flag', // Namespace + FlagConfig, // Config (whatever the factory takes) + {}, // In: prerequisites from upstream ctx (none here) + FlagState // Contribution: shape under ctx.state.flag +>({ + namespace: 'flag', + run: (config) => async (req) => { + const enabled = req.headers.get(`x-flag-${config.name}`) === '1' + return { kind: 'pass', contribution: { enabled } } + }, +}) +``` + +Used as: + +```ts +chain(withFlag({ name: 'beta' }))(async (req, ctx) => { + if (!ctx.state.flag.enabled) + return new Response('not enabled', { status: 404 }) + return Response.json({ ok: true }) +}) +``` + +### `run`'s shape + +```ts +run: (config: Config) => (req: Request, ctx: In) => + Promise> + +type GateResult = + | { kind: 'pass'; contribution: C } + | { kind: 'reject'; response: Response } +``` + +The outer `(config) =>` is invoked once when the consumer constructs the gate (`withFlag({ name: 'beta' })`). Initialize per-instance state (stores, clients, computed config) here. The inner `(req, ctx) =>` is invoked per-request. + +Return `{ kind: 'pass', contribution }` to admit the request and contribute typed state. Return `{ kind: 'reject', response }` to short-circuit the chain with a canonical 4xx response. + +### Declaring upstream prerequisites + +A gate can require structural shape from the upstream ctx via `In`. For example, a gate that reads the authenticated user: + +```ts +import type { UserClaims } from '@supabase/server' + +export const withSubscription = defineGate< + 'subscription', + { lookup: (userId: string) => Promise }, + { userClaims: UserClaims }, // In: requires userClaims upstream + { plan: Plan } +>({ + namespace: 'subscription', + run: (config) => async (req, ctx) => { + if (!ctx.userClaims) { + return { + kind: 'reject', + response: Response.json({ error: 'unauthenticated' }, { status: 401 }), + } + } + const plan = await config.lookup(ctx.userClaims.id) + if (!plan) { + return { + kind: 'reject', + response: Response.json({ error: 'no_plan' }, { status: 402 }), + } + } + return { kind: 'pass', contribution: { plan } } + }, +}) +``` + +A consumer using this gate must supply `userClaims` upstream — typically by wrapping the chain with `withSupabase`. Standalone use without `userClaims` won't compile. + +### Reserved namespaces + +These names cannot be used as gate namespaces (would shadow the host or chain ctx structure): + +- `state`, `locals` +- `supabase`, `supabaseAdmin`, `userClaims`, `claims`, `authType`, `authKeyName` + +`defineGate({ namespace: 'state', ... })` fails to typecheck. + +### Collisions + +Two gates declaring the same namespace fail to compile when composed by `chain`. The accumulated state type collapses to `never`, surfacing as a type error on the handler's `ctx.state` access. + +```ts +chain( + withPayment({ ... }), + withPayment({ ... }), // duplicate namespace 'payment' +)(handler) // type error +``` + +Pick a different namespace for each gate. If you have two implementations of the same concept (e.g. two payment providers), name them by provider (`stripePayment`, `coinbasePayment`). + +### Reusing per-request state + +If a gate needs to share data with the handler beyond its primary contribution (e.g. a debugging blob, a transient cache key), write to `ctx.locals` from inside `run`: + +```ts +run: (config) => async (req, ctx) => { + ctx.locals.requestId ??= crypto.randomUUID() + // ... +} +``` + +`ctx.locals` is mutable and shared across all gates and the handler for that request. Don't put values that need _typed_ access there — those belong in your gate's contribution. + +## API + +| Export | Description | +| ----------------------------------- | ----------------------------------------------------------------------------------------- | +| `defineGate(spec)` | Author helper: declare a gate. Returns a `(config) => Gate` factory. | +| `chain(...gates)(handler)` | Consumer helper: compose gates and produce a `(req, baseCtx?) => Response` function. | +| `Gate` | The structural type of a gate. | +| `GateResult` | Discriminated union of `{ kind: 'pass', contribution }` / `{ kind: 'reject', response }`. | +| `ChainCtx` | The merged ctx type seen by a chain handler. | +| `AccumulatedState` | Type-level merge of all gates' contributions; resolves to `never` on collision. | +| `MergeStrict` | Strict object merge (`never` on key overlap). | +| `ValidNamespace` | Type-level guard: `never` for reserved or broad-`string` namespaces. | +| `ReservedNamespace` | Union of names that can't be gate namespaces. | diff --git a/src/core/gates/chain.test.ts b/src/core/gates/chain.test.ts new file mode 100644 index 0000000..5f63ab4 --- /dev/null +++ b/src/core/gates/chain.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it, vi } from 'vitest' + +import { withSupabase } from '../../with-supabase.js' +import { chain } from './chain.js' +import { defineGate } from './define-gate.js' +import type { Gate } from './types.js' + +const baseEnv = { + url: 'https://test.supabase.co', + publishableKeys: { default: 'sb_publishable_xyz' }, + secretKeys: { default: 'sb_secret_xyz' }, + jwks: null, +} + +const passingGate = (namespace: N, contribution: C) => + defineGate, C>({ + namespace: namespace as never, + run: () => async () => ({ kind: 'pass', contribution }), + })(undefined) + +const rejectingGate = (namespace: N, status = 401) => + defineGate, Record>({ + namespace: namespace as never, + run: () => async () => ({ + kind: 'reject', + response: new Response(`rejected by ${namespace}`, { status }), + }), + })(undefined) + +describe('chain', () => { + it('runs gates in order and passes contributions to ctx.state', async () => { + const gateA = passingGate('alpha', { a: 1 }) + const gateB = passingGate('beta', { b: 2 }) + + const handler = vi.fn( + async ( + _req: Request, + ctx: { state: { alpha: { a: number }; beta: { b: number } } }, + ) => { + expect(ctx.state.alpha).toEqual({ a: 1 }) + expect(ctx.state.beta).toEqual({ b: 2 }) + return Response.json({ ok: true }) + }, + ) + + const fetchHandler = chain(gateA, gateB)(handler) + const res = await fetchHandler(new Request('http://localhost/')) + + expect(res.status).toBe(200) + expect(handler).toHaveBeenCalledOnce() + }) + + it('short-circuits on the first rejecting gate without running downstream', async () => { + const downstreamRun = vi.fn() + const downstream: Gate< + Record, + 'downstream', + Record + > = { + namespace: 'downstream', + run: async (...args) => { + downstreamRun(...args) + return { kind: 'pass', contribution: {} } + }, + } + + const handler = vi.fn(async () => Response.json({ ok: true })) + + const fetchHandler = chain( + rejectingGate('blocker', 402), + downstream, + )(handler) + const res = await fetchHandler(new Request('http://localhost/')) + + expect(res.status).toBe(402) + expect(await res.text()).toBe('rejected by blocker') + expect(downstreamRun).not.toHaveBeenCalled() + expect(handler).not.toHaveBeenCalled() + }) + + it('exposes a mutable ctx.locals to gates and the handler', async () => { + const stamping: Gate< + { locals: Record }, + 'stamping', + { stamped: boolean } + > = { + namespace: 'stamping', + run: async (_req, ctx) => { + ctx.locals.stampedAt = 123 + return { kind: 'pass', contribution: { stamped: true } } + }, + } + + const handler = vi.fn( + async ( + _req: Request, + ctx: { + state: { stamping: { stamped: boolean } } + locals: Record + }, + ) => { + expect(ctx.locals.stampedAt).toBe(123) + ctx.locals.handlerWrote = 'yep' + return Response.json({ locals: ctx.locals }) + }, + ) + + const fetchHandler = chain(stamping)(handler) + const res = await fetchHandler(new Request('http://localhost/')) + + expect(res.status).toBe(200) + const body = (await res.json()) as { locals: Record } + expect(body.locals).toEqual({ stampedAt: 123, handlerWrote: 'yep' }) + }) + + it('isolates ctx.locals between requests', async () => { + const fetchHandler = chain(passingGate('marker', { v: 1 }))(async ( + _req, + ctx, + ) => { + const seen = ctx.locals.seen ?? false + ctx.locals.seen = true + return Response.json({ seen }) + }) + + const r1 = await fetchHandler(new Request('http://localhost/')) + const r2 = await fetchHandler(new Request('http://localhost/')) + + expect(await r1.json()).toEqual({ seen: false }) + expect(await r2.json()).toEqual({ seen: false }) + }) + + it('merges baseCtx into the handler ctx when supplied', async () => { + const handler = vi.fn(async (_req: Request, ctx) => { + expect(ctx.tenantId).toBe('acme') + expect(ctx.state.ping).toEqual({ pong: true }) + return Response.json({ ok: true }) + }) + + const composed = chain(passingGate('ping', { pong: true as const }))<{ + tenantId: string + }>(handler) + const res = await composed(new Request('http://localhost/'), { + tenantId: 'acme', + }) + + expect(res.status).toBe(200) + }) + + it('composes inside withSupabase, threading the SupabaseContext as baseCtx', async () => { + const inner = vi.fn(async (_req: Request, ctx) => { + // ctx has supabase fields (from withSupabase) AND state/locals (from chain) + expect(ctx.authType).toBe('always') + expect(ctx.state.ping).toEqual({ pong: true }) + expect(ctx.locals).toEqual({}) + return Response.json({ ok: true }) + }) + + const fetchHandler = withSupabase( + { allow: 'always', env: baseEnv, cors: false }, + chain(passingGate('ping', { pong: true as const }))(inner), + ) + + const res = await fetchHandler(new Request('http://localhost/')) + expect(res.status).toBe(200) + expect(inner).toHaveBeenCalledOnce() + }) + + it('runs with no gates and an empty state', async () => { + const handler = vi.fn( + async ( + _req: Request, + ctx: { + state: Record + locals: Record + }, + ) => { + expect(ctx.state).toEqual({}) + expect(ctx.locals).toEqual({}) + return Response.json({ ok: true }) + }, + ) + + const fetchHandler = chain()(handler) + await fetchHandler(new Request('http://localhost/')) + + expect(handler).toHaveBeenCalledOnce() + }) +}) + +describe('defineGate', () => { + it('produces a gate factory that closes over its config', async () => { + const withGreeting = defineGate< + 'greeting', + { who: string }, + Record, + { hello: string } + >({ + namespace: 'greeting', + run: (config) => async () => ({ + kind: 'pass', + contribution: { hello: config.who }, + }), + }) + + const fetchHandler = chain(withGreeting({ who: 'world' }))( + async (_req, ctx) => Response.json({ msg: ctx.state.greeting.hello }), + ) + + const res = await fetchHandler(new Request('http://localhost/')) + expect(await res.json()).toEqual({ msg: 'world' }) + }) + + it('rejects reserved namespace names at the type level', () => { + // Type-level assertion: passing a reserved literal to defineGate fails + // ValidNamespace's check, so the namespace property's expected type is + // `never` and the literal can't be assigned. The @ts-expect-error + // directives below document and lock in that intent. + defineGate({ + // @ts-expect-error — 'state' is reserved + namespace: 'state', + run: () => async () => ({ kind: 'pass', contribution: {} }), + }) + + defineGate({ + // @ts-expect-error — 'supabase' is a host key; reserved + namespace: 'supabase', + run: () => async () => ({ kind: 'pass', contribution: {} }), + }) + }) +}) diff --git a/src/core/gates/chain.ts b/src/core/gates/chain.ts new file mode 100644 index 0000000..fa2567c --- /dev/null +++ b/src/core/gates/chain.ts @@ -0,0 +1,73 @@ +import type { AccumulatedState, ChainCtx, Gate } from './types.js' + +/** + * Composes a tuple of gates into a function that runs them in order against + * an inbound `Request`, then invokes the supplied handler with a context + * containing each gate's contribution under `ctx.state[namespace]`. + * + * The returned function accepts an optional `baseCtx`. When invoked + * standalone (e.g. as a fetch handler), `baseCtx` defaults to `{}`. When + * invoked from inside `withSupabase`, the `SupabaseContext` is the baseCtx + * and the chain handler sees `SupabaseContext & { state, locals }`. + * + * Type-level guarantees: + * - **Collision detection**: two gates with the same namespace cause the + * accumulated state type to collapse to `never`, surfacing as a type error + * on the handler's `ctx.state` access. + * - **Reserved namespaces**: gates using a reserved name (`state`, `locals`, + * or any `withSupabase` host key) fail at `defineGate` time. + * + * Runtime behaviour: + * - Gates run sequentially; the first to return `{ kind: 'reject', response }` + * short-circuits the chain. + * - Each pass-result's `contribution` is written to `ctx.state[gate.namespace]`. + * - `ctx.locals` is initialized to an empty object that gates and the handler + * may mutate freely. + * + * @example + * ```ts + * import { withSupabase } from '@supabase/server' + * import { chain } from '@supabase/server/core/gates' + * import { withPayment } from '@supabase/server/gates' + * + * export default { + * fetch: withSupabase( + * { allow: 'user' }, + * chain(withPayment({ stripe, amountCents: 5 }))(async (req, ctx) => { + * // ctx.supabase, ctx.userClaims — from withSupabase + * // ctx.state.payment.intentId — from withPayment + * // ctx.locals.foo = 'bar' — free scratch + * return Response.json({ paid: ctx.state.payment.intentId }) + * }), + * ), + * } + * ``` + */ +export function chain[]>( + ...gates: G +) { + return >( + handler: ( + req: Request, + ctx: ChainCtx>, + ) => Promise, + ): ((req: Request, baseCtx?: Base) => Promise) => { + return async (req, baseCtx) => { + const state: Record = {} + const locals: Record = {} + const ctx = { + ...((baseCtx ?? {}) as Base), + state, + locals, + } as ChainCtx> + + for (const gate of gates) { + const result = await gate.run(req, ctx as never) + if (result.kind === 'reject') return result.response + state[gate.namespace] = result.contribution + } + + return handler(req, ctx) + } + } +} diff --git a/src/core/gates/define-gate.ts b/src/core/gates/define-gate.ts new file mode 100644 index 0000000..35887f6 --- /dev/null +++ b/src/core/gates/define-gate.ts @@ -0,0 +1,52 @@ +import type { Gate, GateResult, ValidNamespace } from './types.js' + +/** + * Defines a gate. Returns a config-taking factory function that produces a + * {@link Gate} value, suitable for use with {@link chain}. + * + * A gate is a small unit that runs against an inbound `Request` and the + * upstream context. It either short-circuits with a `Response` (rejection) or + * contributes a typed value at `ctx.state[namespace]` (pass). + * + * @typeParam Namespace - Literal string used as the slot under `ctx.state`. + * Cannot be a reserved name (see {@link ReservedNamespace}). + * @typeParam Config - The configuration object the factory accepts. + * @typeParam In - Structural shape the gate requires from the upstream ctx. + * Defaults to `{}` (no prerequisites). + * @typeParam Contribution - Shape of the value placed at `ctx.state[Namespace]`. + * + * @example + * ```ts + * import { defineGate } from '@supabase/server/core/gates' + * + * export const withFlag = defineGate({ + * namespace: 'flag', + * run: (config: { name: string }) => async (req) => { + * const enabled = req.headers.get(`x-flag-${config.name}`) === '1' + * return { kind: 'pass', contribution: { name: config.name, enabled } } + * }, + * }) + * + * // Consumer: + * chain(withFlag({ name: 'beta' }))(async (req, ctx) => { + * if (!ctx.state.flag.enabled) return new Response('not enabled', { status: 404 }) + * return Response.json({ ok: true }) + * }) + * ``` + */ +export function defineGate< + const Namespace extends string, + Config, + In extends object = Record, + Contribution = unknown, +>(spec: { + namespace: ValidNamespace + run: ( + config: Config, + ) => (req: Request, ctx: In) => Promise> +}): (config: Config) => Gate { + return (config) => ({ + namespace: spec.namespace as Namespace, + run: spec.run(config), + }) +} diff --git a/src/core/gates/index.ts b/src/core/gates/index.ts new file mode 100644 index 0000000..21d88db --- /dev/null +++ b/src/core/gates/index.ts @@ -0,0 +1,21 @@ +/** + * Gate composition primitives. + * + * - {@link defineGate} — author-facing helper for declaring a gate. + * - {@link chain} — consumer-facing composer that turns a tuple of gates + * into a fetch-handler-shaped function. + * + * @packageDocumentation + */ + +export { chain } from './chain.js' +export { defineGate } from './define-gate.js' +export type { + AccumulatedState, + ChainCtx, + Gate, + GateResult, + MergeStrict, + ReservedNamespace, + ValidNamespace, +} from './types.js' diff --git a/src/core/gates/types.ts b/src/core/gates/types.ts new file mode 100644 index 0000000..fab3967 --- /dev/null +++ b/src/core/gates/types.ts @@ -0,0 +1,95 @@ +/** + * Type primitives for the gate composition system. + * + * @packageDocumentation + */ + +/** + * The result a gate's `run` function returns: either a successful contribution + * to be merged into `ctx.state[namespace]`, or a `Response` that short-circuits + * the chain. + */ +export type GateResult = + | { kind: 'pass'; contribution: Contribution } + | { kind: 'reject'; response: Response } + +/** + * A gate is a value with a namespace and a `run` function. The chain composer + * runs gates in order, threading their contributions into `ctx.state[namespace]`. + * + * Authors create gates via {@link defineGate}; consumers compose them via + * {@link chain}. + * + * @typeParam In - Structural shape the gate requires from the upstream ctx + * (e.g. `{ userClaims: UserClaims | null }` for a gate that reads auth). + * Use `{}` if the gate has no prerequisites. + * @typeParam Namespace - Literal string key under `ctx.state` where the + * contribution lives. + * @typeParam Contribution - Shape of the value placed at `ctx.state[Namespace]`. + */ +export interface Gate { + readonly namespace: Namespace + readonly run: (req: Request, ctx: In) => Promise> +} + +/** + * Names that gates cannot use as their namespace, because they're either + * reserved for the chain ctx structure (`state`, `locals`) or claimed by the + * `withSupabase` host context. + */ +export type ReservedNamespace = + | 'state' + | 'locals' + | 'supabase' + | 'supabaseAdmin' + | 'userClaims' + | 'claims' + | 'authType' + | 'authKeyName' + +/** + * Compile-time guard: resolves to the literal namespace if it's allowed, + * `never` otherwise. Use as the type of `defineGate`'s `namespace` field + * to surface invalid choices as type errors. + */ +export type ValidNamespace = string extends N + ? never + : N extends ReservedNamespace + ? never + : N + +/** + * Strict object merge that collapses to `never` when the operands share any + * keys. Used by `AccumulatedState` to surface namespace collisions as type + * errors at chain composition time. + */ +export type MergeStrict = keyof A & keyof B extends never ? A & B : never + +/** + * Accumulates the state contributions of a tuple of gates into a single + * object type, with `MergeStrict` collision detection: if two gates declare + * the same namespace, the result is `never` and the chain fails to compile. + */ +export type AccumulatedState< + G extends readonly Gate[], +> = G extends readonly [ + infer First, + ...infer Rest extends readonly Gate[], +] + ? // eslint-disable-next-line @typescript-eslint/no-unused-vars + First extends Gate + ? MergeStrict<{ [K in N]: C }, AccumulatedState> + : never + : Record + +/** + * The shape of `ctx` seen by a chain handler: + * - whatever the upstream `Base` provided (e.g. `SupabaseContext` when wrapped + * by `withSupabase`, or `{}` standalone), + * - plus a `state` object whose slots are the gates' contributions (read-only), + * - plus a `locals` mutable scratch object. + */ +export type ChainCtx = Base & { + readonly state: Readonly + locals: Record +} diff --git a/tsdown.config.ts b/tsdown.config.ts index 625dd8f..570c5d9 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -4,6 +4,7 @@ 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', ], From 44365a9de99878a5b34215be647e0e3b929e7f23 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Sat, 2 May 2026 01:44:12 -0300 Subject: [PATCH 02/22] feat(gates): add x402 paywall gate --- README.md | 31 ++++ jsr.json | 3 +- package.json | 5 + src/gates/x402/README.md | 144 +++++++++++++++++ src/gates/x402/index.ts | 20 +++ src/gates/x402/with-payment.test.ts | 185 +++++++++++++++++++++ src/gates/x402/with-payment.ts | 239 ++++++++++++++++++++++++++++ tsdown.config.ts | 1 + 8 files changed, 627 insertions(+), 1 deletion(-) create mode 100644 src/gates/x402/README.md create mode 100644 src/gates/x402/index.ts create mode 100644 src/gates/x402/with-payment.test.ts create mode 100644 src/gates/x402/with-payment.ts diff --git a/README.md b/README.md index 385dbaa..dc45467 100644 --- a/README.md +++ b/README.md @@ -302,6 +302,33 @@ export default withSupabase({ allow: 'user' }) The adapter does not handle CORS — use H3's CORS utilities for that. +## Gates + +Compose preconditions around a handler. A **gate** runs against the inbound `Request`, either short-circuits with a `Response` or contributes typed data to `ctx.state[namespace]`. Stack them with `chain` to build per-route policy (paywalls, bot checks, rate limits, signed webhooks) without nesting wrappers by hand. + +```ts +import { withSupabase } from '@supabase/server' +import { chain } from '@supabase/server/core/gates' +import { withPayment } from '@supabase/server/gates/x402' + +export default { + fetch: withSupabase( + { allow: 'user' }, + chain(withPayment({ stripe, amountCents: 5 }))(async (req, ctx) => { + // ctx.supabase, ctx.userClaims — from withSupabase + // ctx.state.payment.intentId — from withPayment + // ctx.locals.foo = 'bar' — free per-request scratch + return Response.json({ paid: ctx.state.payment.intentId }) + }), + ), +} +``` + +`withSupabase` is the host wrapper, not a gate — it establishes `SupabaseContext` and hands it to whatever it wraps. Gates compose inside it (or stand alone). + +- [`@supabase/server/core/gates`](src/core/gates/README.md) — authoring primitives (`defineGate`, `chain`, `ctx` rules, prerequisite enforcement). +- [`@supabase/server/gates/x402`](src/gates/x402/README.md) — `withPayment`, the Stripe-facilitated x402 paywall gate. + ## Primitives For when you need more control than `withSupabase` provides — multiple routes with different auth, custom response headers, or building your own wrapper. @@ -440,6 +467,8 @@ For other environments, pass overrides via the `env` config option or `resolveEn | `@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` | `chain`, `defineGate` (gate composition primitives) | +| `@supabase/server/gates/x402` | `withPayment` (Stripe-facilitated x402 paywall gate) | ## Documentation @@ -454,6 +483,8 @@ For other environments, pass overrides via the `env` config option or `resolveEn | How do I get typed database queries? | [`docs/typescript-generics.md`](docs/typescript-generics.md) | | How do I use this in Next.js, Nuxt, SvelteKit, or 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 compose preconditions (gates) around a handler? | [`src/core/gates/README.md`](src/core/gates/README.md) | +| How do I charge per call with x402 + Stripe? | [`src/gates/x402/README.md`](src/gates/x402/README.md) | ## Development diff --git a/jsr.json b/jsr.json index 5ed82d7..5134f25 100644 --- a/jsr.json +++ b/jsr.json @@ -5,7 +5,8 @@ ".": "./src/index.ts", "./core": "./src/core/index.ts", "./core/gates": "./src/core/gates/index.ts", - "./adapters/hono": "./src/adapters/hono/index.ts" + "./adapters/hono": "./src/adapters/hono/index.ts", + "./gates/x402": "./src/gates/x402/index.ts" }, "publish": { "include": ["src/**/*.ts", "README.md", "LICENSE"], diff --git a/package.json b/package.json index 9257664..1e29036 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,11 @@ "import": "./dist/adapters/h3/index.mjs", "require": "./dist/adapters/h3/index.cjs" }, + "./gates/x402": { + "types": "./dist/gates/x402/index.d.mts", + "import": "./dist/gates/x402/index.mjs", + "require": "./dist/gates/x402/index.cjs" + }, "./package.json": "./package.json" }, "main": "./dist/index.cjs", diff --git a/src/gates/x402/README.md b/src/gates/x402/README.md new file mode 100644 index 0000000..20677d3 --- /dev/null +++ b/src/gates/x402/README.md @@ -0,0 +1,144 @@ +# withPayment + +> **Experimental:** Stripe's machine-payment crypto deposit mode is a preview API. Both Stripe's surface and this gate may change. + +Stripe-facilitated [x402](https://www.x402.org) paywall gate. Charge per-call in USDC for any fetch handler — Stripe issues the deposit address, settles on-chain, and the chain admits the request once the `PaymentIntent` has succeeded. + +Lives under `@supabase/server/gates/x402`. Compose with [`chain`](../../core/gates/README.md) from `@supabase/server/core/gates`. + +```ts +import Stripe from 'stripe' +import { chain } from '@supabase/server/core/gates' +import { withPayment } from '@supabase/server/gates/x402' + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { + apiVersion: '2026-03-04.preview' as never, +}) + +export default { + fetch: chain(withPayment({ stripe, amountCents: 1 }))(async (req, ctx) => { + return Response.json({ ok: true, paid: ctx.state.payment.intentId }) + }), +} +``` + +## How it works + +1. **First request — no `X-PAYMENT` header.** `withPayment` creates a Stripe `PaymentIntent` in crypto-deposit mode, records the deposit address → PI mapping in the store, and short-circuits the chain with a `402 Payment Required` carrying an [x402 v1](https://www.x402.org) `accepts` body that advertises the address. +2. **Client pays.** An x402-aware client (or agent) sends USDC to the advertised address on the requested network. +3. **Retry with `X-PAYMENT` header.** The header is a base64-encoded JSON envelope of the form `{ payload: { authorization: { to: } } }`. `withPayment` decodes it, looks up the matching `PaymentIntent`, and: + - if `status === "succeeded"`, contributes `{ intentId }` to `ctx.state.payment` and lets the chain proceed, + - if not yet settled, replies `402` with `{ error: "payment_not_settled", status }`, + - if the address is unknown or the header is malformed, falls back to issuing a fresh `402`. + +## Config + +```ts +withPayment({ + stripe, // a Stripe client (or any structurally compatible object) + amountCents: 1, // price per call in USD cents; Stripe converts to USDC + network: 'base', // 'base' | 'tempo' | 'solana' — default 'base' + store, // deposit-address → PI-id lookup (default: in-memory Map) +}) +``` + +`StripeLike` is structurally typed — this package does not depend on the `stripe` SDK at runtime or types-level. Pass any object exposing `paymentIntents.create` and `paymentIntents.retrieve`. + +## Production deployments need a real store + +The default store is an in-memory `Map`. That is fine for tests and a single long-lived process, but it loses the deposit-address → PI mapping across restarts and cannot be shared between instances — meaning a paid client may hit a different worker on retry and be asked to pay again. + +Provide a Postgres-, Redis-, or KV-backed `PaymentStore`: + +```ts +import { chain } from '@supabase/server/core/gates' +import { withPayment, type PaymentStore } from '@supabase/server/gates/x402' + +const store: PaymentStore = { + async set(depositAddress, paymentIntentId) { + await kv.set(`x402:${depositAddress}`, paymentIntentId, { ex: 3600 }) + }, + async get(depositAddress) { + return kv.get(`x402:${depositAddress}`) + }, +} + +export default { + fetch: chain(withPayment({ stripe, amountCents: 1, store }))(handler), +} +``` + +## Composing with `withSupabase` + +`withPayment` is a gate; `withSupabase` is the fetch-handler wrapper that establishes Supabase context. Compose by running the chain inside `withSupabase`: + +```ts +import { withSupabase } from '@supabase/server' +import { chain } from '@supabase/server/core/gates' +import { withPayment } from '@supabase/server/gates/x402' + +export default { + fetch: withSupabase( + { allow: 'user' }, + chain(withPayment({ stripe, amountCents: 5 }))(async (req, ctx) => { + // ctx.supabase is the user-scoped client (from withSupabase) + // ctx.state.payment.intentId is the settled PaymentIntent id + const { data } = await ctx.supabase.from('premium_reports').select() + return Response.json({ data, paid: ctx.state.payment.intentId }) + }), + ), +} +``` + +For fully anonymous machine-to-machine paywalls, drop `withSupabase`: + +```ts +export default { + fetch: chain(withPayment({ stripe, amountCents: 1 }))(async (req, ctx) => { + return Response.json({ paid: ctx.state.payment.intentId }) + }), +} +``` + +## Migrating from `withPayment(config, handler)` + +Earlier versions exposed `withPayment` as a fetch-handler wrapper (`withPayment(config, handler)`). It is now a gate. Wrap your handler with `chain`: + +```diff +- export default { +- fetch: withPayment( +- { stripe, amountCents: 1 }, +- async (_req, { paymentIntentId }) => { +- return Response.json({ paid: paymentIntentId }) +- }, +- ), +- } ++ import { chain } from '@supabase/server/core/gates' ++ ++ export default { ++ fetch: chain(withPayment({ stripe, amountCents: 1 }))(async (req, ctx) => { ++ return Response.json({ paid: ctx.state.payment.intentId }) ++ }), ++ } +``` + +`PaymentReceipt` is replaced by `PaymentState` (same shape: `{ intentId: string }`, accessible at `ctx.state.payment`). + +## API + +| Export | Description | +| --------------------------- | ---------------------------------------------------------------------------------------------- | +| `withPayment(config)` | Returns a gate that contributes `{ intentId }` to `ctx.state.payment` once the PI has settled. | +| `PaymentState` | Shape contributed at `ctx.state.payment`: `{ intentId: string }` | +| `PaymentStore` | Interface for the deposit-address → PaymentIntent-id mapping | +| `WithPaymentConfig` | Config object accepted by `withPayment` | +| `Network` | `'base' \| 'tempo' \| 'solana'` | +| `StripeLike` | Minimal structural type for the Stripe client | +| `PaymentIntent` | Subset of Stripe's `PaymentIntent` used by this wrapper | +| `PaymentIntentCreateParams` | Params shape passed to `stripe.paymentIntents.create` | + +## See also + +- [Gate composition primitives](../../core/gates/README.md) — `chain`, `defineGate`, ctx shape +- [x402 specification](https://www.x402.org) +- [Stripe machine payments docs](https://docs.stripe.com/payments/machine/x402) diff --git a/src/gates/x402/index.ts b/src/gates/x402/index.ts new file mode 100644 index 0000000..7fa1efd --- /dev/null +++ b/src/gates/x402/index.ts @@ -0,0 +1,20 @@ +/** + * Stripe-facilitated x402 paywall gate. + * + * Compose with {@link chain} from `@supabase/server/core/gates`. Optionally + * wrap with {@link withSupabase} to gate authenticated routes; use stand-alone + * for fully anonymous machine-to-machine paywalls. + * + * @packageDocumentation + */ + +export { withPayment } from './with-payment.js' +export type { + Network, + PaymentIntent, + PaymentIntentCreateParams, + PaymentState, + PaymentStore, + StripeLike, + WithPaymentConfig, +} from './with-payment.js' diff --git a/src/gates/x402/with-payment.test.ts b/src/gates/x402/with-payment.test.ts new file mode 100644 index 0000000..f8db389 --- /dev/null +++ b/src/gates/x402/with-payment.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it, vi } from 'vitest' + +import { chain } from '../../core/gates/index.js' +import { withPayment } from './with-payment.js' +import type { PaymentIntent, PaymentState, StripeLike } from './with-payment.js' + +type Ctx = { + state: { payment: PaymentState } + locals: Record +} + +const innerOk = async () => Response.json({ ok: true }) + +const DEPOSIT_ADDRESS = '0xDEPOSITADDRESS' + +const makePI = (status: string, id = 'pi_test_123'): PaymentIntent => ({ + id, + status, + next_action: { + crypto_display_details: { + deposit_addresses: { base: { address: DEPOSIT_ADDRESS } }, + }, + }, +}) + +const makeStripeMock = (initialStatus = 'requires_action') => { + const create = vi.fn().mockResolvedValue(makePI(initialStatus)) + const retrieve = vi.fn().mockResolvedValue(makePI(initialStatus)) + const stripe: StripeLike = { paymentIntents: { create, retrieve } } + return { stripe, create, retrieve } +} + +const encodePayment = (to: string) => + btoa(JSON.stringify({ payload: { authorization: { to } } })) + +describe('withPayment', () => { + it('returns 402 with deposit address when X-PAYMENT is missing', async () => { + const { stripe, create } = makeStripeMock() + const handler = chain(withPayment({ stripe, amountCents: 1 }))(innerOk) + + const res = await handler(new Request('http://localhost/api/foo')) + + expect(res.status).toBe(402) + const body = await res.json() + expect(body).toEqual({ + x402Version: 1, + accepts: [ + { + scheme: 'exact', + network: 'base', + maxAmountRequired: '1', + asset: 'USDC', + payTo: DEPOSIT_ADDRESS, + resource: '/api/foo', + extra: { stripePaymentIntent: 'pi_test_123' }, + }, + ], + }) + expect(create).toHaveBeenCalledOnce() + expect(create.mock.calls[0][0]).toMatchObject({ + amount: 1, + currency: 'usd', + payment_method_types: ['crypto'], + payment_method_options: { + crypto: { mode: 'deposit', deposit_options: { networks: ['base'] } }, + }, + confirm: true, + }) + }) + + it('runs handler when X-PAYMENT references a succeeded PaymentIntent', async () => { + const { stripe, retrieve } = makeStripeMock() + const inner = vi.fn(async (_req: Request, ctx: Ctx) => { + expect(ctx.state.payment).toEqual({ intentId: 'pi_test_123' }) + return Response.json({ ok: true }) + }) + const handler = chain(withPayment({ stripe, amountCents: 1 }))(inner) + + // Seed the store: first request creates the PI and registers its address. + await handler(new Request('http://localhost/api/foo')) + + // Stripe reports the PI as settled on the retry. + retrieve.mockResolvedValueOnce(makePI('succeeded')) + + const res = await handler( + new Request('http://localhost/api/foo', { + headers: { 'x-payment': encodePayment(DEPOSIT_ADDRESS) }, + }), + ) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ ok: true }) + expect(inner).toHaveBeenCalledOnce() + expect(retrieve).toHaveBeenCalledWith('pi_test_123') + }) + + it('returns 402 when the PaymentIntent has not settled yet', async () => { + const { stripe } = makeStripeMock('requires_action') + const inner = vi.fn(innerOk) + const handler = chain(withPayment({ stripe, amountCents: 1 }))(inner) + + await handler(new Request('http://localhost/api/foo')) + + const res = await handler( + new Request('http://localhost/api/foo', { + headers: { 'x-payment': encodePayment(DEPOSIT_ADDRESS) }, + }), + ) + + expect(res.status).toBe(402) + const body = await res.json() + expect(body).toMatchObject({ + x402Version: 1, + error: 'payment_not_settled', + status: 'requires_action', + }) + expect(inner).not.toHaveBeenCalled() + }) + + it('issues a fresh 402 when X-PAYMENT references an unknown deposit address', async () => { + const { stripe, create, retrieve } = makeStripeMock() + const inner = vi.fn(innerOk) + const handler = chain(withPayment({ stripe, amountCents: 1 }))(inner) + + const res = await handler( + new Request('http://localhost/api/foo', { + headers: { 'x-payment': encodePayment('0xUNKNOWN') }, + }), + ) + + expect(res.status).toBe(402) + const body = await res.json() + expect(body.accepts?.[0]?.payTo).toBe(DEPOSIT_ADDRESS) + expect(create).toHaveBeenCalledOnce() + expect(retrieve).not.toHaveBeenCalled() + expect(inner).not.toHaveBeenCalled() + }) + + it('issues a fresh 402 when X-PAYMENT is malformed', async () => { + const { stripe, create } = makeStripeMock() + const handler = chain(withPayment({ stripe, amountCents: 1 }))(innerOk) + + const res = await handler( + new Request('http://localhost/api/foo', { + headers: { 'x-payment': 'not-base64-json' }, + }), + ) + + expect(res.status).toBe(402) + expect(create).toHaveBeenCalledOnce() + }) + + it('honors a custom store and network', async () => { + const { stripe } = makeStripeMock() + stripe.paymentIntents.create = vi.fn().mockResolvedValue({ + id: 'pi_custom', + status: 'requires_action', + next_action: { + crypto_display_details: { + deposit_addresses: { solana: { address: 'SOLADDRESS' } }, + }, + }, + }) + + const writes: Array<[string, string]> = [] + const store = { + set: vi.fn(async (a: string, b: string) => { + writes.push([a, b]) + }), + get: vi.fn(async () => null), + } + + const handler = chain( + withPayment({ stripe, amountCents: 5, network: 'solana', store }), + )(innerOk) + + const res = await handler(new Request('http://localhost/api/foo')) + + expect(res.status).toBe(402) + const body = await res.json() + expect(body.accepts[0].network).toBe('solana') + expect(body.accepts[0].payTo).toBe('SOLADDRESS') + expect(writes).toEqual([['SOLADDRESS', 'pi_custom']]) + }) +}) diff --git a/src/gates/x402/with-payment.ts b/src/gates/x402/with-payment.ts new file mode 100644 index 0000000..a5e7df4 --- /dev/null +++ b/src/gates/x402/with-payment.ts @@ -0,0 +1,239 @@ +/** + * Stripe-facilitated x402 paywall gate. + * + * Issues an HTTP 402 with a Stripe-generated USDC deposit address on + * unauthenticated requests, and lets the chain proceed once the corresponding + * PaymentIntent has settled on-chain. + * + * @see https://docs.stripe.com/payments/machine/x402 + * @see https://www.x402.org + */ + +import { defineGate } from '../../core/gates/index.js' + +/** Networks supported by Stripe's machine-payment crypto deposit mode. */ +export type Network = 'base' | 'tempo' | 'solana' + +/** + * Maps a Stripe-issued deposit address back to the PaymentIntent that owns it. + * + * Implementations must persist across requests in any deployment that runs more + * than one instance (i.e. anything other than a single long-lived process). + * The default in-memory store is suitable only for tests and single-process dev. + */ +export interface PaymentStore { + set(depositAddress: string, paymentIntentId: string): Promise + get(depositAddress: string): Promise +} + +/** + * Subset of the `Stripe` client surface used by `withPayment`. Structurally + * typed so callers pass their own `Stripe` instance without this package + * depending on the `stripe` SDK at the type level. + */ +export interface StripeLike { + paymentIntents: { + create(params: PaymentIntentCreateParams): Promise + retrieve(id: string): Promise + } +} + +export interface PaymentIntent { + id: string + status: string + next_action?: { + crypto_display_details?: { + deposit_addresses?: Partial> + } + } | null +} + +export interface PaymentIntentCreateParams { + amount: number + currency: string + payment_method_types: ['crypto'] + payment_method_data: { type: 'crypto' } + payment_method_options: { + crypto: { + mode: 'deposit' + deposit_options: { networks: Network[] } + } + } + confirm: true +} + +export interface WithPaymentConfig { + /** A `Stripe` instance configured with a secret key and the x402 preview API version. */ + stripe: StripeLike + + /** Price per call, denominated in USD cents. Stripe converts to USDC at settlement. */ + amountCents: number + + /** @defaultValue `"base"` */ + network?: Network + + /** + * Lookup table mapping deposit address → PaymentIntent id. Defaults to an + * in-memory `Map`. Production deployments should pass a Postgres-, Redis-, + * or KV-backed implementation so the mapping survives across instances. + */ + store?: PaymentStore +} + +/** + * Shape contributed to `ctx.state.payment` once the chain has admitted a + * paid request. + */ +export interface PaymentState { + /** The id of the settled Stripe `PaymentIntent` that paid for this call. */ + intentId: string +} + +/** + * x402 paywall gate. Compose with {@link chain} (and optionally + * {@link withSupabase}) to gate a handler behind a Stripe-settled USDC payment. + * + * - Without `X-PAYMENT`, rejects with a 402 advertising a freshly-created + * Stripe `PaymentIntent`'s deposit address. + * - With `X-PAYMENT`, decodes the base64 payload, looks up the matching + * `PaymentIntent` via `store`, and admits the request iff it has succeeded + * — placing `{ intentId }` at `ctx.state.payment`. + * + * @example + * ```ts + * import Stripe from 'stripe' + * import { chain } from '@supabase/server/core/gates' + * import { withPayment } from '@supabase/server/gates/x402' + * + * const stripe = new Stripe(env.STRIPE_SECRET_KEY, { + * apiVersion: '2026-03-04.preview' as never, + * }) + * + * export default { + * fetch: chain(withPayment({ stripe, amountCents: 1 }))(async (req, ctx) => { + * return Response.json({ ok: true, paid: ctx.state.payment.intentId }) + * }), + * } + * ``` + */ +export const withPayment = defineGate< + 'payment', + WithPaymentConfig, + Record, + PaymentState +>({ + namespace: 'payment', + run: (config) => { + const network = config.network ?? 'base' + const store = config.store ?? createMemoryStore() + + return async (req) => { + const header = req.headers.get('x-payment') + if (header) { + const toAddress = decodePaymentHeader(header) + if (toAddress) { + const paymentIntentId = await store.get(toAddress) + if (paymentIntentId) { + const pi = + await config.stripe.paymentIntents.retrieve(paymentIntentId) + if (pi.status === 'succeeded') { + return { + kind: 'pass', + contribution: { intentId: paymentIntentId }, + } + } + return { + kind: 'reject', + response: Response.json( + { + x402Version: 1, + error: 'payment_not_settled', + status: pi.status, + }, + { status: 402 }, + ), + } + } + } + } + + return { + kind: 'reject', + response: await issuePaymentRequired(req, config, network, store), + } + } + }, +}) + +function decodePaymentHeader(header: string): string | null { + try { + const decoded = JSON.parse(atob(header)) as { + payload?: { authorization?: { to?: unknown } } + } + const to = decoded.payload?.authorization?.to + return typeof to === 'string' ? to : null + } catch { + return null + } +} + +async function issuePaymentRequired( + req: Request, + config: WithPaymentConfig, + network: Network, + store: PaymentStore, +): Promise { + const pi = await config.stripe.paymentIntents.create({ + amount: config.amountCents, + currency: 'usd', + payment_method_types: ['crypto'], + payment_method_data: { type: 'crypto' }, + payment_method_options: { + crypto: { + mode: 'deposit', + deposit_options: { networks: [network] }, + }, + }, + confirm: true, + }) + + const address = + pi.next_action?.crypto_display_details?.deposit_addresses?.[network] + ?.address + if (!address) { + throw new Error( + `Stripe PaymentIntent ${pi.id} did not return a deposit address for ${network}`, + ) + } + await store.set(address, pi.id) + + return Response.json( + { + x402Version: 1, + accepts: [ + { + scheme: 'exact', + network, + maxAmountRequired: String(config.amountCents), + asset: 'USDC', + payTo: address, + resource: new URL(req.url).pathname, + extra: { stripePaymentIntent: pi.id }, + }, + ], + }, + { status: 402 }, + ) +} + +function createMemoryStore(): PaymentStore { + const map = new Map() + return { + async set(addr, id) { + map.set(addr, id) + }, + async get(addr) { + return map.get(addr) ?? null + }, + } +} diff --git a/tsdown.config.ts b/tsdown.config.ts index 570c5d9..7dc30b0 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ 'src/core/gates/index.ts', 'src/adapters/hono/index.ts', 'src/adapters/h3/index.ts', + 'src/gates/x402/index.ts', ], format: ['esm', 'cjs'], dts: true, From d240d9b0595702b010b6fb7858a4fe08f5e00e3d Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Sat, 2 May 2026 02:00:06 -0300 Subject: [PATCH 03/22] feat(gates): add Cloudflare Turnstile gate --- CHANGELOG.md | 37 ++-- README.md | 45 ++--- jsr.json | 1 + package.json | 5 + src/gates/cloudflare/README.md | 91 ++++++++++ src/gates/cloudflare/index.ts | 11 ++ src/gates/cloudflare/with-turnstile.test.ts | 176 ++++++++++++++++++ src/gates/cloudflare/with-turnstile.ts | 186 ++++++++++++++++++++ tsdown.config.ts | 1 + 9 files changed, 511 insertions(+), 42 deletions(-) create mode 100644 src/gates/cloudflare/README.md create mode 100644 src/gates/cloudflare/index.ts create mode 100644 src/gates/cloudflare/with-turnstile.test.ts create mode 100644 src/gates/cloudflare/with-turnstile.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 55734b1..f697a82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,48 +2,43 @@ ## [0.2.0](https://github.com/supabase/server/compare/server-v0.1.4...server-v0.2.0) (2026-04-24) - ### ⚠ BREAKING CHANGES -* when multiple auth modes are allowed, a present-but-invalid JWT is now rejected with InvalidCredentialsError instead of falling through to the next mode. Clients that previously relied on silent fallthrough (e.g., stale token + valid apikey) must now either omit the Authorization header or refresh the token. +- when multiple auth modes are allowed, a present-but-invalid JWT is now rejected with InvalidCredentialsError instead of falling through to the next mode. Clients that previously relied on silent fallthrough (e.g., stale token + valid apikey) must now either omit the Authorization header or refresh the token. ### Features -* add H3 adapter ([#36](https://github.com/supabase/server/issues/36)) ([4310142](https://github.com/supabase/server/commit/43101427e64c01b986376ca5d94c5e008d0adcdf)) - +- add H3 adapter ([#36](https://github.com/supabase/server/issues/36)) ([4310142](https://github.com/supabase/server/commit/43101427e64c01b986376ca5d94c5e008d0adcdf)) ### Bug Fixes -* reject invalid JWTs immediately instead of falling through to next auth mode ([#35](https://github.com/supabase/server/issues/35)) ([0251690](https://github.com/supabase/server/commit/0251690a7f57eb3e2d72074348d8a96f5fb55231)) +- reject invalid JWTs immediately instead of falling through to next auth mode ([#35](https://github.com/supabase/server/issues/35)) ([0251690](https://github.com/supabase/server/commit/0251690a7f57eb3e2d72074348d8a96f5fb55231)) ## [0.1.4](https://github.com/supabase/server/compare/server-v0.1.3...server-v0.1.4) (2026-04-01) - ### Features -* add `supabaseOptions` and refactor client creation to options objects ([#19](https://github.com/supabase/server/issues/19)) ([5a10099](https://github.com/supabase/server/commit/5a100995a1b6254f92768c82c74b1c754c29b3b2)) -* exposing `keyName` to `SupabaseContext` ([#22](https://github.com/supabase/server/issues/22)) ([7f1b1a7](https://github.com/supabase/server/commit/7f1b1a75cc98d08a63275131481e5df825c10afb)) -* implement server-side DX primitives, wrappers, and adapters ([#6](https://github.com/supabase/server/issues/6)) ([d206e5c](https://github.com/supabase/server/commit/d206e5cdb102bf96e0c501b72e7f161cbf9fba0c)) -* passing down Database generic type to `createClient` ([#16](https://github.com/supabase/server/issues/16)) ([4053f6d](https://github.com/supabase/server/commit/4053f6d8db89201a239190a025b08cf19083acb4)) -* set initial release version ([8352bda](https://github.com/supabase/server/commit/8352bda35c5967a6692f0a21744d30793e10709a)) -* standardize error response ([#18](https://github.com/supabase/server/issues/18)) ([a7ddb74](https://github.com/supabase/server/commit/a7ddb74bfbbe4565d461be7df7f01e64854f6c06)) - +- add `supabaseOptions` and refactor client creation to options objects ([#19](https://github.com/supabase/server/issues/19)) ([5a10099](https://github.com/supabase/server/commit/5a100995a1b6254f92768c82c74b1c754c29b3b2)) +- exposing `keyName` to `SupabaseContext` ([#22](https://github.com/supabase/server/issues/22)) ([7f1b1a7](https://github.com/supabase/server/commit/7f1b1a75cc98d08a63275131481e5df825c10afb)) +- implement server-side DX primitives, wrappers, and adapters ([#6](https://github.com/supabase/server/issues/6)) ([d206e5c](https://github.com/supabase/server/commit/d206e5cdb102bf96e0c501b72e7f161cbf9fba0c)) +- passing down Database generic type to `createClient` ([#16](https://github.com/supabase/server/issues/16)) ([4053f6d](https://github.com/supabase/server/commit/4053f6d8db89201a239190a025b08cf19083acb4)) +- set initial release version ([8352bda](https://github.com/supabase/server/commit/8352bda35c5967a6692f0a21744d30793e10709a)) +- standardize error response ([#18](https://github.com/supabase/server/issues/18)) ([a7ddb74](https://github.com/supabase/server/commit/a7ddb74bfbbe4565d461be7df7f01e64854f6c06)) ### Bug Fixes -* key name resolution for client creation ([#9](https://github.com/supabase/server/issues/9)) ([e17bd4e](https://github.com/supabase/server/commit/e17bd4ecb1c46d0dc1468f363c884090d78ae86a)) -* move SKILL.md into skills/ subdirectory to align with agentskills spec ([#24](https://github.com/supabase/server/issues/24)) ([10c8780](https://github.com/supabase/server/commit/10c8780cc21de3bb860d2ec8bf5589f69d4ea447)) -* release action ([#29](https://github.com/supabase/server/issues/29)) ([91580d1](https://github.com/supabase/server/commit/91580d11fd1217a22da1150757114ee980d6157b)) -* remove provenance until repo is public ([2ebbc71](https://github.com/supabase/server/commit/2ebbc71e214c4bbae62c6af203a039801b5e3d4d)) -* removing `core` lib exports from root index ([#17](https://github.com/supabase/server/issues/17)) ([5e53e3c](https://github.com/supabase/server/commit/5e53e3c14fcc7c198f1c0bbec9089b4aedd91473)) -* support bare array format for SUPABASE_JWKS ([#8](https://github.com/supabase/server/issues/8)) ([6bd2e4d](https://github.com/supabase/server/commit/6bd2e4dfc1b60ce4cc8a1b59435b87797e1cb017)) +- key name resolution for client creation ([#9](https://github.com/supabase/server/issues/9)) ([e17bd4e](https://github.com/supabase/server/commit/e17bd4ecb1c46d0dc1468f363c884090d78ae86a)) +- move SKILL.md into skills/ subdirectory to align with agentskills spec ([#24](https://github.com/supabase/server/issues/24)) ([10c8780](https://github.com/supabase/server/commit/10c8780cc21de3bb860d2ec8bf5589f69d4ea447)) +- release action ([#29](https://github.com/supabase/server/issues/29)) ([91580d1](https://github.com/supabase/server/commit/91580d11fd1217a22da1150757114ee980d6157b)) +- remove provenance until repo is public ([2ebbc71](https://github.com/supabase/server/commit/2ebbc71e214c4bbae62c6af203a039801b5e3d4d)) +- removing `core` lib exports from root index ([#17](https://github.com/supabase/server/issues/17)) ([5e53e3c](https://github.com/supabase/server/commit/5e53e3c14fcc7c198f1c0bbec9089b4aedd91473)) +- support bare array format for SUPABASE_JWKS ([#8](https://github.com/supabase/server/issues/8)) ([6bd2e4d](https://github.com/supabase/server/commit/6bd2e4dfc1b60ce4cc8a1b59435b87797e1cb017)) ## [0.1.3](https://github.com/supabase/server/compare/server-v0.1.2...server-v0.1.3) (2026-04-01) - ### Bug Fixes -* move SKILL.md into skills/ subdirectory to align with agentskills spec ([#24](https://github.com/supabase/server/issues/24)) ([10c8780](https://github.com/supabase/server/commit/10c8780cc21de3bb860d2ec8bf5589f69d4ea447)) +- move SKILL.md into skills/ subdirectory to align with agentskills spec ([#24](https://github.com/supabase/server/issues/24)) ([10c8780](https://github.com/supabase/server/commit/10c8780cc21de3bb860d2ec8bf5589f69d4ea447)) ## [0.1.2](https://github.com/supabase/server/compare/server-v0.1.1...server-v0.1.2) (2026-04-01) diff --git a/README.md b/README.md index dc45467..f28a182 100644 --- a/README.md +++ b/README.md @@ -327,6 +327,7 @@ export default { `withSupabase` is the host wrapper, not a gate — it establishes `SupabaseContext` and hands it to whatever it wraps. Gates compose inside it (or stand alone). - [`@supabase/server/core/gates`](src/core/gates/README.md) — authoring primitives (`defineGate`, `chain`, `ctx` rules, prerequisite enforcement). +- [`@supabase/server/gates/cloudflare`](src/gates/cloudflare/README.md) — Cloudflare-issued credentials and headers (`withTurnstile`, more coming). - [`@supabase/server/gates/x402`](src/gates/x402/README.md) — `withPayment`, the Stripe-facilitated x402 paywall gate. ## Primitives @@ -461,30 +462,32 @@ For other environments, pass overrides via the `env` config option or `resolveEn ## 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/core/gates` | `chain`, `defineGate` (gate composition primitives) | -| `@supabase/server/gates/x402` | `withPayment` (Stripe-facilitated x402 paywall gate) | +| 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` | `chain`, `defineGate` (gate composition primitives) | +| `@supabase/server/gates/cloudflare` | `withTurnstile` (Cloudflare bot-check, Access, …) | +| `@supabase/server/gates/x402` | `withPayment` (Stripe-facilitated x402 paywall gate) | ## 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) | -| How do I use this with Hono? | [`docs/hono-adapter.md`](docs/hono-adapter.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 in Next.js, Nuxt, SvelteKit, or 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 compose preconditions (gates) around a handler? | [`src/core/gates/README.md`](src/core/gates/README.md) | -| How do I charge per call with x402 + Stripe? | [`src/gates/x402/README.md`](src/gates/x402/README.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) | +| How do I use this with Hono? | [`docs/hono-adapter.md`](docs/hono-adapter.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 in Next.js, Nuxt, SvelteKit, or 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 compose preconditions (gates) around a handler? | [`src/core/gates/README.md`](src/core/gates/README.md) | +| How do I gate a route behind a Cloudflare check? | [`src/gates/cloudflare/README.md`](src/gates/cloudflare/README.md) | +| How do I charge per call with x402 + Stripe? | [`src/gates/x402/README.md`](src/gates/x402/README.md) | ## Development diff --git a/jsr.json b/jsr.json index 5134f25..577ec71 100644 --- a/jsr.json +++ b/jsr.json @@ -6,6 +6,7 @@ "./core": "./src/core/index.ts", "./core/gates": "./src/core/gates/index.ts", "./adapters/hono": "./src/adapters/hono/index.ts", + "./gates/cloudflare": "./src/gates/cloudflare/index.ts", "./gates/x402": "./src/gates/x402/index.ts" }, "publish": { diff --git a/package.json b/package.json index 1e29036..75353c9 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,11 @@ "import": "./dist/adapters/h3/index.mjs", "require": "./dist/adapters/h3/index.cjs" }, + "./gates/cloudflare": { + "types": "./dist/gates/cloudflare/index.d.mts", + "import": "./dist/gates/cloudflare/index.mjs", + "require": "./dist/gates/cloudflare/index.cjs" + }, "./gates/x402": { "types": "./dist/gates/x402/index.d.mts", "import": "./dist/gates/x402/index.mjs", diff --git a/src/gates/cloudflare/README.md b/src/gates/cloudflare/README.md new file mode 100644 index 0000000..7282bda --- /dev/null +++ b/src/gates/cloudflare/README.md @@ -0,0 +1,91 @@ +# Cloudflare gates + +Gates that integrate with Cloudflare-issued credentials, headers, and APIs. Compose with [`chain`](../../core/gates/README.md) from `@supabase/server/core/gates`. + +```ts +import { chain } from '@supabase/server/core/gates' +import { withTurnstile } from '@supabase/server/gates/cloudflare' + +export default { + fetch: chain( + withTurnstile({ + secretKey: process.env.TURNSTILE_SECRET_KEY!, + expectedAction: 'login', + }), + )(async (req, ctx) => { + return Response.json({ ok: true, hostname: ctx.state.turnstile.hostname }) + }), +} +``` + +## Available gates + +| Gate | Namespace | Purpose | +| --------------- | ----------- | --------------------------------------------------------------------- | +| `withTurnstile` | `turnstile` | Verifies a Cloudflare Turnstile bot-check token against `siteverify`. | + +More gates (Cloudflare Access, geofencing, bot management) are planned — see the package roadmap. + +## `withTurnstile` + +Verifies the `cf-turnstile-response` token a client widget produces against Cloudflare's siteverify endpoint. On success, contributes the verified challenge metadata to `ctx.state.turnstile`. On failure, short-circuits with a 401 (or 503 if siteverify is unreachable). + +### Config + +```ts +withTurnstile({ + secretKey, // Turnstile secret key (required) + expectedAction, // optional: reject if `action` doesn't match + getToken, // optional: custom token extractor (default: cf-turnstile-response header) + siteverifyUrl, // optional: override the verify endpoint (useful for tests) +}) +``` + +### Contribution + +```ts +ctx.state.turnstile = { + challengeTs: string // ISO 8601 timestamp the challenge was solved + hostname: string // hostname of the page the widget rendered on + action: string // the widget's action label + cdata: string | null // any cdata the client attached +} +``` + +### Token location + +Turnstile tokens are typically returned to the client by the widget and submitted alongside the form / API call. The default extractor reads the `cf-turnstile-response` request header. For form-encoded or JSON bodies, supply `getToken`: + +```ts +withTurnstile({ + secretKey, + getToken: async (req) => { + const form = await req.clone().formData() + return (form.get('cf-turnstile-response') as string | null) ?? null + }, +}) +``` + +`req.clone()` preserves the body for downstream handlers — without it, the body is consumed by the gate. + +### Action binding + +If you bind your widget's client-side `action` to a value (e.g. `"login"`) and pass `expectedAction: 'login'`, the gate rejects when the verified action doesn't match. This prevents a token issued for one form from being replayed against a different endpoint. + +### Errors + +| Status | `error` | Meaning | +| ------ | ------------------------------------ | ------------------------------------------------------------------------------- | +| 401 | `turnstile_token_missing` | No token was found by `getToken`. | +| 401 | `turnstile_verification_failed` | Cloudflare reported `success: false`. Body includes `codes` from `error-codes`. | +| 401 | `turnstile_action_mismatch` | `expectedAction` was set and the verified action differs. | +| 503 | `turnstile_verification_unavailable` | Siteverify returned a non-2xx status. Treat as transient. | + +### Forwarded IP + +If `cf-connecting-ip` is present on the request, it's forwarded to siteverify as `remoteip` — recommended by Cloudflare to harden the check against token replay from other IPs. No-op if you're not behind Cloudflare or the header isn't set. + +## See also + +- [Gate composition primitives](../../core/gates/README.md) — `chain`, `defineGate`, ctx shape +- [Turnstile docs](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/) diff --git a/src/gates/cloudflare/index.ts b/src/gates/cloudflare/index.ts new file mode 100644 index 0000000..959fdd4 --- /dev/null +++ b/src/gates/cloudflare/index.ts @@ -0,0 +1,11 @@ +/** + * Cloudflare gates. + * + * Each gate slots into {@link chain} from `@supabase/server/core/gates` and + * contributes typed state to `ctx.state[namespace]`. + * + * @packageDocumentation + */ + +export { withTurnstile } from './with-turnstile.js' +export type { TurnstileState, WithTurnstileConfig } from './with-turnstile.js' diff --git a/src/gates/cloudflare/with-turnstile.test.ts b/src/gates/cloudflare/with-turnstile.test.ts new file mode 100644 index 0000000..bc580d3 --- /dev/null +++ b/src/gates/cloudflare/with-turnstile.test.ts @@ -0,0 +1,176 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { chain } from '../../core/gates/index.js' +import { withTurnstile } from './with-turnstile.js' + +const SITEVERIFY = 'https://verify.test/turnstile' + +const baseConfig = { + secretKey: 'sk_test', + siteverifyUrl: SITEVERIFY, +} + +const fetchMock = vi.fn() +vi.stubGlobal('fetch', fetchMock) + +afterEach(() => { + fetchMock.mockReset() +}) + +const okBody = { + success: true, + challenge_ts: '2026-01-01T00:00:00Z', + hostname: 'app.example.com', + action: 'login', + cdata: 'abc', +} + +const innerOk = async () => Response.json({ ok: true }) + +describe('withTurnstile', () => { + it('rejects when no token is present', async () => { + const handler = chain(withTurnstile(baseConfig))(innerOk) + + const res = await handler(new Request('http://localhost/')) + + expect(res.status).toBe(401) + expect(await res.json()).toEqual({ error: 'turnstile_token_missing' }) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('passes when verification succeeds and contributes state', async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify(okBody), { status: 200 }), + ) + + const inner = vi.fn(async (_req: Request, ctx) => { + expect(ctx.state.turnstile).toEqual({ + challengeTs: '2026-01-01T00:00:00Z', + hostname: 'app.example.com', + action: 'login', + cdata: 'abc', + }) + return Response.json({ ok: true }) + }) + + const handler = chain(withTurnstile(baseConfig))(inner) + + const res = await handler( + new Request('http://localhost/', { + headers: { 'cf-turnstile-response': 'tok_abc' }, + }), + ) + + expect(res.status).toBe(200) + expect(inner).toHaveBeenCalledOnce() + expect(fetchMock).toHaveBeenCalledOnce() + const [calledUrl, calledInit] = fetchMock.mock.calls[0] + expect(calledUrl).toBe(SITEVERIFY) + expect(calledInit.method).toBe('POST') + const sent = calledInit.body as URLSearchParams + expect(sent.get('secret')).toBe('sk_test') + expect(sent.get('response')).toBe('tok_abc') + expect(sent.get('remoteip')).toBeNull() + }) + + it('rejects when verification fails', async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + success: false, + 'error-codes': ['invalid-input-response'], + }), + ), + ) + + const handler = chain(withTurnstile(baseConfig))(innerOk) + + const res = await handler( + new Request('http://localhost/', { + headers: { 'cf-turnstile-response': 'tok_bad' }, + }), + ) + + expect(res.status).toBe(401) + expect(await res.json()).toEqual({ + error: 'turnstile_verification_failed', + codes: ['invalid-input-response'], + }) + }) + + it('rejects on action mismatch', async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ ...okBody, action: 'signup' })), + ) + + const handler = chain( + withTurnstile({ ...baseConfig, expectedAction: 'login' }), + )(innerOk) + + const res = await handler( + new Request('http://localhost/', { + headers: { 'cf-turnstile-response': 'tok' }, + }), + ) + + expect(res.status).toBe(401) + expect(await res.json()).toMatchObject({ + error: 'turnstile_action_mismatch', + expected: 'login', + actual: 'signup', + }) + }) + + it('returns 503 when siteverify is unreachable', async () => { + fetchMock.mockResolvedValueOnce( + new Response('upstream error', { status: 502 }), + ) + + const handler = chain(withTurnstile(baseConfig))(innerOk) + + const res = await handler( + new Request('http://localhost/', { + headers: { 'cf-turnstile-response': 'tok' }, + }), + ) + + expect(res.status).toBe(503) + }) + + it('forwards remoteip when cf-connecting-ip is present', async () => { + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(okBody))) + + const handler = chain(withTurnstile(baseConfig))(innerOk) + + await handler( + new Request('http://localhost/', { + headers: { + 'cf-turnstile-response': 'tok', + 'cf-connecting-ip': '1.2.3.4', + }, + }), + ) + + const sent = fetchMock.mock.calls[0][1].body as URLSearchParams + expect(sent.get('remoteip')).toBe('1.2.3.4') + }) + + it('honors a custom getToken extractor', async () => { + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(okBody))) + + const handler = chain( + withTurnstile({ + ...baseConfig, + getToken: (req) => new URL(req.url).searchParams.get('captcha'), + }), + )(innerOk) + + const res = await handler( + new Request('http://localhost/?captcha=tok_query'), + ) + + expect(res.status).toBe(200) + const sent = fetchMock.mock.calls[0][1].body as URLSearchParams + expect(sent.get('response')).toBe('tok_query') + }) +}) diff --git a/src/gates/cloudflare/with-turnstile.ts b/src/gates/cloudflare/with-turnstile.ts new file mode 100644 index 0000000..da12a19 --- /dev/null +++ b/src/gates/cloudflare/with-turnstile.ts @@ -0,0 +1,186 @@ +/** + * Cloudflare Turnstile bot-check gate. + * + * Verifies the `cf-turnstile-response` token a client widget produced against + * Cloudflare's siteverify endpoint, then either short-circuits with a 401 or + * contributes the verified challenge metadata to `ctx.state.turnstile`. + * + * @see https://developers.cloudflare.com/turnstile/get-started/server-side-validation/ + */ + +import { defineGate } from '../../core/gates/index.js' + +const SITEVERIFY_URL = + 'https://challenges.cloudflare.com/turnstile/v0/siteverify' + +export interface WithTurnstileConfig { + /** + * Turnstile secret key for your widget. Get one at + * https://dash.cloudflare.com/?to=/:account/turnstile. + */ + secretKey: string + + /** + * If set, the gate rejects when the verified `action` doesn't match. Bind + * your widget's client-side `action` to this so a token issued for one form + * can't be replayed against a different endpoint. + */ + expectedAction?: string + + /** + * Where to find the Turnstile token on the inbound request. Defaults to + * the `cf-turnstile-response` header. For form-encoded or JSON bodies, + * supply a custom extractor — but be aware that consuming the body here + * makes it unavailable to downstream handlers unless you `req.clone()` first. + * + * @defaultValue `(req) => req.headers.get('cf-turnstile-response')` + */ + getToken?: (req: Request) => Promise | string | null + + /** + * Override the Turnstile siteverify URL. Useful for tests; otherwise leave + * unset to hit Cloudflare's production endpoint. + * + * @defaultValue `'https://challenges.cloudflare.com/turnstile/v0/siteverify'` + */ + siteverifyUrl?: string +} + +/** + * Shape contributed at `ctx.state.turnstile` after a successful verification. + */ +export interface TurnstileState { + /** ISO 8601 timestamp when the challenge was solved. */ + challengeTs: string + /** Hostname of the page the widget was rendered on. */ + hostname: string + /** The action the widget was bound to. */ + action: string + /** Custom data the client attached to the widget. */ + cdata: string | null +} + +interface SiteverifyResponse { + success: boolean + challenge_ts?: string + hostname?: string + action?: string + cdata?: string + 'error-codes'?: string[] +} + +/** + * Cloudflare Turnstile bot-check gate. + * + * @example + * ```ts + * import { chain } from '@supabase/server/core/gates' + * import { withTurnstile } from '@supabase/server/gates/cloudflare' + * + * export default { + * fetch: chain( + * withTurnstile({ + * secretKey: process.env.TURNSTILE_SECRET_KEY!, + * expectedAction: 'login', + * }), + * )(async (req, ctx) => { + * return Response.json({ ok: true, action: ctx.state.turnstile.action }) + * }), + * } + * ``` + */ +export const withTurnstile = defineGate< + 'turnstile', + WithTurnstileConfig, + Record, + TurnstileState +>({ + namespace: 'turnstile', + run: (config) => { + const url = config.siteverifyUrl ?? SITEVERIFY_URL + const getToken = config.getToken ?? defaultGetToken + + return async (req) => { + const token = await getToken(req) + if (!token) { + return { + kind: 'reject', + response: Response.json( + { error: 'turnstile_token_missing' }, + { status: 401 }, + ), + } + } + + const params = new URLSearchParams() + params.set('secret', config.secretKey) + params.set('response', token) + const remoteip = req.headers.get('cf-connecting-ip') + if (remoteip) params.set('remoteip', remoteip) + + const verifyResponse = await fetch(url, { + method: 'POST', + body: params, + }) + + if (!verifyResponse.ok) { + return { + kind: 'reject', + response: Response.json( + { + error: 'turnstile_verification_unavailable', + status: verifyResponse.status, + }, + { status: 503 }, + ), + } + } + + const result = (await verifyResponse.json()) as SiteverifyResponse + + if (!result.success) { + return { + kind: 'reject', + response: Response.json( + { + error: 'turnstile_verification_failed', + codes: result['error-codes'] ?? [], + }, + { status: 401 }, + ), + } + } + + if ( + config.expectedAction !== undefined && + result.action !== config.expectedAction + ) { + return { + kind: 'reject', + response: Response.json( + { + error: 'turnstile_action_mismatch', + expected: config.expectedAction, + actual: result.action ?? null, + }, + { status: 401 }, + ), + } + } + + return { + kind: 'pass', + contribution: { + challengeTs: result.challenge_ts ?? '', + hostname: result.hostname ?? '', + action: result.action ?? '', + cdata: result.cdata ?? null, + }, + } + } + }, +}) + +function defaultGetToken(req: Request): string | null { + return req.headers.get('cf-turnstile-response') +} diff --git a/tsdown.config.ts b/tsdown.config.ts index 7dc30b0..08cd8d4 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ 'src/core/gates/index.ts', 'src/adapters/hono/index.ts', 'src/adapters/h3/index.ts', + 'src/gates/cloudflare/index.ts', 'src/gates/x402/index.ts', ], format: ['esm', 'cjs'], From eac3548d40fcc4d300db7c1ae36f59b41efe38de Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Sat, 2 May 2026 02:31:42 -0300 Subject: [PATCH 04/22] feat(gates): add Cloudflare Access gate --- src/gates/cloudflare/README.md | 46 +++++++- src/gates/cloudflare/index.ts | 2 + src/gates/cloudflare/with-access.test.ts | 114 +++++++++++++++++++ src/gates/cloudflare/with-access.ts | 138 +++++++++++++++++++++++ 4 files changed, 296 insertions(+), 4 deletions(-) create mode 100644 src/gates/cloudflare/with-access.test.ts create mode 100644 src/gates/cloudflare/with-access.ts diff --git a/src/gates/cloudflare/README.md b/src/gates/cloudflare/README.md index 7282bda..09e32a6 100644 --- a/src/gates/cloudflare/README.md +++ b/src/gates/cloudflare/README.md @@ -20,11 +20,12 @@ export default { ## Available gates -| Gate | Namespace | Purpose | -| --------------- | ----------- | --------------------------------------------------------------------- | -| `withTurnstile` | `turnstile` | Verifies a Cloudflare Turnstile bot-check token against `siteverify`. | +| Gate | Namespace | Purpose | +| --------------- | ----------- | ------------------------------------------------------------------------------- | +| `withTurnstile` | `turnstile` | Verifies a Cloudflare Turnstile bot-check token against `siteverify`. | +| `withAccess` | `access` | Validates a Cloudflare Zero Trust JWT (`Cf-Access-Jwt-Assertion`) against JWKS. | -More gates (Cloudflare Access, geofencing, bot management) are planned — see the package roadmap. +More gates (geofencing, bot management) are planned — see the package roadmap. ## `withTurnstile` @@ -85,7 +86,44 @@ If you bind your widget's client-side `action` to a value (e.g. `"login"`) and p If `cf-connecting-ip` is present on the request, it's forwarded to siteverify as `remoteip` — recommended by Cloudflare to harden the check against token replay from other IPs. No-op if you're not behind Cloudflare or the header isn't set. +## `withAccess` + +Validates the `Cf-Access-Jwt-Assertion` header that Cloudflare attaches to every request to an Access-protected origin. Verifies the signature against your team's JWKS and checks that the `aud` claim matches your application's audience tag. On success, contributes the verified identity at `ctx.state.access`. + +### Config + +```ts +withAccess({ + teamDomain: 'acme.cloudflareaccess.com', // your team domain + audience: process.env.CF_ACCESS_AUD!, // your application's AUD tag +}) +``` + +### Contribution + +```ts +ctx.state.access = { + email: string | null + sub: string // Cloudflare's stable identity id + identityNonce: string | null + audience: string // the AUD that was validated + claims: JWTPayload // full payload for custom claims +} +``` + +### Errors + +| Status | `error` | Meaning | +| ------ | ---------------------- | ----------------------------------------------------- | +| 401 | `access_token_missing` | The `Cf-Access-Jwt-Assertion` header was not present. | +| 401 | `access_token_invalid` | Signature, audience, or expiration check failed. | + +### When to use it + +For backend services behind a Cloudflare tunnel + Access policy. Cloudflare authenticates the user at the edge and signs every request with a JWT — `withAccess` is the verifier on the origin side. No need to roll your own SSO flow. + ## See also - [Gate composition primitives](../../core/gates/README.md) — `chain`, `defineGate`, ctx shape - [Turnstile docs](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/) +- [Access JWT validation](https://developers.cloudflare.com/cloudflare-one/identity/authorization-cookie/validating-json/) diff --git a/src/gates/cloudflare/index.ts b/src/gates/cloudflare/index.ts index 959fdd4..453e9ce 100644 --- a/src/gates/cloudflare/index.ts +++ b/src/gates/cloudflare/index.ts @@ -7,5 +7,7 @@ * @packageDocumentation */ +export { withAccess } from './with-access.js' +export type { AccessState, WithAccessConfig } from './with-access.js' export { withTurnstile } from './with-turnstile.js' export type { TurnstileState, WithTurnstileConfig } from './with-turnstile.js' diff --git a/src/gates/cloudflare/with-access.test.ts b/src/gates/cloudflare/with-access.test.ts new file mode 100644 index 0000000..6cbfb13 --- /dev/null +++ b/src/gates/cloudflare/with-access.test.ts @@ -0,0 +1,114 @@ +import { exportJWK, generateKeyPair, SignJWT, type KeyObject } from 'jose' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' + +import { chain } from '../../core/gates/index.js' +import { withAccess } from './with-access.js' + +const TEAM_DOMAIN = 'acme.cloudflareaccess.com' +const ISSUER = `https://${TEAM_DOMAIN}` +const AUDIENCE = + '8c6f8a7d36b4f8e9d6e7c5a4b3a2f1e0d9c8b7a6e5f4d3c2b1a0f9e8d7c6b5a4' + +const fetchMock = vi.fn() +vi.stubGlobal('fetch', fetchMock) + +let privateKey: KeyObject +let kid: string + +beforeAll(async () => { + const pair = await generateKeyPair('RS256') + privateKey = pair.privateKey as KeyObject + const jwk = await exportJWK(pair.publicKey) + kid = 'test-key' + const jwks = { keys: [{ ...jwk, kid, alg: 'RS256', use: 'sig' }] } + // Every fetch in these tests goes to the JWKS endpoint. + fetchMock.mockImplementation(async () => + Response.json(jwks, { headers: { 'cache-control': 'max-age=60' } }), + ) +}) + +afterEach(() => { + fetchMock.mockClear() +}) + +const sign = ( + overrides: { aud?: string | string[]; email?: string; sub?: string } = {}, +) => + new SignJWT({ + email: overrides.email ?? 'user@example.com', + identity_nonce: 'nonce-abc', + }) + .setProtectedHeader({ alg: 'RS256', kid }) + .setIssuer(ISSUER) + .setAudience(overrides.aud ?? AUDIENCE) + .setSubject(overrides.sub ?? 'user-123') + .setIssuedAt() + .setExpirationTime('5m') + .sign(privateKey) + +const baseConfig = { teamDomain: TEAM_DOMAIN, audience: AUDIENCE } +const innerOk = async () => Response.json({ ok: true }) + +describe('withAccess', () => { + it('rejects when the assertion header is missing', async () => { + const handler = chain(withAccess(baseConfig))(innerOk) + + const res = await handler(new Request('http://localhost/')) + + expect(res.status).toBe(401) + expect(await res.json()).toEqual({ error: 'access_token_missing' }) + }) + + it('admits a valid token and contributes identity to ctx.state.access', async () => { + const token = await sign() + + const inner = vi.fn(async (_req: Request, ctx) => { + expect(ctx.state.access.email).toBe('user@example.com') + expect(ctx.state.access.sub).toBe('user-123') + expect(ctx.state.access.identityNonce).toBe('nonce-abc') + expect(ctx.state.access.audience).toBe(AUDIENCE) + expect(ctx.state.access.claims.iss).toBe(ISSUER) + return Response.json({ ok: true }) + }) + + const handler = chain(withAccess(baseConfig))(inner) + + const res = await handler( + new Request('http://localhost/', { + headers: { 'cf-access-jwt-assertion': token }, + }), + ) + + expect(res.status).toBe(200) + expect(inner).toHaveBeenCalledOnce() + }) + + it('rejects a token with the wrong audience', async () => { + const token = await sign({ aud: 'someone-elses-audience' }) + + const handler = chain(withAccess(baseConfig))(innerOk) + + const res = await handler( + new Request('http://localhost/', { + headers: { 'cf-access-jwt-assertion': token }, + }), + ) + + expect(res.status).toBe(401) + const body = await res.json() + expect(body.error).toBe('access_token_invalid') + }) + + it('rejects a malformed assertion', async () => { + const handler = chain(withAccess(baseConfig))(innerOk) + + const res = await handler( + new Request('http://localhost/', { + headers: { 'cf-access-jwt-assertion': 'not.a.jwt' }, + }), + ) + + expect(res.status).toBe(401) + expect((await res.json()).error).toBe('access_token_invalid') + }) +}) diff --git a/src/gates/cloudflare/with-access.ts b/src/gates/cloudflare/with-access.ts new file mode 100644 index 0000000..f3e5837 --- /dev/null +++ b/src/gates/cloudflare/with-access.ts @@ -0,0 +1,138 @@ +/** + * Cloudflare Zero Trust Access gate. + * + * Validates the `Cf-Access-Jwt-Assertion` header against the team's JWKS, + * checks the audience tag binding, and contributes the identity claims to + * `ctx.state.access`. + * + * Use this for backend services that sit behind a Cloudflare tunnel + Access + * policy — every request is signed by Cloudflare on the way in. + * + * @see https://developers.cloudflare.com/cloudflare-one/identity/authorization-cookie/validating-json/ + */ + +import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose' + +import { defineGate } from '../../core/gates/index.js' + +const HEADER_NAME = 'cf-access-jwt-assertion' + +export interface WithAccessConfig { + /** + * Your Cloudflare team domain — `.cloudflareaccess.com` (no protocol, + * no path). Used to derive the JWKS URL and the expected `iss` claim. + * + * Find it at https://one.dash.cloudflare.com/ → Settings → Custom Pages. + */ + teamDomain: string + + /** + * The Application Audience (AUD) tag from your Access policy. The gate + * rejects tokens whose `aud` claim doesn't include this value. + * + * Find it at Zero Trust → Access → Applications → → Overview. + */ + audience: string + + /** + * Override the JWKS URL. By default derived from `teamDomain`. Useful for + * tests; otherwise leave unset. + */ + jwksUrl?: string +} + +/** Shape contributed at `ctx.state.access` after a successful verification. */ +export interface AccessState { + /** The user's email address from the verified token. */ + email: string | null + /** The `sub` claim — Cloudflare's stable identity id for this user. */ + sub: string + /** Cloudflare's identity nonce, useful for cache-busting per session. */ + identityNonce: string | null + /** The `aud` claim that was validated. */ + audience: string + /** The full verified JWT payload, for accessing custom claims. */ + claims: JWTPayload +} + +/** + * Cloudflare Zero Trust Access gate. + * + * @example + * ```ts + * import { chain } from '@supabase/server/core/gates' + * import { withAccess } from '@supabase/server/gates/cloudflare' + * + * export default { + * fetch: chain( + * withAccess({ + * teamDomain: 'acme.cloudflareaccess.com', + * audience: process.env.CF_ACCESS_AUD!, + * }), + * )(async (req, ctx) => { + * return Response.json({ user: ctx.state.access.email }) + * }), + * } + * ``` + */ +export const withAccess = defineGate< + 'access', + WithAccessConfig, + Record, + AccessState +>({ + namespace: 'access', + run: (config) => { + const issuer = `https://${config.teamDomain}` + const jwksUrl = config.jwksUrl ?? `${issuer}/cdn-cgi/access/certs` + const jwks = createRemoteJWKSet(new URL(jwksUrl)) + + return async (req) => { + const token = req.headers.get(HEADER_NAME) + if (!token) { + return { + kind: 'reject', + response: Response.json( + { error: 'access_token_missing' }, + { status: 401 }, + ), + } + } + + try { + const { payload } = await jwtVerify(token, jwks, { + issuer, + audience: config.audience, + }) + + const email = typeof payload.email === 'string' ? payload.email : null + const identityNonce = + typeof payload.identity_nonce === 'string' + ? payload.identity_nonce + : null + + return { + kind: 'pass', + contribution: { + email, + sub: payload.sub ?? '', + identityNonce, + audience: config.audience, + claims: payload, + }, + } + } catch (err) { + return { + kind: 'reject', + response: Response.json( + { + error: 'access_token_invalid', + detail: err instanceof Error ? err.message : 'unknown', + }, + { status: 401 }, + ), + } + } + } + }, +}) From 4318b35f2eeefbadb31ef287af2474b3bf0023eb Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Sat, 2 May 2026 02:32:41 -0300 Subject: [PATCH 05/22] feat(gates): add webhook signature verification gate --- src/gates/webhook/README.md | 101 ++++++++++++ src/gates/webhook/index.ts | 13 ++ src/gates/webhook/with-webhook.test.ts | 194 +++++++++++++++++++++++ src/gates/webhook/with-webhook.ts | 203 +++++++++++++++++++++++++ 4 files changed, 511 insertions(+) create mode 100644 src/gates/webhook/README.md create mode 100644 src/gates/webhook/index.ts create mode 100644 src/gates/webhook/with-webhook.test.ts create mode 100644 src/gates/webhook/with-webhook.ts diff --git a/src/gates/webhook/README.md b/src/gates/webhook/README.md new file mode 100644 index 0000000..d14e308 --- /dev/null +++ b/src/gates/webhook/README.md @@ -0,0 +1,101 @@ +# `@supabase/server/gates/webhook` + +HMAC signature verification for inbound webhooks. Reads the raw request body, verifies it against a shared secret, checks the replay window, and contributes the parsed event + raw bytes to `ctx.state.webhook`. + +```ts +import { chain } from '@supabase/server/core/gates' +import { withWebhook } from '@supabase/server/gates/webhook' + +export default { + fetch: chain( + withWebhook({ + provider: { + kind: 'stripe', + secret: process.env.STRIPE_WEBHOOK_SECRET!, + }, + }), + )(async (req, ctx) => { + const event = ctx.state.webhook.event as { type: string } + if (event.type === 'payment_intent.succeeded') { + // … + } + return new Response(null, { status: 204 }) + }), +} +``` + +## Built-in providers + +### Stripe + +Verifies the `Stripe-Signature` header (`t=,v1=`), rejects on: + +- missing header (`signature_missing`) +- malformed header (`signature_malformed`) +- timestamp outside `toleranceMs` (`signature_expired`, default 5 minutes) +- HMAC mismatch (`signature_invalid`) + +Supports key rotation: pass `secret: ['whsec_new', 'whsec_old']` and the gate accepts any of them. + +```ts +withWebhook({ + provider: { + kind: 'stripe', + secret: process.env.STRIPE_WEBHOOK_SECRET!, + toleranceMs: 5 * 60 * 1000, // optional, default 5 minutes + }, +}) +``` + +### Custom + +For any other provider (Svix/Resend, GitHub, Slack, Shopify, in-house) supply a `verify` function. The gate calls it with the raw body already consumed: + +```ts +withWebhook({ + provider: { + kind: 'custom', + async verify(req, rawBody) { + const signature = req.headers.get('x-hub-signature-256') ?? '' + const expected = + 'sha256=' + (await hmacHex(process.env.GH_WEBHOOK_SECRET!, rawBody)) + if (!timingSafeEqual(signature, expected)) { + return { ok: false, error: 'signature_invalid' } + } + const event = JSON.parse(rawBody) + return { + ok: true, + event, + deliveryId: req.headers.get('x-github-delivery') ?? '', + timestamp: Date.now(), + } + }, + }, +}) +``` + +## Contribution + +```ts +ctx.state.webhook = { + event: unknown // parsed JSON body + rawBody: string // raw bytes the signature was computed over + deliveryId: string // provider-supplied id (Stripe: event.id; GitHub: x-github-delivery) + timestamp: number // ms epoch +} +``` + +`rawBody` is preserved so downstream handlers can re-verify, forward to other systems, or pass to libraries that expect raw bytes. + +## Body consumption + +The gate reads the request body via `req.text()` once. Downstream handlers that call `req.json()` would fail because the body is already consumed — read from `ctx.state.webhook.event` (parsed) or `ctx.state.webhook.rawBody` (raw) instead. + +## Idempotency + +The gate doesn't dedupe. Webhooks are typically delivered at-least-once; persist `deliveryId` to a `webhook_events(provider, delivery_id)` table with a unique index and skip duplicates in your handler. + +## See also + +- [Gate composition primitives](../../core/gates/README.md) +- [Stripe webhook signing docs](https://docs.stripe.com/webhooks#verify-manually) diff --git a/src/gates/webhook/index.ts b/src/gates/webhook/index.ts new file mode 100644 index 0000000..a5d138c --- /dev/null +++ b/src/gates/webhook/index.ts @@ -0,0 +1,13 @@ +/** + * Webhook signature verification gate. + * + * @packageDocumentation + */ + +export { withWebhook } from './with-webhook.js' +export type { + WebhookProvider, + WebhookState, + WebhookVerifyResult, + WithWebhookConfig, +} from './with-webhook.js' diff --git a/src/gates/webhook/with-webhook.test.ts b/src/gates/webhook/with-webhook.test.ts new file mode 100644 index 0000000..d9bfaa0 --- /dev/null +++ b/src/gates/webhook/with-webhook.test.ts @@ -0,0 +1,194 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' + +import { chain } from '../../core/gates/index.js' +import { withWebhook } from './with-webhook.js' + +const SECRET = 'whsec_test' + +beforeAll(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date(1_700_000_000_000)) +}) + +afterEach(() => { + vi.setSystemTime(new Date(1_700_000_000_000)) +}) + +async function hmacHex(secret: string, payload: string): Promise { + const enc = new TextEncoder() + const key = await crypto.subtle.importKey( + 'raw', + enc.encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ) + const sig = await crypto.subtle.sign('HMAC', key, enc.encode(payload)) + return Array.from(new Uint8Array(sig)) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') +} + +const innerOk = async () => Response.json({ ok: true }) + +describe('withWebhook (stripe)', () => { + it('admits a valid Stripe signature and contributes parsed event', async () => { + const body = JSON.stringify({ + id: 'evt_123', + type: 'payment_intent.succeeded', + created: 1_700_000_000, + }) + const t = 1_700_000_000 + const v1 = await hmacHex(SECRET, `${t}.${body}`) + + const inner = vi.fn(async (_req: Request, ctx) => { + expect(ctx.state.webhook.deliveryId).toBe('evt_123') + expect((ctx.state.webhook.event as { type: string }).type).toBe( + 'payment_intent.succeeded', + ) + expect(ctx.state.webhook.timestamp).toBe(1_700_000_000_000) + expect(ctx.state.webhook.rawBody).toBe(body) + return Response.json({ ok: true }) + }) + + const handler = chain( + withWebhook({ provider: { kind: 'stripe', secret: SECRET } }), + )(inner) + + const res = await handler( + new Request('http://localhost/', { + method: 'POST', + headers: { 'stripe-signature': `t=${t},v1=${v1}` }, + body, + }), + ) + + expect(res.status).toBe(200) + expect(inner).toHaveBeenCalledOnce() + }) + + it('rejects when the signature header is missing', async () => { + const handler = chain( + withWebhook({ provider: { kind: 'stripe', secret: SECRET } }), + )(innerOk) + + const res = await handler( + new Request('http://localhost/', { method: 'POST', body: '{}' }), + ) + + expect(res.status).toBe(401) + expect((await res.json()).error).toBe('signature_missing') + }) + + it('rejects on a bad signature', async () => { + const body = '{"id":"evt_1"}' + const t = 1_700_000_000 + + const handler = chain( + withWebhook({ provider: { kind: 'stripe', secret: SECRET } }), + )(innerOk) + + const res = await handler( + new Request('http://localhost/', { + method: 'POST', + headers: { 'stripe-signature': `t=${t},v1=deadbeef` }, + body, + }), + ) + + expect(res.status).toBe(401) + expect((await res.json()).error).toBe('signature_invalid') + }) + + it('rejects when the timestamp is outside the tolerance window', async () => { + const body = '{"id":"evt_1"}' + const t = 1_700_000_000 - 600 // 10 min ago, default tolerance is 5 min + const v1 = await hmacHex(SECRET, `${t}.${body}`) + + const handler = chain( + withWebhook({ provider: { kind: 'stripe', secret: SECRET } }), + )(innerOk) + + const res = await handler( + new Request('http://localhost/', { + method: 'POST', + headers: { 'stripe-signature': `t=${t},v1=${v1}` }, + body, + }), + ) + + expect(res.status).toBe(401) + expect((await res.json()).error).toBe('signature_expired') + }) + + it('accepts any of multiple secrets (rotation)', async () => { + const body = '{"id":"evt_rot"}' + const t = 1_700_000_000 + const oldSecret = 'whsec_old' + const v1 = await hmacHex(oldSecret, `${t}.${body}`) + + const handler = chain( + withWebhook({ + provider: { kind: 'stripe', secret: ['whsec_new', oldSecret] }, + }), + )(innerOk) + + const res = await handler( + new Request('http://localhost/', { + method: 'POST', + headers: { 'stripe-signature': `t=${t},v1=${v1}` }, + body, + }), + ) + + expect(res.status).toBe(200) + }) +}) + +describe('withWebhook (custom)', () => { + it('passes when the custom verifier returns ok', async () => { + const verify = vi.fn(async (_req: Request, body: string) => ({ + ok: true as const, + event: JSON.parse(body), + deliveryId: 'd-1', + timestamp: 1_700_000_000_000, + })) + + const inner = vi.fn(async (_req: Request, ctx) => { + expect(ctx.state.webhook.deliveryId).toBe('d-1') + return Response.json({ ok: true }) + }) + + const handler = chain( + withWebhook({ provider: { kind: 'custom', verify } }), + )(inner) + + const res = await handler( + new Request('http://localhost/', { + method: 'POST', + body: '{"hi":1}', + }), + ) + + expect(res.status).toBe(200) + expect(verify).toHaveBeenCalledOnce() + }) + + it('rejects when the custom verifier returns failure', async () => { + const handler = chain( + withWebhook({ + provider: { + kind: 'custom', + verify: () => ({ ok: false, status: 403, error: 'forbidden' }), + }, + }), + )(innerOk) + + const res = await handler( + new Request('http://localhost/', { method: 'POST', body: '{}' }), + ) + + expect(res.status).toBe(403) + expect((await res.json()).error).toBe('forbidden') + }) +}) diff --git a/src/gates/webhook/with-webhook.ts b/src/gates/webhook/with-webhook.ts new file mode 100644 index 0000000..fa50518 --- /dev/null +++ b/src/gates/webhook/with-webhook.ts @@ -0,0 +1,203 @@ +/** + * Webhook signature verification gate. + * + * Verifies the HMAC signature on an inbound webhook against a shared secret, + * checks the replay window, and contributes the parsed event + raw body to + * `ctx.state.webhook`. Stripe is the canonical provider; supply a custom + * `verify` function to plug in others (Svix/Resend, GitHub, Slack, Shopify). + */ + +import { defineGate } from '../../core/gates/index.js' + +const FIVE_MIN_MS = 5 * 60 * 1000 + +export type WebhookProvider = + | { kind: 'stripe'; secret: string | string[]; toleranceMs?: number } + | { + kind: 'custom' + /** + * Verify the inbound request and return a `WebhookSuccess` to admit it + * or a `WebhookFailure` to reject. The gate calls this with the raw + * body string already consumed; emit your own response shape if needed. + */ + verify: ( + req: Request, + rawBody: string, + ) => Promise | WebhookVerifyResult + } + +export type WebhookVerifyResult = + | { ok: true; event: unknown; deliveryId: string; timestamp: number } + | { ok: false; status?: number; error?: string } + +export interface WithWebhookConfig { + provider: WebhookProvider +} + +/** Shape contributed at `ctx.state.webhook` after a successful verification. */ +export interface WebhookState { + /** The parsed JSON event body. */ + event: unknown + /** The raw body bytes (as string) the signature was computed over. */ + rawBody: string + /** Provider-specific delivery id (for idempotency / dedupe). */ + deliveryId: string + /** Provider-supplied event timestamp (ms epoch). */ + timestamp: number +} + +/** + * Webhook signature verification gate. + * + * @example + * ```ts + * import { chain } from '@supabase/server/core/gates' + * import { withWebhook } from '@supabase/server/gates/webhook' + * + * export default { + * fetch: chain( + * withWebhook({ + * provider: { + * kind: 'stripe', + * secret: process.env.STRIPE_WEBHOOK_SECRET!, + * }, + * }), + * )(async (req, ctx) => { + * // ctx.state.webhook.event is the parsed Stripe event + * // ctx.state.webhook.rawBody is the raw bytes (preserved here) + * return new Response(null, { status: 204 }) + * }), + * } + * ``` + */ +export const withWebhook = defineGate< + 'webhook', + WithWebhookConfig, + Record, + WebhookState +>({ + namespace: 'webhook', + run: (config) => async (req) => { + const rawBody = await req.text() + const result = + config.provider.kind === 'custom' + ? await config.provider.verify(req, rawBody) + : await verifyStripe(req, rawBody, config.provider) + + if (!result.ok) { + return { + kind: 'reject', + response: Response.json( + { error: result.error ?? 'invalid_signature' }, + { status: result.status ?? 401 }, + ), + } + } + + return { + kind: 'pass', + contribution: { + event: result.event, + rawBody, + deliveryId: result.deliveryId, + timestamp: result.timestamp, + }, + } + }, +}) + +async function verifyStripe( + req: Request, + rawBody: string, + provider: { kind: 'stripe'; secret: string | string[]; toleranceMs?: number }, +): Promise { + const header = req.headers.get('stripe-signature') + if (!header) return { ok: false, error: 'signature_missing' } + + const parsed = parseStripeHeader(header) + if (!parsed) return { ok: false, error: 'signature_malformed' } + + const tolerance = provider.toleranceMs ?? FIVE_MIN_MS + const ageMs = Math.abs(Date.now() - parsed.t * 1000) + if (ageMs > tolerance) { + return { ok: false, error: 'signature_expired' } + } + + const signedPayload = `${parsed.t}.${rawBody}` + const secrets = Array.isArray(provider.secret) + ? provider.secret + : [provider.secret] + + let matched = false + for (const secret of secrets) { + const expected = await hmacSha256Hex(secret, signedPayload) + for (const v1 of parsed.v1) { + if (timingSafeEqualHex(expected, v1)) { + matched = true + break + } + } + if (matched) break + } + if (!matched) return { ok: false, error: 'signature_invalid' } + + let event: { id?: string; created?: number } & Record + try { + event = JSON.parse(rawBody) as typeof event + } catch { + return { ok: false, error: 'body_not_json' } + } + + return { + ok: true, + event, + deliveryId: typeof event.id === 'string' ? event.id : '', + timestamp: + typeof event.created === 'number' + ? event.created * 1000 + : parsed.t * 1000, + } +} + +function parseStripeHeader(header: string): { t: number; v1: string[] } | null { + const parts = header.split(',') + let t: number | null = null + const v1: string[] = [] + for (const part of parts) { + const eq = part.indexOf('=') + if (eq < 0) continue + const k = part.slice(0, eq).trim() + const v = part.slice(eq + 1).trim() + if (k === 't') t = Number(v) + else if (k === 'v1') v1.push(v) + } + if (t === null || Number.isNaN(t) || v1.length === 0) return null + return { t, v1 } +} + +async function hmacSha256Hex(secret: string, payload: string): Promise { + const enc = new TextEncoder() + const key = await crypto.subtle.importKey( + 'raw', + enc.encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ) + const sig = await crypto.subtle.sign('HMAC', key, enc.encode(payload)) + const bytes = new Uint8Array(sig) + let hex = '' + for (let i = 0; i < bytes.length; i++) { + hex += bytes[i]!.toString(16).padStart(2, '0') + } + return hex +} + +function timingSafeEqualHex(a: string, b: string): boolean { + if (a.length !== b.length) return false + let mismatch = 0 + for (let i = 0; i < a.length; i++) { + mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i) + } + return mismatch === 0 +} From 9b784b8b2e50d26f89098c28594d0364202b121a Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Sat, 2 May 2026 02:33:18 -0300 Subject: [PATCH 06/22] feat(gates): add fixed-window rate-limit gate --- src/gates/rate-limit/README.md | 89 ++++++++++++ src/gates/rate-limit/index.ts | 12 ++ src/gates/rate-limit/with-rate-limit.test.ts | 110 +++++++++++++++ src/gates/rate-limit/with-rate-limit.ts | 141 +++++++++++++++++++ 4 files changed, 352 insertions(+) create mode 100644 src/gates/rate-limit/README.md create mode 100644 src/gates/rate-limit/index.ts create mode 100644 src/gates/rate-limit/with-rate-limit.test.ts create mode 100644 src/gates/rate-limit/with-rate-limit.ts diff --git a/src/gates/rate-limit/README.md b/src/gates/rate-limit/README.md new file mode 100644 index 0000000..def7033 --- /dev/null +++ b/src/gates/rate-limit/README.md @@ -0,0 +1,89 @@ +# `@supabase/server/gates/rate-limit` + +Fixed-window rate-limit gate. Counts hits per key within a window; rejects with `429 Too Many Requests` once the limit is exceeded. + +```ts +import { chain } from '@supabase/server/core/gates' +import { withRateLimit } from '@supabase/server/gates/rate-limit' + +export default { + fetch: chain( + withRateLimit({ + limit: 60, + windowMs: 60_000, + key: (req) => req.headers.get('cf-connecting-ip') ?? 'anon', + }), + )(async (req, ctx) => { + return Response.json({ remaining: ctx.state.rateLimit.remaining }) + }), +} +``` + +## Config + +| Field | Type | Description | +| ---------- | --------------------------------------------- | ----------------------------------------------------------------- | +| `limit` | `number` | Maximum hits per `windowMs` per key. | +| `windowMs` | `number` | Window length in milliseconds. | +| `key` | `(req: Request) => string \| Promise` | Bucketing key. Per-IP, per-user, per-tenant, etc. | +| `store` | `RateLimitStore?` | Backing store. Defaults to in-memory `Map` (single-process only). | + +## Contribution + +```ts +ctx.state.rateLimit = { + limit: number // configured limit + remaining: number // hits remaining in current window + reset: number // ms epoch when the window resets +} +``` + +## Errors + +`429 Too Many Requests` with `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining: 0`, and `X-RateLimit-Reset` headers. Body: `{ error: 'rate_limit_exceeded', retryAfter: }`. + +## Production stores + +The default in-memory store works for tests and a single long-lived process. Multi-instance / serverless deployments need a shared store so windows aren't reset by request affinity. Implement the `RateLimitStore` interface against Postgres, Redis, or KV: + +```ts +import type { RateLimitStore } from '@supabase/server/gates/rate-limit' + +const postgresStore: RateLimitStore = { + async hit(key, windowMs) { + const { rows } = await db.query(`select * from rate_limit_hit($1, $2)`, [ + key, + windowMs, + ]) + return { count: rows[0].count, resetAt: rows[0].reset_at } + }, +} +``` + +The `hit` method must atomically increment-or-create the bucket. A SQL function is the simplest correct implementation. + +## Composing with `withSupabase` for per-user limits + +```ts +withSupabase( + { allow: 'user' }, + chain( + withRateLimit({ + limit: 30, + windowMs: 60_000, + key: async (_req) => { + // Pull the user id from the upstream Supabase context. + // Note: the key extractor doesn't see ctx by default; stash it in + // a closure or use ctx.locals from inside a wrapper gate. + return 'per-user-key' + }, + }), + )(handler), +) +``` + +For per-user limits, key off `ctx.userClaims.id`. The current `key` signature only sees the request — pass user identity via a header you trust (after `withSupabase` validation), or compose the gate after a small "stamp the user id into req" step. + +## See also + +- [Gate composition primitives](../../core/gates/README.md) diff --git a/src/gates/rate-limit/index.ts b/src/gates/rate-limit/index.ts new file mode 100644 index 0000000..4bb6afa --- /dev/null +++ b/src/gates/rate-limit/index.ts @@ -0,0 +1,12 @@ +/** + * Rate-limit gate. + * + * @packageDocumentation + */ + +export { withRateLimit, createMemoryStore } from './with-rate-limit.js' +export type { + RateLimitState, + RateLimitStore, + WithRateLimitConfig, +} from './with-rate-limit.js' diff --git a/src/gates/rate-limit/with-rate-limit.test.ts b/src/gates/rate-limit/with-rate-limit.test.ts new file mode 100644 index 0000000..5e1d564 --- /dev/null +++ b/src/gates/rate-limit/with-rate-limit.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' + +import { chain } from '../../core/gates/index.js' +import { withRateLimit, createMemoryStore } from './with-rate-limit.js' + +const innerOk = async () => Response.json({ ok: true }) + +beforeAll(() => { + vi.useFakeTimers() +}) + +afterEach(() => { + vi.setSystemTime(new Date(0)) +}) + +describe('withRateLimit', () => { + it('admits requests under the limit and contributes ctx.state.rateLimit', async () => { + const handler = chain( + withRateLimit({ + limit: 3, + windowMs: 60_000, + key: () => 'k', + }), + )(async (_req, ctx) => + Response.json({ remaining: ctx.state.rateLimit.remaining }), + ) + + const r1 = await handler(new Request('http://localhost/')) + const r2 = await handler(new Request('http://localhost/')) + const r3 = await handler(new Request('http://localhost/')) + + expect(r1.status).toBe(200) + expect(await r1.json()).toEqual({ remaining: 2 }) + expect(await r2.json()).toEqual({ remaining: 1 }) + expect(await r3.json()).toEqual({ remaining: 0 }) + }) + + it('rejects with 429 + Retry-After once the limit is exceeded', async () => { + vi.setSystemTime(new Date(1_700_000_000_000)) + + const handler = chain( + withRateLimit({ limit: 1, windowMs: 60_000, key: () => 'k' }), + )(innerOk) + + const ok = await handler(new Request('http://localhost/')) + expect(ok.status).toBe(200) + + const blocked = await handler(new Request('http://localhost/')) + expect(blocked.status).toBe(429) + expect(blocked.headers.get('Retry-After')).toBe('60') + expect(blocked.headers.get('X-RateLimit-Limit')).toBe('1') + expect(blocked.headers.get('X-RateLimit-Remaining')).toBe('0') + expect(blocked.headers.get('X-RateLimit-Reset')).toBe( + String(Math.floor((1_700_000_000_000 + 60_000) / 1000)), + ) + const body = await blocked.json() + expect(body).toMatchObject({ error: 'rate_limit_exceeded', retryAfter: 60 }) + }) + + it('isolates buckets by key', async () => { + const handler = chain( + withRateLimit({ + limit: 1, + windowMs: 60_000, + key: (req) => new URL(req.url).searchParams.get('user') ?? 'anon', + }), + )(innerOk) + + expect( + (await handler(new Request('http://localhost/?user=a'))).status, + ).toBe(200) + expect( + (await handler(new Request('http://localhost/?user=b'))).status, + ).toBe(200) + expect( + (await handler(new Request('http://localhost/?user=a'))).status, + ).toBe(429) + expect( + (await handler(new Request('http://localhost/?user=b'))).status, + ).toBe(429) + }) + + it('resets after the window elapses', async () => { + vi.setSystemTime(new Date(1_700_000_000_000)) + + const handler = chain( + withRateLimit({ limit: 1, windowMs: 1_000, key: () => 'k' }), + )(innerOk) + + expect((await handler(new Request('http://localhost/'))).status).toBe(200) + expect((await handler(new Request('http://localhost/'))).status).toBe(429) + + vi.setSystemTime(new Date(1_700_000_001_500)) + expect((await handler(new Request('http://localhost/'))).status).toBe(200) + }) +}) + +describe('createMemoryStore', () => { + it('returns a fresh window when the previous has expired', async () => { + vi.setSystemTime(new Date(1_700_000_000_000)) + const store = createMemoryStore() + + const first = await store.hit('k', 1_000) + expect(first).toEqual({ count: 1, resetAt: 1_700_000_001_000 }) + + vi.setSystemTime(new Date(1_700_000_002_000)) + const fresh = await store.hit('k', 1_000) + expect(fresh).toEqual({ count: 1, resetAt: 1_700_000_003_000 }) + }) +}) diff --git a/src/gates/rate-limit/with-rate-limit.ts b/src/gates/rate-limit/with-rate-limit.ts new file mode 100644 index 0000000..c8947e2 --- /dev/null +++ b/src/gates/rate-limit/with-rate-limit.ts @@ -0,0 +1,141 @@ +/** + * Fixed-window rate-limit gate. + * + * Counts hits per key within a rolling window; rejects with 429 when the + * count exceeds `limit`. The store is pluggable — defaults to a per-process + * in-memory `Map`. For multi-instance / serverless deployments, supply a + * Postgres-, Redis-, or KV-backed implementation. + */ + +import { defineGate } from '../../core/gates/index.js' + +export interface RateLimitStore { + /** + * Atomically increment the hit count for `key` within a window of length + * `windowMs` milliseconds. Returns the post-increment count and the + * absolute timestamp (ms epoch) when the current window resets. + */ + hit( + key: string, + windowMs: number, + ): Promise<{ count: number; resetAt: number }> +} + +export interface WithRateLimitConfig { + /** Maximum hits per `windowMs` per key. */ + limit: number + + /** Window length in milliseconds. */ + windowMs: number + + /** + * Extracts the bucketing key from the request. Common choices: + * - `req => req.headers.get('cf-connecting-ip') ?? 'anon'` for per-IP limits + * - `(_, ctx) => ctx.userClaims?.id ?? 'anon'` for per-user limits (when + * composed inside `withSupabase`) + */ + key: (req: Request) => string | Promise + + /** + * Backing store. Defaults to an in-memory `Map` suitable for tests and + * single-process dev. Production multi-instance deployments need a shared + * store so windows aren't reset by request affinity. + */ + store?: RateLimitStore +} + +/** Shape contributed at `ctx.state.rateLimit` after a successful hit. */ +export interface RateLimitState { + /** The configured limit for this window. */ + limit: number + /** Hits remaining in the current window. */ + remaining: number + /** Absolute ms timestamp when the current window resets. */ + reset: number +} + +/** + * Fixed-window rate-limit gate. + * + * @example + * ```ts + * import { chain } from '@supabase/server/core/gates' + * import { withRateLimit } from '@supabase/server/gates/rate-limit' + * + * export default { + * fetch: chain( + * withRateLimit({ + * limit: 60, + * windowMs: 60_000, + * key: (req) => req.headers.get('cf-connecting-ip') ?? 'anon', + * }), + * )(async (req, ctx) => { + * return Response.json({ remaining: ctx.state.rateLimit.remaining }) + * }), + * } + * ``` + */ +export const withRateLimit = defineGate< + 'rateLimit', + WithRateLimitConfig, + Record, + RateLimitState +>({ + namespace: 'rateLimit', + run: (config) => { + const store = config.store ?? createMemoryStore() + + return async (req) => { + const key = await config.key(req) + const { count, resetAt } = await store.hit(key, config.windowMs) + const remaining = Math.max(0, config.limit - count) + const resetSec = Math.floor(resetAt / 1000) + + if (count > config.limit) { + const retryAfter = Math.max(1, Math.ceil((resetAt - Date.now()) / 1000)) + return { + kind: 'reject', + response: Response.json( + { error: 'rate_limit_exceeded', retryAfter }, + { + status: 429, + headers: { + 'Retry-After': String(retryAfter), + 'X-RateLimit-Limit': String(config.limit), + 'X-RateLimit-Remaining': '0', + 'X-RateLimit-Reset': String(resetSec), + }, + }, + ), + } + } + + return { + kind: 'pass', + contribution: { + limit: config.limit, + remaining, + reset: resetAt, + }, + } + } + }, +}) + +/** Default in-memory store. Single-process only. */ +export function createMemoryStore(): RateLimitStore { + const buckets = new Map() + return { + async hit(key, windowMs) { + const now = Date.now() + const existing = buckets.get(key) + if (!existing || existing.resetAt <= now) { + const fresh = { count: 1, resetAt: now + windowMs } + buckets.set(key, fresh) + return { ...fresh } + } + existing.count += 1 + return { count: existing.count, resetAt: existing.resetAt } + }, + } +} From fe612da58488ad018eb1eaeb56bb92c6c330db4b Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Sat, 2 May 2026 02:33:48 -0300 Subject: [PATCH 07/22] feat(gates): add provider-agnostic feature-flag gate --- src/gates/flag/README.md | 84 +++++++++++++++++++++ src/gates/flag/index.ts | 8 ++ src/gates/flag/with-flag.test.ts | 113 ++++++++++++++++++++++++++++ src/gates/flag/with-flag.ts | 122 +++++++++++++++++++++++++++++++ 4 files changed, 327 insertions(+) create mode 100644 src/gates/flag/README.md create mode 100644 src/gates/flag/index.ts create mode 100644 src/gates/flag/with-flag.test.ts create mode 100644 src/gates/flag/with-flag.ts diff --git a/src/gates/flag/README.md b/src/gates/flag/README.md new file mode 100644 index 0000000..b673a70 --- /dev/null +++ b/src/gates/flag/README.md @@ -0,0 +1,84 @@ +# `@supabase/server/gates/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?". + +```ts +import { chain } from '@supabase/server/core/gates' +import { withFlag } from '@supabase/server/gates/flag' + +export default { + fetch: chain( + withFlag({ + name: 'beta-checkout', + evaluate: (req) => req.headers.get('x-beta') === '1', + }), + )(async (_req, ctx) => { + return Response.json({ feature: ctx.state.flag.name }) + }), +} +``` + +## Config + +| Field | Type | Description | +| -------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| `name` | `string` | Recorded in `ctx.state.flag.name` and the default rejection body. | +| `evaluate` | `(req) => boolean \| FlagVerdict \| 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 +withFlag({ + 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.state.flag.variant // 'a' | 'b' | 'control' | null +ctx.state.flag.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 `withFlag` _after_ `withSupabase` to target by user identity: + +```ts +withSupabase( + { allow: 'user' }, + chain( + withFlag({ + name: 'beta-checkout', + evaluate: async (_req) => { + // Cheap escape hatch: stash the user id on a header before chain runs, + // or read from a request-scoped store. For now, plug in an + // identity-aware provider: + return await posthog.isFeatureEnabled('beta-checkout', userId) + }, + }), + )(handler), +) +``` + +The current `evaluate` signature only sees the request — for user-aware flags, either pull the identity from a header `withSupabase` already validated (e.g. `req.headers.get('authorization')` to derive a stable id), or wait for a future enhancement that threads ctx into the evaluator. + +## Single namespace caveat + +The gate occupies `ctx.state.flag` — only one `withFlag` can compose into a chain 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 composition primitives](../../core/gates/README.md) diff --git a/src/gates/flag/index.ts b/src/gates/flag/index.ts new file mode 100644 index 0000000..06edb64 --- /dev/null +++ b/src/gates/flag/index.ts @@ -0,0 +1,8 @@ +/** + * Feature-flag gate. + * + * @packageDocumentation + */ + +export { withFlag } from './with-flag.js' +export type { FlagState, FlagVerdict, WithFlagConfig } from './with-flag.js' diff --git a/src/gates/flag/with-flag.test.ts b/src/gates/flag/with-flag.test.ts new file mode 100644 index 0000000..91de3f5 --- /dev/null +++ b/src/gates/flag/with-flag.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from 'vitest' + +import { chain } from '../../core/gates/index.js' +import { withFlag } from './with-flag.js' + +const innerOk = async () => Response.json({ ok: true }) + +describe('withFlag', () => { + it('admits when evaluate returns true and contributes the flag state', async () => { + const inner = vi.fn(async (_req: Request, ctx) => { + expect(ctx.state.flag).toEqual({ + name: 'beta', + enabled: true, + variant: null, + payload: null, + }) + return Response.json({ ok: true }) + }) + + const handler = chain( + withFlag({ + 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 = chain(withFlag({ 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 = chain( + withFlag({ + 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.state.flag.variant).toBe('green') + expect(ctx.state.flag.payload).toEqual({ rollout: 0.25 }) + return Response.json({ ok: true }) + }) + + const handler = chain( + withFlag({ + 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 = chain(withFlag({ 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 = chain( + withFlag({ + name: 'beta', + evaluate: async () => { + await new Promise((r) => setTimeout(r, 1)) + return { enabled: true, variant: 'a' } + }, + }), + )(async (_req, ctx) => Response.json({ variant: ctx.state.flag.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/flag/with-flag.ts b/src/gates/flag/with-flag.ts new file mode 100644 index 0000000..47a7271 --- /dev/null +++ b/src/gates/flag/with-flag.ts @@ -0,0 +1,122 @@ +/** + * Feature-flag gate. + * + * Evaluates a flag for the inbound request and either admits with the + * verdict at `ctx.state.flag` or short-circuits with a configurable response. + * Provider-agnostic — pass any `evaluate` function (PostHog, LaunchDarkly, + * Statsig, a header check, a database lookup). + */ + +import { defineGate } from '../../core/gates/index.js' + +export interface WithFlagConfig { + /** Human-readable name for the flag, recorded in `ctx.state.flag.name`. */ + name: string + + /** + * Evaluate the flag for the inbound request. Return `true` to admit, + * `false` to reject with a default 404. Return an object to record + * additional metadata (variant, payload) and admit; return + * `{ enabled: false, ... }` to reject with custom data. + */ + evaluate: ( + req: Request, + ) => Promise | boolean | FlagVerdict + + /** + * 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 +} + +/** + * Verdict shape that an `evaluate` function may return for richer state. + */ +export interface FlagVerdict { + enabled: boolean + /** A/B test variant if applicable. */ + variant?: string | null + /** Provider-specific payload (rollout %, targeting rules, etc.). */ + payload?: unknown +} + +/** Shape contributed at `ctx.state.flag` after a successful evaluation. */ +export interface FlagState { + name: string + enabled: true + variant: string | null + payload: unknown +} + +/** + * Feature-flag gate. + * + * @example + * ```ts + * import { chain } from '@supabase/server/core/gates' + * import { withFlag } from '@supabase/server/gates/flag' + * + * export default { + * fetch: chain( + * withFlag({ + * name: 'beta-checkout', + * evaluate: (req) => req.headers.get('x-beta') === '1', + * }), + * )(async (_req, ctx) => { + * return Response.json({ feature: ctx.state.flag.name }) + * }), + * } + * ``` + * + * Pluggable providers — use whatever you like in `evaluate`: + * + * ```ts + * withFlag({ + * name: 'beta-checkout', + * evaluate: async (req) => { + * const userId = req.headers.get('x-user-id') ?? 'anon' + * return await posthog.isFeatureEnabled('beta-checkout', userId) + * }, + * }) + * ``` + */ +export const withFlag = defineGate< + 'flag', + WithFlagConfig, + Record, + FlagState +>({ + namespace: 'flag', + run: (config) => async (req) => { + const result = await config.evaluate(req) + const verdict: FlagVerdict = + typeof result === 'boolean' ? { enabled: result } : result + + if (!verdict.enabled) { + return { + kind: 'reject', + response: Response.json( + config.rejectBody ?? { error: 'feature_disabled', flag: config.name }, + { status: config.rejectStatus ?? 404 }, + ), + } + } + + return { + kind: 'pass', + contribution: { + name: config.name, + enabled: true, + variant: verdict.variant ?? null, + payload: verdict.payload ?? null, + }, + } + }, +}) From 5e33c355167563b1f696e47b4a32b19e4e240473 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Sat, 2 May 2026 02:34:22 -0300 Subject: [PATCH 08/22] chore(gates): register flag, rate-limit, and webhook subpath exports --- README.md | 15 ++++++++++++--- jsr.json | 3 +++ package.json | 15 +++++++++++++++ tsdown.config.ts | 3 +++ 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f28a182..9c397bc 100644 --- a/README.md +++ b/README.md @@ -327,8 +327,11 @@ export default { `withSupabase` is the host wrapper, not a gate — it establishes `SupabaseContext` and hands it to whatever it wraps. Gates compose inside it (or stand alone). - [`@supabase/server/core/gates`](src/core/gates/README.md) — authoring primitives (`defineGate`, `chain`, `ctx` rules, prerequisite enforcement). -- [`@supabase/server/gates/cloudflare`](src/gates/cloudflare/README.md) — Cloudflare-issued credentials and headers (`withTurnstile`, more coming). -- [`@supabase/server/gates/x402`](src/gates/x402/README.md) — `withPayment`, the Stripe-facilitated x402 paywall gate. +- [`@supabase/server/gates/cloudflare`](src/gates/cloudflare/README.md) — `withTurnstile`, `withAccess`. +- [`@supabase/server/gates/flag`](src/gates/flag/README.md) — `withFlag`, provider-agnostic feature flag. +- [`@supabase/server/gates/rate-limit`](src/gates/rate-limit/README.md) — `withRateLimit`, fixed-window with pluggable store. +- [`@supabase/server/gates/webhook`](src/gates/webhook/README.md) — `withWebhook`, HMAC signature verification. +- [`@supabase/server/gates/x402`](src/gates/x402/README.md) — `withPayment`, Stripe-facilitated x402 paywall. ## Primitives @@ -469,7 +472,10 @@ For other environments, pass overrides via the `env` config option or `resolveEn | `@supabase/server/adapters/hono` | `withSupabase` (Hono middleware) | | `@supabase/server/adapters/h3` | `withSupabase` (H3 / Nuxt middleware) | | `@supabase/server/core/gates` | `chain`, `defineGate` (gate composition primitives) | -| `@supabase/server/gates/cloudflare` | `withTurnstile` (Cloudflare bot-check, Access, …) | +| `@supabase/server/gates/cloudflare` | `withTurnstile`, `withAccess` (Cloudflare bot-check + Zero Trust JWT) | +| `@supabase/server/gates/flag` | `withFlag` (provider-agnostic feature-flag gate) | +| `@supabase/server/gates/rate-limit` | `withRateLimit` (fixed-window rate limit; pluggable store) | +| `@supabase/server/gates/webhook` | `withWebhook` (HMAC signature verification, Stripe + custom) | | `@supabase/server/gates/x402` | `withPayment` (Stripe-facilitated x402 paywall gate) | ## Documentation @@ -487,6 +493,9 @@ For other environments, pass overrides via the `env` config option or `resolveEn | What's the complete API surface? | [`docs/api-reference.md`](docs/api-reference.md) | | How do I compose preconditions (gates) around a handler? | [`src/core/gates/README.md`](src/core/gates/README.md) | | How do I gate a route behind a Cloudflare check? | [`src/gates/cloudflare/README.md`](src/gates/cloudflare/README.md) | +| How do I gate a route behind a feature flag? | [`src/gates/flag/README.md`](src/gates/flag/README.md) | +| How do I rate-limit a route? | [`src/gates/rate-limit/README.md`](src/gates/rate-limit/README.md) | +| How do I verify webhook signatures? | [`src/gates/webhook/README.md`](src/gates/webhook/README.md) | | How do I charge per call with x402 + Stripe? | [`src/gates/x402/README.md`](src/gates/x402/README.md) | ## Development diff --git a/jsr.json b/jsr.json index 577ec71..c9eeeec 100644 --- a/jsr.json +++ b/jsr.json @@ -7,6 +7,9 @@ "./core/gates": "./src/core/gates/index.ts", "./adapters/hono": "./src/adapters/hono/index.ts", "./gates/cloudflare": "./src/gates/cloudflare/index.ts", + "./gates/flag": "./src/gates/flag/index.ts", + "./gates/rate-limit": "./src/gates/rate-limit/index.ts", + "./gates/webhook": "./src/gates/webhook/index.ts", "./gates/x402": "./src/gates/x402/index.ts" }, "publish": { diff --git a/package.json b/package.json index 75353c9..4d8358a 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,21 @@ "import": "./dist/gates/cloudflare/index.mjs", "require": "./dist/gates/cloudflare/index.cjs" }, + "./gates/flag": { + "types": "./dist/gates/flag/index.d.mts", + "import": "./dist/gates/flag/index.mjs", + "require": "./dist/gates/flag/index.cjs" + }, + "./gates/rate-limit": { + "types": "./dist/gates/rate-limit/index.d.mts", + "import": "./dist/gates/rate-limit/index.mjs", + "require": "./dist/gates/rate-limit/index.cjs" + }, + "./gates/webhook": { + "types": "./dist/gates/webhook/index.d.mts", + "import": "./dist/gates/webhook/index.mjs", + "require": "./dist/gates/webhook/index.cjs" + }, "./gates/x402": { "types": "./dist/gates/x402/index.d.mts", "import": "./dist/gates/x402/index.mjs", diff --git a/tsdown.config.ts b/tsdown.config.ts index 08cd8d4..e8cc5a2 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -8,6 +8,9 @@ export default defineConfig({ 'src/adapters/hono/index.ts', 'src/adapters/h3/index.ts', 'src/gates/cloudflare/index.ts', + 'src/gates/flag/index.ts', + 'src/gates/rate-limit/index.ts', + 'src/gates/webhook/index.ts', 'src/gates/x402/index.ts', ], format: ['esm', 'cjs'], From ac38951452588f9e59852fd274d48c019f41c5e3 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Sat, 2 May 2026 03:42:34 -0300 Subject: [PATCH 09/22] refactor(gates)!: replace chain composer with direct nesting --- README.md | 24 +- src/core/gates/README.md | 163 +++++++------ src/core/gates/chain.test.ts | 231 ------------------- src/core/gates/chain.ts | 73 ------ src/core/gates/define-gate.test.ts | 188 +++++++++++++++ src/core/gates/define-gate.ts | 178 +++++++++++--- src/core/gates/index.ts | 19 +- src/core/gates/types.ts | 86 +------ src/gates/cloudflare/README.md | 25 +- src/gates/cloudflare/with-access.test.ts | 21 +- src/gates/cloudflare/with-access.ts | 8 +- src/gates/cloudflare/with-turnstile.test.ts | 29 +-- src/gates/cloudflare/with-turnstile.ts | 8 +- src/gates/flag/README.md | 44 ++-- src/gates/flag/with-flag.test.ts | 47 ++-- src/gates/flag/with-flag.ts | 10 +- src/gates/rate-limit/README.md | 36 ++- src/gates/rate-limit/with-rate-limit.test.ts | 38 ++- src/gates/rate-limit/with-rate-limit.ts | 6 +- src/gates/webhook/README.md | 28 +-- src/gates/webhook/with-webhook.test.ts | 61 ++--- src/gates/webhook/with-webhook.ts | 10 +- src/gates/x402/README.md | 100 ++++---- src/gates/x402/with-payment.test.ts | 23 +- src/gates/x402/with-payment.ts | 8 +- 25 files changed, 690 insertions(+), 774 deletions(-) delete mode 100644 src/core/gates/chain.test.ts delete mode 100644 src/core/gates/chain.ts create mode 100644 src/core/gates/define-gate.test.ts diff --git a/README.md b/README.md index 9c397bc..582889d 100644 --- a/README.md +++ b/README.md @@ -304,29 +304,31 @@ The adapter does not handle CORS — use H3's CORS utilities for that. ## Gates -Compose preconditions around a handler. A **gate** runs against the inbound `Request`, either short-circuits with a `Response` or contributes typed data to `ctx.state[namespace]`. Stack them with `chain` to build per-route policy (paywalls, bot checks, rate limits, signed webhooks) without nesting wrappers by hand. +Compose preconditions around a handler. A **gate** runs against the inbound `Request`, either short-circuits with a `Response` or contributes typed data to a flat key on `ctx`. Each gate is a fetch-handler wrapper — nest them directly the same way `withSupabase` nests, no separate composer. ```ts +import type { SupabaseContext } from '@supabase/server' import { withSupabase } from '@supabase/server' -import { chain } from '@supabase/server/core/gates' import { withPayment } from '@supabase/server/gates/x402' export default { fetch: withSupabase( { allow: 'user' }, - chain(withPayment({ stripe, amountCents: 5 }))(async (req, ctx) => { - // ctx.supabase, ctx.userClaims — from withSupabase - // ctx.state.payment.intentId — from withPayment - // ctx.locals.foo = 'bar' — free per-request scratch - return Response.json({ paid: ctx.state.payment.intentId }) - }), + withPayment( + { stripe, amountCents: 5 }, + async (req, ctx) => { + // ctx.supabase, ctx.userClaims — from withSupabase + // ctx.payment.intentId — from withPayment + return Response.json({ paid: ctx.payment.intentId }) + }, + ), ), } ``` -`withSupabase` is the host wrapper, not a gate — it establishes `SupabaseContext` and hands it to whatever it wraps. Gates compose inside it (or stand alone). +`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). Pass `` to thread upstream keys into the gate handler's `ctx` type. -- [`@supabase/server/core/gates`](src/core/gates/README.md) — authoring primitives (`defineGate`, `chain`, `ctx` rules, prerequisite enforcement). +- [`@supabase/server/core/gates`](src/core/gates/README.md) — authoring primitives (`defineGate`, ctx rules, prerequisite enforcement, conflict detection). - [`@supabase/server/gates/cloudflare`](src/gates/cloudflare/README.md) — `withTurnstile`, `withAccess`. - [`@supabase/server/gates/flag`](src/gates/flag/README.md) — `withFlag`, provider-agnostic feature flag. - [`@supabase/server/gates/rate-limit`](src/gates/rate-limit/README.md) — `withRateLimit`, fixed-window with pluggable store. @@ -471,7 +473,7 @@ For other environments, pass overrides via the `env` config option or `resolveEn | `@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` | `chain`, `defineGate` (gate composition primitives) | +| `@supabase/server/core/gates` | `defineGate` (gate composition primitives) | | `@supabase/server/gates/cloudflare` | `withTurnstile`, `withAccess` (Cloudflare bot-check + Zero Trust JWT) | | `@supabase/server/gates/flag` | `withFlag` (provider-agnostic feature-flag gate) | | `@supabase/server/gates/rate-limit` | `withRateLimit` (fixed-window rate limit; pluggable store) | diff --git a/src/core/gates/README.md b/src/core/gates/README.md index c2720fe..d57c4b6 100644 --- a/src/core/gates/README.md +++ b/src/core/gates/README.md @@ -1,30 +1,33 @@ -# @supabase/server/core/gates +# `@supabase/server/core/gates` -Composable preconditions for fetch handlers. A **gate** is a small unit that runs against an inbound `Request` and either short-circuits with a `Response` or contributes typed data to `ctx.state[namespace]` for the handler. +Composable preconditions for fetch handlers. A **gate** is a small unit that runs against an inbound `Request` and either short-circuits with a `Response` or contributes typed data to a flat key on `ctx` for the handler. -This module exports two helpers: +This module exports: - **`defineGate`** — for _gate authors_ writing a new integration. -- **`chain`** — for _gate consumers_ composing gates into a fetch handler. -`withSupabase` is **not** a gate. It's a fetch-handler wrapper that establishes `SupabaseContext`. Gates compose _inside_ it (or standalone). +Gates compose by direct nesting — each `withFoo(config, handler)` is a fetch-handler wrapper, the same shape as `withSupabase`. No separate composer. ## Quick start (consumer) ```ts +import type { SupabaseContext } from '@supabase/server' import { withSupabase } from '@supabase/server' -import { chain } from '@supabase/server/core/gates' -import { withPayment } from '@supabase/server/gates/x402' +import { withFlag } from './gates/with-flag.ts' export default { fetch: withSupabase( { allow: 'user' }, - chain(withPayment({ stripe, amountCents: 5 }))(async (req, ctx) => { - // ctx.supabase, ctx.userClaims — from withSupabase - // ctx.state.payment.intentId — from withPayment - // ctx.locals.foo = 'bar' — free per-request scratch - return Response.json({ paid: ctx.state.payment.intentId }) - }), + withFlag( + { name: 'beta', evaluate: (req) => req.headers.has('x-beta') }, + async (req, ctx) => { + // ctx.supabase, ctx.userClaims — from withSupabase + // ctx.flag — from withFlag + if (!ctx.flag.enabled) + return new Response('not enabled', { status: 404 }) + return Response.json({ user: ctx.userClaims!.id }) + }, + ), ), } ``` @@ -33,38 +36,39 @@ Standalone (no `withSupabase`): ```ts export default { - fetch: chain(withPayment({ stripe, amountCents: 1 }))(async (req, ctx) => { - return Response.json({ paid: ctx.state.payment.intentId }) - }), + fetch: withFlag( + { name: 'beta', evaluate: (req) => req.headers.has('x-beta') }, + async (req, ctx) => Response.json({ enabled: ctx.flag.enabled }), + ), } ``` ## The `ctx` shape -Inside a chain handler: +Inside a gated handler, ctx is a flat intersection — each gate contributes a typed key: -| Path | Set by | Mutability | -| -------------------------------------- | ------------------------------ | ---------- | -| `ctx.supabase`, `ctx.userClaims`, etc. | `withSupabase` (when wrapping) | read-only | -| `ctx.state.` | gates via `chain` | read-only | -| `ctx.locals` | anyone (handler, helpers) | mutable | -| `ctx.foo` (top-level, anything else) | — | type error | +| Key | Set by | Mutability | +| ------------------------------------------------- | ------------------------------ | ----------------------- | +| `ctx.supabase`, `ctx.userClaims`, etc. | `withSupabase` (when wrapping) | read-only by convention | +| `ctx.` (e.g. `ctx.flag`, `ctx.payment`) | the corresponding gate | read-only by convention | -Three rules: +Two type-level guarantees: -- **`ctx.state` is gate-owned.** Each gate owns exactly one slot, named by its namespace. Slots are read-only from the handler's view. -- **`ctx.locals` is everyone-else's.** Per-request scratch space. `Record`. Mutate freely. -- **The top level is closed.** `withSupabase` populates the established host keys; everything else is a type error. Use `state` or `locals`. +- **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. Gates with `In` keys also require `baseCtx`, so they can't be the outermost handler unless wrapped. ## Authoring a gate (`defineGate`) -A gate has a _namespace_ (its slot under `ctx.state`), a _contribution shape_ (the typed value placed there), and a _run_ function. +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 { @@ -72,14 +76,20 @@ export interface FlagState { } export const withFlag = defineGate< - 'flag', // Namespace - FlagConfig, // Config (whatever the factory takes) - {}, // In: prerequisites from upstream ctx (none here) - FlagState // Contribution: shape under ctx.state.flag + 'flag', // Key + FlagConfig, // Config + {}, // In: no upstream prerequisites + FlagState // Contribution: shape under ctx.flag >({ - namespace: 'flag', + key: 'flag', run: (config) => async (req) => { - const enabled = req.headers.get(`x-flag-${config.name}`) === '1' + const enabled = config.evaluate(req) + if (!enabled) { + return { + kind: 'reject', + response: Response.json({ error: 'feature_disabled' }, { status: 404 }), + } + } return { kind: 'pass', contribution: { enabled } } }, }) @@ -88,9 +98,8 @@ export const withFlag = defineGate< Used as: ```ts -chain(withFlag({ name: 'beta' }))(async (req, ctx) => { - if (!ctx.state.flag.enabled) - return new Response('not enabled', { status: 404 }) +withFlag({ name: 'beta', evaluate: ... }, async (req, ctx) => { + if (!ctx.flag.enabled) return new Response('not enabled', { status: 404 }) return Response.json({ ok: true }) }) ``` @@ -106,13 +115,13 @@ type GateResult = | { kind: 'reject'; response: Response } ``` -The outer `(config) =>` is invoked once when the consumer constructs the gate (`withFlag({ name: 'beta' })`). Initialize per-instance state (stores, clients, computed config) here. The inner `(req, ctx) =>` is invoked per-request. +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 `{ kind: 'pass', contribution }` to admit the request and contribute typed state. Return `{ kind: 'reject', response }` to short-circuit the chain with a canonical 4xx response. +Return `{ kind: 'pass', contribution }` to admit the request and contribute typed state. Return `{ kind: 'reject', response }` to short-circuit with a canonical 4xx response. ### Declaring upstream prerequisites -A gate can require structural shape from the upstream ctx via `In`. For example, a gate that reads the authenticated user: +A gate that depends on upstream data declares it in `In`: ```ts import type { UserClaims } from '@supabase/server' @@ -120,11 +129,11 @@ import type { UserClaims } from '@supabase/server' export const withSubscription = defineGate< 'subscription', { lookup: (userId: string) => Promise }, - { userClaims: UserClaims }, // In: requires userClaims upstream + { userClaims: UserClaims | null }, // In: requires userClaims upstream { plan: Plan } >({ - namespace: 'subscription', - run: (config) => async (req, ctx) => { + key: 'subscription', + run: (config) => async (_req, ctx) => { if (!ctx.userClaims) { return { kind: 'reject', @@ -143,53 +152,55 @@ export const withSubscription = defineGate< }) ``` -A consumer using this gate must supply `userClaims` upstream — typically by wrapping the chain with `withSupabase`. Standalone use without `userClaims` won't compile. +A consumer using this gate must supply `userClaims` upstream — typically by wrapping with `withSupabase`. Standalone use without `userClaims` won't compile, and `baseCtx` becomes required (no optional `?`). -### Reserved namespaces +### Conflict detection -These names cannot be used as gate namespaces (would shadow the host or chain ctx structure): +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: -- `state`, `locals` -- `supabase`, `supabaseAdmin`, `userClaims`, `claims`, `authType`, `authKeyName` +```ts +withFoo({...}, withFoo({...}, handler)) // type error: Conflict<'foo'> is not callable +``` -`defineGate({ namespace: 'state', ... })` fails to typecheck. +Pick a different key for each gate. Gates that may be applied multiple times can accept a `key` config to override the default. -### Collisions +### Threading state through nested gates -Two gates declaring the same namespace fail to compile when composed by `chain`. The accumulated state type collapses to `never`, surfacing as a type error on the handler's `ctx.state` access. +When a gate is wrapped by another (e.g. `withSupabase(... withRateLimit(... handler))`), the outer's keys land on `Base` for the inner. TypeScript can't bidirectionally infer this from the outer call site, so the inner gate's `Base` must be passed explicitly to surface the upstream keys in the handler's `ctx` type: ```ts -chain( - withPayment({ ... }), - withPayment({ ... }), // duplicate namespace 'payment' -)(handler) // type error +import type { SupabaseContext } from '@supabase/server' + +withSupabase({ allow: 'user' }, + withRateLimit({ limit: 30, windowMs: 60_000, key: ... }, + async (_req, ctx) => { + // ctx.userClaims — from withSupabase + // ctx.rateLimit — from withRateLimit + return Response.json({ user: ctx.userClaims!.id }) + }, + ), +) ``` -Pick a different namespace for each gate. If you have two implementations of the same concept (e.g. two payment providers), name them by provider (`stripePayment`, `coinbasePayment`). - -### Reusing per-request state - -If a gate needs to share data with the handler beyond its primary contribution (e.g. a debugging blob, a transient cache key), write to `ctx.locals` from inside `run`: +For multi-gate stacks, intersect the accumulated types: ```ts -run: (config) => async (req, ctx) => { - ctx.locals.requestId ??= crypto.randomUUID() - // ... -} +type AfterRateLimit = SupabaseContext & { rateLimit: RateLimitState } + +withSupabase({ allow: 'user' }, + withRateLimit(..., + withFlag(..., handler), + ), +) ``` -`ctx.locals` is mutable and shared across all gates and the handler for that request. Don't put values that need _typed_ access there — those belong in your gate's contribution. +Without the explicit ``, the inner handler's `ctx` only types the gate's own key — runtime works, types narrow to that one gate's slice. ## API -| Export | Description | -| ----------------------------------- | ----------------------------------------------------------------------------------------- | -| `defineGate(spec)` | Author helper: declare a gate. Returns a `(config) => Gate` factory. | -| `chain(...gates)(handler)` | Consumer helper: compose gates and produce a `(req, baseCtx?) => Response` function. | -| `Gate` | The structural type of a gate. | -| `GateResult` | Discriminated union of `{ kind: 'pass', contribution }` / `{ kind: 'reject', response }`. | -| `ChainCtx` | The merged ctx type seen by a chain handler. | -| `AccumulatedState` | Type-level merge of all gates' contributions; resolves to `never` on collision. | -| `MergeStrict` | Strict object merge (`never` on key overlap). | -| `ValidNamespace` | Type-level guard: `never` for reserved or broad-`string` namespaces. | -| `ReservedNamespace` | Union of names that can't be gate namespaces. | +| Export | Description | +| -------------------------------------------- | --------------------------------------------------------------------------------------- | +| `defineGate(spec)` | Author helper: declare a gate. Returns a `(config, handler)` factory. | +| `GateResult` | Discriminated union: `{ kind: 'pass', contribution }` / `{ kind: 'reject', response }`. | +| `Conflict` | Sentinel string returned when a gate would shadow an upstream key. | +| `GateFactory` | The shape of a gate factory produced by `defineGate`. | diff --git a/src/core/gates/chain.test.ts b/src/core/gates/chain.test.ts deleted file mode 100644 index 5f63ab4..0000000 --- a/src/core/gates/chain.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -import { withSupabase } from '../../with-supabase.js' -import { chain } from './chain.js' -import { defineGate } from './define-gate.js' -import type { Gate } from './types.js' - -const baseEnv = { - url: 'https://test.supabase.co', - publishableKeys: { default: 'sb_publishable_xyz' }, - secretKeys: { default: 'sb_secret_xyz' }, - jwks: null, -} - -const passingGate = (namespace: N, contribution: C) => - defineGate, C>({ - namespace: namespace as never, - run: () => async () => ({ kind: 'pass', contribution }), - })(undefined) - -const rejectingGate = (namespace: N, status = 401) => - defineGate, Record>({ - namespace: namespace as never, - run: () => async () => ({ - kind: 'reject', - response: new Response(`rejected by ${namespace}`, { status }), - }), - })(undefined) - -describe('chain', () => { - it('runs gates in order and passes contributions to ctx.state', async () => { - const gateA = passingGate('alpha', { a: 1 }) - const gateB = passingGate('beta', { b: 2 }) - - const handler = vi.fn( - async ( - _req: Request, - ctx: { state: { alpha: { a: number }; beta: { b: number } } }, - ) => { - expect(ctx.state.alpha).toEqual({ a: 1 }) - expect(ctx.state.beta).toEqual({ b: 2 }) - return Response.json({ ok: true }) - }, - ) - - const fetchHandler = chain(gateA, gateB)(handler) - const res = await fetchHandler(new Request('http://localhost/')) - - expect(res.status).toBe(200) - expect(handler).toHaveBeenCalledOnce() - }) - - it('short-circuits on the first rejecting gate without running downstream', async () => { - const downstreamRun = vi.fn() - const downstream: Gate< - Record, - 'downstream', - Record - > = { - namespace: 'downstream', - run: async (...args) => { - downstreamRun(...args) - return { kind: 'pass', contribution: {} } - }, - } - - const handler = vi.fn(async () => Response.json({ ok: true })) - - const fetchHandler = chain( - rejectingGate('blocker', 402), - downstream, - )(handler) - const res = await fetchHandler(new Request('http://localhost/')) - - expect(res.status).toBe(402) - expect(await res.text()).toBe('rejected by blocker') - expect(downstreamRun).not.toHaveBeenCalled() - expect(handler).not.toHaveBeenCalled() - }) - - it('exposes a mutable ctx.locals to gates and the handler', async () => { - const stamping: Gate< - { locals: Record }, - 'stamping', - { stamped: boolean } - > = { - namespace: 'stamping', - run: async (_req, ctx) => { - ctx.locals.stampedAt = 123 - return { kind: 'pass', contribution: { stamped: true } } - }, - } - - const handler = vi.fn( - async ( - _req: Request, - ctx: { - state: { stamping: { stamped: boolean } } - locals: Record - }, - ) => { - expect(ctx.locals.stampedAt).toBe(123) - ctx.locals.handlerWrote = 'yep' - return Response.json({ locals: ctx.locals }) - }, - ) - - const fetchHandler = chain(stamping)(handler) - const res = await fetchHandler(new Request('http://localhost/')) - - expect(res.status).toBe(200) - const body = (await res.json()) as { locals: Record } - expect(body.locals).toEqual({ stampedAt: 123, handlerWrote: 'yep' }) - }) - - it('isolates ctx.locals between requests', async () => { - const fetchHandler = chain(passingGate('marker', { v: 1 }))(async ( - _req, - ctx, - ) => { - const seen = ctx.locals.seen ?? false - ctx.locals.seen = true - return Response.json({ seen }) - }) - - const r1 = await fetchHandler(new Request('http://localhost/')) - const r2 = await fetchHandler(new Request('http://localhost/')) - - expect(await r1.json()).toEqual({ seen: false }) - expect(await r2.json()).toEqual({ seen: false }) - }) - - it('merges baseCtx into the handler ctx when supplied', async () => { - const handler = vi.fn(async (_req: Request, ctx) => { - expect(ctx.tenantId).toBe('acme') - expect(ctx.state.ping).toEqual({ pong: true }) - return Response.json({ ok: true }) - }) - - const composed = chain(passingGate('ping', { pong: true as const }))<{ - tenantId: string - }>(handler) - const res = await composed(new Request('http://localhost/'), { - tenantId: 'acme', - }) - - expect(res.status).toBe(200) - }) - - it('composes inside withSupabase, threading the SupabaseContext as baseCtx', async () => { - const inner = vi.fn(async (_req: Request, ctx) => { - // ctx has supabase fields (from withSupabase) AND state/locals (from chain) - expect(ctx.authType).toBe('always') - expect(ctx.state.ping).toEqual({ pong: true }) - expect(ctx.locals).toEqual({}) - return Response.json({ ok: true }) - }) - - const fetchHandler = withSupabase( - { allow: 'always', env: baseEnv, cors: false }, - chain(passingGate('ping', { pong: true as const }))(inner), - ) - - const res = await fetchHandler(new Request('http://localhost/')) - expect(res.status).toBe(200) - expect(inner).toHaveBeenCalledOnce() - }) - - it('runs with no gates and an empty state', async () => { - const handler = vi.fn( - async ( - _req: Request, - ctx: { - state: Record - locals: Record - }, - ) => { - expect(ctx.state).toEqual({}) - expect(ctx.locals).toEqual({}) - return Response.json({ ok: true }) - }, - ) - - const fetchHandler = chain()(handler) - await fetchHandler(new Request('http://localhost/')) - - expect(handler).toHaveBeenCalledOnce() - }) -}) - -describe('defineGate', () => { - it('produces a gate factory that closes over its config', async () => { - const withGreeting = defineGate< - 'greeting', - { who: string }, - Record, - { hello: string } - >({ - namespace: 'greeting', - run: (config) => async () => ({ - kind: 'pass', - contribution: { hello: config.who }, - }), - }) - - const fetchHandler = chain(withGreeting({ who: 'world' }))( - async (_req, ctx) => Response.json({ msg: ctx.state.greeting.hello }), - ) - - const res = await fetchHandler(new Request('http://localhost/')) - expect(await res.json()).toEqual({ msg: 'world' }) - }) - - it('rejects reserved namespace names at the type level', () => { - // Type-level assertion: passing a reserved literal to defineGate fails - // ValidNamespace's check, so the namespace property's expected type is - // `never` and the literal can't be assigned. The @ts-expect-error - // directives below document and lock in that intent. - defineGate({ - // @ts-expect-error — 'state' is reserved - namespace: 'state', - run: () => async () => ({ kind: 'pass', contribution: {} }), - }) - - defineGate({ - // @ts-expect-error — 'supabase' is a host key; reserved - namespace: 'supabase', - run: () => async () => ({ kind: 'pass', contribution: {} }), - }) - }) -}) diff --git a/src/core/gates/chain.ts b/src/core/gates/chain.ts deleted file mode 100644 index fa2567c..0000000 --- a/src/core/gates/chain.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { AccumulatedState, ChainCtx, Gate } from './types.js' - -/** - * Composes a tuple of gates into a function that runs them in order against - * an inbound `Request`, then invokes the supplied handler with a context - * containing each gate's contribution under `ctx.state[namespace]`. - * - * The returned function accepts an optional `baseCtx`. When invoked - * standalone (e.g. as a fetch handler), `baseCtx` defaults to `{}`. When - * invoked from inside `withSupabase`, the `SupabaseContext` is the baseCtx - * and the chain handler sees `SupabaseContext & { state, locals }`. - * - * Type-level guarantees: - * - **Collision detection**: two gates with the same namespace cause the - * accumulated state type to collapse to `never`, surfacing as a type error - * on the handler's `ctx.state` access. - * - **Reserved namespaces**: gates using a reserved name (`state`, `locals`, - * or any `withSupabase` host key) fail at `defineGate` time. - * - * Runtime behaviour: - * - Gates run sequentially; the first to return `{ kind: 'reject', response }` - * short-circuits the chain. - * - Each pass-result's `contribution` is written to `ctx.state[gate.namespace]`. - * - `ctx.locals` is initialized to an empty object that gates and the handler - * may mutate freely. - * - * @example - * ```ts - * import { withSupabase } from '@supabase/server' - * import { chain } from '@supabase/server/core/gates' - * import { withPayment } from '@supabase/server/gates' - * - * export default { - * fetch: withSupabase( - * { allow: 'user' }, - * chain(withPayment({ stripe, amountCents: 5 }))(async (req, ctx) => { - * // ctx.supabase, ctx.userClaims — from withSupabase - * // ctx.state.payment.intentId — from withPayment - * // ctx.locals.foo = 'bar' — free scratch - * return Response.json({ paid: ctx.state.payment.intentId }) - * }), - * ), - * } - * ``` - */ -export function chain[]>( - ...gates: G -) { - return >( - handler: ( - req: Request, - ctx: ChainCtx>, - ) => Promise, - ): ((req: Request, baseCtx?: Base) => Promise) => { - return async (req, baseCtx) => { - const state: Record = {} - const locals: Record = {} - const ctx = { - ...((baseCtx ?? {}) as Base), - state, - locals, - } as ChainCtx> - - for (const gate of gates) { - const result = await gate.run(req, ctx as never) - if (result.kind === 'reject') return result.response - state[gate.namespace] = result.contribution - } - - return handler(req, ctx) - } - } -} diff --git a/src/core/gates/define-gate.test.ts b/src/core/gates/define-gate.test.ts new file mode 100644 index 0000000..74c138c --- /dev/null +++ b/src/core/gates/define-gate.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it, vi } from 'vitest' + +import { defineGate } from './define-gate.js' + +const innerOk = async () => Response.json({ ok: true }) + +const passingGate = (key: Key, contribution: C) => + defineGate, C>({ + key, + run: () => async () => ({ kind: 'pass', contribution }), + }) + +const rejectingGate = (key: Key, status = 401) => + defineGate, Record>({ + key, + run: () => async () => ({ + kind: 'reject', + response: 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 () => ({ + kind: 'pass', + contribution: { 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 }) + + // Calling the gate with a Base that already includes 'foo' returns a + // `Conflict<'foo'>` sentinel string instead of a fetch handler. The error + // surfaces when the result is used in a function position. + const conflicted = withFoo<{ foo: { v: number } }>(undefined, async () => + Response.json({ ok: true }), + ) + + // @ts-expect-error — Conflict string is not assignable to a fetch handler + const _fn: (req: Request) => Promise = conflicted + void _fn + }) + + 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 { + kind: 'pass', + contribution: { 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 { + kind: 'reject', + response: Response.json( + { error: 'tenant_forbidden' }, + { status: 403 }, + ), + } + } + return { kind: 'pass', contribution: { 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 }) + }) +}) diff --git a/src/core/gates/define-gate.ts b/src/core/gates/define-gate.ts index 35887f6..65ae68b 100644 --- a/src/core/gates/define-gate.ts +++ b/src/core/gates/define-gate.ts @@ -1,52 +1,176 @@ -import type { Gate, GateResult, ValidNamespace } from './types.js' +import type { Conflict, GateResult } from './types.js' /** - * Defines a gate. Returns a config-taking factory function that produces a - * {@link Gate} value, suitable for use with {@link chain}. + * Defines a gate. * * A gate is a small unit that runs against an inbound `Request` and the * upstream context. It either short-circuits with a `Response` (rejection) or - * contributes a typed value at `ctx.state[namespace]` (pass). + * contributes a typed value at `ctx[key]` (pass), then calls the inner + * handler with the merged context. * - * @typeParam Namespace - Literal string used as the slot under `ctx.state`. - * Cannot be a reserved name (see {@link ReservedNamespace}). - * @typeParam Config - The configuration object the factory accepts. - * @typeParam In - Structural shape the gate requires from the upstream ctx. - * Defaults to `{}` (no prerequisites). - * @typeParam Contribution - Shape of the value placed at `ctx.state[Namespace]`. + * The returned factory has the shape `withFoo(config, handler) → fetchHandler`, + * so gates nest the same way `withSupabase` does — no separate composer. * - * @example + * 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 factory 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]`. + * + * @example No prerequisites: * ```ts * import { defineGate } from '@supabase/server/core/gates' * - * export const withFlag = defineGate({ - * namespace: 'flag', - * run: (config: { name: string }) => async (req) => { - * const enabled = req.headers.get(`x-flag-${config.name}`) === '1' - * return { kind: 'pass', contribution: { name: config.name, enabled } } + * export const withFlag = defineGate< + * 'flag', + * { name: string; evaluate: (req: Request) => boolean }, + * {}, + * { name: string; enabled: true } + * >({ + * key: 'flag', + * run: (config) => async (req) => { + * if (!config.evaluate(req)) { + * return { + * kind: 'reject', + * response: Response.json({ error: 'feature_disabled' }, { status: 404 }), + * } + * } + * return { kind: 'pass', contribution: { name: config.name, enabled: true } } * }, * }) * - * // Consumer: - * chain(withFlag({ name: 'beta' }))(async (req, ctx) => { - * if (!ctx.state.flag.enabled) return new Response('not enabled', { status: 404 }) - * return Response.json({ ok: true }) + * // Standalone: + * withFlag({ name: 'beta', evaluate: ... }, async (req, ctx) => { + * return Response.json({ flag: ctx.flag.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 { + * kind: 'reject', + * response: Response.json({ error: 'forbidden' }, { status: 403 }), + * } + * } + * return { kind: 'pass', contribution: { allowed } } + * }, + * }) + * + * // Composes only inside `withSupabase` (or a wrapper that provides those keys): + * withSupabase({ allow: 'user' }, + * withReportAccess({ reportId: 'r1' }, async (req, ctx) => { + * ctx.supabase // from withSupabase + * ctx.userClaims // from withSupabase + * ctx.reportAccess // from withReportAccess + * }) + * ) + * ``` */ export function defineGate< - const Namespace extends string, + const Key extends string, Config, In extends object = Record, Contribution = unknown, >(spec: { - namespace: ValidNamespace + key: Key run: ( config: Config, ) => (req: Request, ctx: In) => Promise> -}): (config: Config) => Gate { - return (config) => ({ - namespace: spec.namespace as Namespace, - run: spec.run(config), - }) +}): GateFactory { + 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.kind === 'reject') return result.response + const ctx = { ...upstream, [spec.key]: result.contribution } + return ( + handler as unknown as (req: Request, ctx: object) => Promise + )(req, ctx) + } + }) as GateFactory +} + +/** + * The factory shape 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. Required `baseCtx` for gates with + * prerequisites, optional otherwise. + */ +type Wrapped = keyof In extends never + ? (req: Request, baseCtx?: Base) => Promise + : (req: Request, baseCtx: Base) => Promise + +/** + * Result of calling a gate factory: either the wrapped handler (no conflict), + * or a `Conflict` sentinel string (key already on `Base`). The sentinel + * surfaces at the *use site* of the returned value — when it's passed as a + * handler to an outer wrapper that expected a function, TypeScript reports + * "Type '…' is not assignable to type 'gate-conflict: …'", citing the literal + * conflict message. + * + * `any` Base (common in tests via `vi.fn` inference) skips conflict detection + * because `keyof any` would false-positive every key. + */ +type FactoryReturn = + IsAny extends true + ? Wrapped + : Key extends keyof Base + ? Conflict + : Wrapped + +export interface GateFactory< + Key extends string, + Config, + In extends object, + Contribution, +> { + ( + config: Config, + handler: ( + req: Request, + ctx: Base & { [K in Key]: Contribution }, + ) => Promise, + ): FactoryReturn } diff --git a/src/core/gates/index.ts b/src/core/gates/index.ts index 21d88db..b67099c 100644 --- a/src/core/gates/index.ts +++ b/src/core/gates/index.ts @@ -2,20 +2,15 @@ * Gate composition primitives. * * - {@link defineGate} — author-facing helper for declaring a gate. - * - {@link chain} — consumer-facing composer that turns a tuple of gates - * into a fetch-handler-shaped function. + * + * 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 { chain } from './chain.js' export { defineGate } from './define-gate.js' -export type { - AccumulatedState, - ChainCtx, - Gate, - GateResult, - MergeStrict, - ReservedNamespace, - ValidNamespace, -} from './types.js' +export type { GateFactory } from './define-gate.js' +export type { Conflict, GateResult } from './types.js' diff --git a/src/core/gates/types.ts b/src/core/gates/types.ts index fab3967..0d7f214 100644 --- a/src/core/gates/types.ts +++ b/src/core/gates/types.ts @@ -6,90 +6,18 @@ /** * The result a gate's `run` function returns: either a successful contribution - * to be merged into `ctx.state[namespace]`, or a `Response` that short-circuits - * the chain. + * to be merged into `ctx[key]`, or a `Response` that short-circuits. */ export type GateResult = | { kind: 'pass'; contribution: Contribution } | { kind: 'reject'; response: Response } /** - * A gate is a value with a namespace and a `run` function. The chain composer - * runs gates in order, threading their contributions into `ctx.state[namespace]`. + * 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. * - * Authors create gates via {@link defineGate}; consumers compose them via - * {@link chain}. - * - * @typeParam In - Structural shape the gate requires from the upstream ctx - * (e.g. `{ userClaims: UserClaims | null }` for a gate that reads auth). - * Use `{}` if the gate has no prerequisites. - * @typeParam Namespace - Literal string key under `ctx.state` where the - * contribution lives. - * @typeParam Contribution - Shape of the value placed at `ctx.state[Namespace]`. - */ -export interface Gate { - readonly namespace: Namespace - readonly run: (req: Request, ctx: In) => Promise> -} - -/** - * Names that gates cannot use as their namespace, because they're either - * reserved for the chain ctx structure (`state`, `locals`) or claimed by the - * `withSupabase` host context. - */ -export type ReservedNamespace = - | 'state' - | 'locals' - | 'supabase' - | 'supabaseAdmin' - | 'userClaims' - | 'claims' - | 'authType' - | 'authKeyName' - -/** - * Compile-time guard: resolves to the literal namespace if it's allowed, - * `never` otherwise. Use as the type of `defineGate`'s `namespace` field - * to surface invalid choices as type errors. - */ -export type ValidNamespace = string extends N - ? never - : N extends ReservedNamespace - ? never - : N - -/** - * Strict object merge that collapses to `never` when the operands share any - * keys. Used by `AccumulatedState` to surface namespace collisions as type - * errors at chain composition time. - */ -export type MergeStrict = keyof A & keyof B extends never ? A & B : never - -/** - * Accumulates the state contributions of a tuple of gates into a single - * object type, with `MergeStrict` collision detection: if two gates declare - * the same namespace, the result is `never` and the chain fails to compile. - */ -export type AccumulatedState< - G extends readonly Gate[], -> = G extends readonly [ - infer First, - ...infer Rest extends readonly Gate[], -] - ? // eslint-disable-next-line @typescript-eslint/no-unused-vars - First extends Gate - ? MergeStrict<{ [K in N]: C }, AccumulatedState> - : never - : Record - -/** - * The shape of `ctx` seen by a chain handler: - * - whatever the upstream `Base` provided (e.g. `SupabaseContext` when wrapped - * by `withSupabase`, or `{}` standalone), - * - plus a `state` object whose slots are the gates' contributions (read-only), - * - plus a `locals` mutable scratch object. + * 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 ChainCtx = Base & { - readonly state: Readonly - locals: Record -} +export type Conflict = + `gate-conflict: key '${Key}' is already present on the upstream context` diff --git a/src/gates/cloudflare/README.md b/src/gates/cloudflare/README.md index 09e32a6..3243a34 100644 --- a/src/gates/cloudflare/README.md +++ b/src/gates/cloudflare/README.md @@ -1,20 +1,19 @@ # Cloudflare gates -Gates that integrate with Cloudflare-issued credentials, headers, and APIs. Compose with [`chain`](../../core/gates/README.md) from `@supabase/server/core/gates`. +Gates that integrate with Cloudflare-issued credentials, headers, and APIs. Each is a fetch-handler wrapper; nest them directly the way `withSupabase` does (see [the gate composition primitives](../../core/gates/README.md)). ```ts -import { chain } from '@supabase/server/core/gates' import { withTurnstile } from '@supabase/server/gates/cloudflare' export default { - fetch: chain( - withTurnstile({ + fetch: withTurnstile( + { secretKey: process.env.TURNSTILE_SECRET_KEY!, expectedAction: 'login', - }), - )(async (req, ctx) => { - return Response.json({ ok: true, hostname: ctx.state.turnstile.hostname }) - }), + }, + async (req, ctx) => + Response.json({ ok: true, hostname: ctx.turnstile.hostname }), + ), } ``` @@ -29,7 +28,7 @@ More gates (geofencing, bot management) are planned — see the package roadmap. ## `withTurnstile` -Verifies the `cf-turnstile-response` token a client widget produces against Cloudflare's siteverify endpoint. On success, contributes the verified challenge metadata to `ctx.state.turnstile`. On failure, short-circuits with a 401 (or 503 if siteverify is unreachable). +Verifies the `cf-turnstile-response` token a client widget produces against Cloudflare's siteverify endpoint. On success, contributes the verified challenge metadata to `ctx.turnstile`. On failure, short-circuits with a 401 (or 503 if siteverify is unreachable). ### Config @@ -45,7 +44,7 @@ withTurnstile({ ### Contribution ```ts -ctx.state.turnstile = { +ctx.turnstile = { challengeTs: string // ISO 8601 timestamp the challenge was solved hostname: string // hostname of the page the widget rendered on action: string // the widget's action label @@ -88,7 +87,7 @@ If `cf-connecting-ip` is present on the request, it's forwarded to siteverify as ## `withAccess` -Validates the `Cf-Access-Jwt-Assertion` header that Cloudflare attaches to every request to an Access-protected origin. Verifies the signature against your team's JWKS and checks that the `aud` claim matches your application's audience tag. On success, contributes the verified identity at `ctx.state.access`. +Validates the `Cf-Access-Jwt-Assertion` header that Cloudflare attaches to every request to an Access-protected origin. Verifies the signature against your team's JWKS and checks that the `aud` claim matches your application's audience tag. On success, contributes the verified identity at `ctx.access`. ### Config @@ -102,7 +101,7 @@ withAccess({ ### Contribution ```ts -ctx.state.access = { +ctx.access = { email: string | null sub: string // Cloudflare's stable identity id identityNonce: string | null @@ -124,6 +123,6 @@ For backend services behind a Cloudflare tunnel + Access policy. Cloudflare auth ## See also -- [Gate composition primitives](../../core/gates/README.md) — `chain`, `defineGate`, ctx shape +- [Gate composition primitives](../../core/gates/README.md) — `defineGate`, ctx shape, prereqs - [Turnstile docs](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/) - [Access JWT validation](https://developers.cloudflare.com/cloudflare-one/identity/authorization-cookie/validating-json/) diff --git a/src/gates/cloudflare/with-access.test.ts b/src/gates/cloudflare/with-access.test.ts index 6cbfb13..80e4ddf 100644 --- a/src/gates/cloudflare/with-access.test.ts +++ b/src/gates/cloudflare/with-access.test.ts @@ -1,7 +1,6 @@ import { exportJWK, generateKeyPair, SignJWT, type KeyObject } from 'jose' import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' -import { chain } from '../../core/gates/index.js' import { withAccess } from './with-access.js' const TEAM_DOMAIN = 'acme.cloudflareaccess.com' @@ -51,7 +50,7 @@ const innerOk = async () => Response.json({ ok: true }) describe('withAccess', () => { it('rejects when the assertion header is missing', async () => { - const handler = chain(withAccess(baseConfig))(innerOk) + const handler = withAccess(baseConfig, innerOk) const res = await handler(new Request('http://localhost/')) @@ -59,19 +58,19 @@ describe('withAccess', () => { expect(await res.json()).toEqual({ error: 'access_token_missing' }) }) - it('admits a valid token and contributes identity to ctx.state.access', async () => { + it('admits a valid token and contributes identity to ctx.access', async () => { const token = await sign() const inner = vi.fn(async (_req: Request, ctx) => { - expect(ctx.state.access.email).toBe('user@example.com') - expect(ctx.state.access.sub).toBe('user-123') - expect(ctx.state.access.identityNonce).toBe('nonce-abc') - expect(ctx.state.access.audience).toBe(AUDIENCE) - expect(ctx.state.access.claims.iss).toBe(ISSUER) + expect(ctx.access.email).toBe('user@example.com') + expect(ctx.access.sub).toBe('user-123') + expect(ctx.access.identityNonce).toBe('nonce-abc') + expect(ctx.access.audience).toBe(AUDIENCE) + expect(ctx.access.claims.iss).toBe(ISSUER) return Response.json({ ok: true }) }) - const handler = chain(withAccess(baseConfig))(inner) + const handler = withAccess(baseConfig, inner) const res = await handler( new Request('http://localhost/', { @@ -86,7 +85,7 @@ describe('withAccess', () => { it('rejects a token with the wrong audience', async () => { const token = await sign({ aud: 'someone-elses-audience' }) - const handler = chain(withAccess(baseConfig))(innerOk) + const handler = withAccess(baseConfig, innerOk) const res = await handler( new Request('http://localhost/', { @@ -100,7 +99,7 @@ describe('withAccess', () => { }) it('rejects a malformed assertion', async () => { - const handler = chain(withAccess(baseConfig))(innerOk) + const handler = withAccess(baseConfig, innerOk) const res = await handler( new Request('http://localhost/', { diff --git a/src/gates/cloudflare/with-access.ts b/src/gates/cloudflare/with-access.ts index f3e5837..98e32bc 100644 --- a/src/gates/cloudflare/with-access.ts +++ b/src/gates/cloudflare/with-access.ts @@ -3,7 +3,7 @@ * * Validates the `Cf-Access-Jwt-Assertion` header against the team's JWKS, * checks the audience tag binding, and contributes the identity claims to - * `ctx.state.access`. + * `ctx.access`. * * Use this for backend services that sit behind a Cloudflare tunnel + Access * policy — every request is signed by Cloudflare on the way in. @@ -41,7 +41,7 @@ export interface WithAccessConfig { jwksUrl?: string } -/** Shape contributed at `ctx.state.access` after a successful verification. */ +/** Shape contributed at `ctx.access` after a successful verification. */ export interface AccessState { /** The user's email address from the verified token. */ email: string | null @@ -70,7 +70,7 @@ export interface AccessState { * audience: process.env.CF_ACCESS_AUD!, * }), * )(async (req, ctx) => { - * return Response.json({ user: ctx.state.access.email }) + * return Response.json({ user: ctx.access.email }) * }), * } * ``` @@ -81,7 +81,7 @@ export const withAccess = defineGate< Record, AccessState >({ - namespace: 'access', + key: 'access', run: (config) => { const issuer = `https://${config.teamDomain}` const jwksUrl = config.jwksUrl ?? `${issuer}/cdn-cgi/access/certs` diff --git a/src/gates/cloudflare/with-turnstile.test.ts b/src/gates/cloudflare/with-turnstile.test.ts index bc580d3..2a09ec7 100644 --- a/src/gates/cloudflare/with-turnstile.test.ts +++ b/src/gates/cloudflare/with-turnstile.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { chain } from '../../core/gates/index.js' import { withTurnstile } from './with-turnstile.js' const SITEVERIFY = 'https://verify.test/turnstile' @@ -29,7 +28,7 @@ const innerOk = async () => Response.json({ ok: true }) describe('withTurnstile', () => { it('rejects when no token is present', async () => { - const handler = chain(withTurnstile(baseConfig))(innerOk) + const handler = withTurnstile(baseConfig, innerOk) const res = await handler(new Request('http://localhost/')) @@ -44,7 +43,7 @@ describe('withTurnstile', () => { ) const inner = vi.fn(async (_req: Request, ctx) => { - expect(ctx.state.turnstile).toEqual({ + expect(ctx.turnstile).toEqual({ challengeTs: '2026-01-01T00:00:00Z', hostname: 'app.example.com', action: 'login', @@ -53,7 +52,7 @@ describe('withTurnstile', () => { return Response.json({ ok: true }) }) - const handler = chain(withTurnstile(baseConfig))(inner) + const handler = withTurnstile(baseConfig, inner) const res = await handler( new Request('http://localhost/', { @@ -83,7 +82,7 @@ describe('withTurnstile', () => { ), ) - const handler = chain(withTurnstile(baseConfig))(innerOk) + const handler = withTurnstile(baseConfig, innerOk) const res = await handler( new Request('http://localhost/', { @@ -103,9 +102,10 @@ describe('withTurnstile', () => { new Response(JSON.stringify({ ...okBody, action: 'signup' })), ) - const handler = chain( - withTurnstile({ ...baseConfig, expectedAction: 'login' }), - )(innerOk) + const handler = withTurnstile( + { ...baseConfig, expectedAction: 'login' }, + innerOk, + ) const res = await handler( new Request('http://localhost/', { @@ -126,7 +126,7 @@ describe('withTurnstile', () => { new Response('upstream error', { status: 502 }), ) - const handler = chain(withTurnstile(baseConfig))(innerOk) + const handler = withTurnstile(baseConfig, innerOk) const res = await handler( new Request('http://localhost/', { @@ -140,7 +140,7 @@ describe('withTurnstile', () => { it('forwards remoteip when cf-connecting-ip is present', async () => { fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(okBody))) - const handler = chain(withTurnstile(baseConfig))(innerOk) + const handler = withTurnstile(baseConfig, innerOk) await handler( new Request('http://localhost/', { @@ -158,12 +158,13 @@ describe('withTurnstile', () => { it('honors a custom getToken extractor', async () => { fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(okBody))) - const handler = chain( - withTurnstile({ + const handler = withTurnstile( + { ...baseConfig, getToken: (req) => new URL(req.url).searchParams.get('captcha'), - }), - )(innerOk) + }, + innerOk, + ) const res = await handler( new Request('http://localhost/?captcha=tok_query'), diff --git a/src/gates/cloudflare/with-turnstile.ts b/src/gates/cloudflare/with-turnstile.ts index da12a19..42c677c 100644 --- a/src/gates/cloudflare/with-turnstile.ts +++ b/src/gates/cloudflare/with-turnstile.ts @@ -3,7 +3,7 @@ * * Verifies the `cf-turnstile-response` token a client widget produced against * Cloudflare's siteverify endpoint, then either short-circuits with a 401 or - * contributes the verified challenge metadata to `ctx.state.turnstile`. + * contributes the verified challenge metadata to `ctx.turnstile`. * * @see https://developers.cloudflare.com/turnstile/get-started/server-side-validation/ */ @@ -47,7 +47,7 @@ export interface WithTurnstileConfig { } /** - * Shape contributed at `ctx.state.turnstile` after a successful verification. + * Shape contributed at `ctx.turnstile` after a successful verification. */ export interface TurnstileState { /** ISO 8601 timestamp when the challenge was solved. */ @@ -84,7 +84,7 @@ interface SiteverifyResponse { * expectedAction: 'login', * }), * )(async (req, ctx) => { - * return Response.json({ ok: true, action: ctx.state.turnstile.action }) + * return Response.json({ ok: true, action: ctx.turnstile.action }) * }), * } * ``` @@ -95,7 +95,7 @@ export const withTurnstile = defineGate< Record, TurnstileState >({ - namespace: 'turnstile', + key: 'turnstile', run: (config) => { const url = config.siteverifyUrl ?? SITEVERIFY_URL const getToken = config.getToken ?? defaultGetToken diff --git a/src/gates/flag/README.md b/src/gates/flag/README.md index b673a70..8f7c4e3 100644 --- a/src/gates/flag/README.md +++ b/src/gates/flag/README.md @@ -3,18 +3,16 @@ 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?". ```ts -import { chain } from '@supabase/server/core/gates' import { withFlag } from '@supabase/server/gates/flag' export default { - fetch: chain( - withFlag({ + fetch: withFlag( + { name: 'beta-checkout', evaluate: (req) => req.headers.get('x-beta') === '1', - }), - )(async (_req, ctx) => { - return Response.json({ feature: ctx.state.flag.name }) - }), + }, + async (_req, ctx) => Response.json({ feature: ctx.flag.name }), + ), } ``` @@ -22,7 +20,7 @@ export default { | Field | Type | Description | | -------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | -| `name` | `string` | Recorded in `ctx.state.flag.name` and the default rejection body. | +| `name` | `string` | Recorded in `ctx.flag.name` and the default rejection body. | | `evaluate` | `(req) => boolean \| FlagVerdict \| 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: }`. | @@ -44,8 +42,8 @@ withFlag({ Then the handler reads: ```ts -ctx.state.flag.variant // 'a' | 'b' | 'control' | null -ctx.state.flag.payload // anything you returned +ctx.flag.variant // 'a' | 'b' | 'control' | null +ctx.flag.payload // anything you returned ``` ## Why 404 by default @@ -57,27 +55,33 @@ Soft reveal. A `403 Forbidden` tells the caller "this exists, but you can't see Place `withFlag` _after_ `withSupabase` to target by user identity: ```ts +import type { SupabaseContext } from '@supabase/server' +import { withSupabase } from '@supabase/server' +import { withFlag } from '@supabase/server/gates/flag' + withSupabase( { allow: 'user' }, - chain( - withFlag({ + withFlag( + { name: 'beta-checkout', - evaluate: async (_req) => { - // Cheap escape hatch: stash the user id on a header before chain runs, - // or read from a request-scoped store. For now, plug in an - // identity-aware provider: + 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), + }, + handler, + ), ) ``` -The current `evaluate` signature only sees the request — for user-aware flags, either pull the identity from a header `withSupabase` already validated (e.g. `req.headers.get('authorization')` to derive a stable id), or wait for a future enhancement that threads ctx into the evaluator. +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.state.flag` — only one `withFlag` can compose into a chain 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. +The gate occupies `ctx.flag` — only one `withFlag` 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 diff --git a/src/gates/flag/with-flag.test.ts b/src/gates/flag/with-flag.test.ts index 91de3f5..8b2a087 100644 --- a/src/gates/flag/with-flag.test.ts +++ b/src/gates/flag/with-flag.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { chain } from '../../core/gates/index.js' import { withFlag } from './with-flag.js' const innerOk = async () => Response.json({ ok: true }) @@ -8,7 +7,7 @@ const innerOk = async () => Response.json({ ok: true }) describe('withFlag', () => { it('admits when evaluate returns true and contributes the flag state', async () => { const inner = vi.fn(async (_req: Request, ctx) => { - expect(ctx.state.flag).toEqual({ + expect(ctx.flag).toEqual({ name: 'beta', enabled: true, variant: null, @@ -17,12 +16,7 @@ describe('withFlag', () => { return Response.json({ ok: true }) }) - const handler = chain( - withFlag({ - name: 'beta', - evaluate: () => true, - }), - )(inner) + const handler = withFlag({ name: 'beta', evaluate: () => true }, inner) const res = await handler(new Request('http://localhost/')) expect(res.status).toBe(200) @@ -30,9 +24,7 @@ describe('withFlag', () => { }) it('rejects with 404 by default when evaluate returns false', async () => { - const handler = chain(withFlag({ name: 'beta', evaluate: () => false }))( - innerOk, - ) + const handler = withFlag({ name: 'beta', evaluate: () => false }, innerOk) const res = await handler(new Request('http://localhost/')) expect(res.status).toBe(404) @@ -43,14 +35,15 @@ describe('withFlag', () => { }) it('honors a custom rejectStatus and rejectBody', async () => { - const handler = chain( - withFlag({ + const handler = withFlag( + { name: 'beta', evaluate: () => false, rejectStatus: 403, rejectBody: { code: 'NOT_ROLLED_OUT' }, - }), - )(innerOk) + }, + innerOk, + ) const res = await handler(new Request('http://localhost/')) expect(res.status).toBe(403) @@ -59,21 +52,22 @@ describe('withFlag', () => { it('captures variant + payload when evaluate returns a verdict object', async () => { const inner = vi.fn(async (_req: Request, ctx) => { - expect(ctx.state.flag.variant).toBe('green') - expect(ctx.state.flag.payload).toEqual({ rollout: 0.25 }) + expect(ctx.flag.variant).toBe('green') + expect(ctx.flag.payload).toEqual({ rollout: 0.25 }) return Response.json({ ok: true }) }) - const handler = chain( - withFlag({ + const handler = withFlag( + { name: 'beta', evaluate: () => ({ enabled: true, variant: 'green', payload: { rollout: 0.25 }, }), - }), - )(inner) + }, + inner, + ) await handler(new Request('http://localhost/')) expect(inner).toHaveBeenCalledOnce() @@ -82,7 +76,7 @@ describe('withFlag', () => { 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 = chain(withFlag({ name: 'beta', evaluate }))(innerOk) + const handler = withFlag({ name: 'beta', evaluate }, innerOk) const off = await handler(new Request('http://localhost/')) expect(off.status).toBe(404) @@ -96,15 +90,16 @@ describe('withFlag', () => { }) it('supports async evaluators', async () => { - const handler = chain( - withFlag({ + const handler = withFlag( + { name: 'beta', evaluate: async () => { await new Promise((r) => setTimeout(r, 1)) return { enabled: true, variant: 'a' } }, - }), - )(async (_req, ctx) => Response.json({ variant: ctx.state.flag.variant })) + }, + async (_req, ctx) => Response.json({ variant: ctx.flag.variant }), + ) const res = await handler(new Request('http://localhost/')) expect(res.status).toBe(200) diff --git a/src/gates/flag/with-flag.ts b/src/gates/flag/with-flag.ts index 47a7271..b51fe84 100644 --- a/src/gates/flag/with-flag.ts +++ b/src/gates/flag/with-flag.ts @@ -2,7 +2,7 @@ * Feature-flag gate. * * Evaluates a flag for the inbound request and either admits with the - * verdict at `ctx.state.flag` or short-circuits with a configurable response. + * verdict at `ctx.flag` or short-circuits with a configurable response. * Provider-agnostic — pass any `evaluate` function (PostHog, LaunchDarkly, * Statsig, a header check, a database lookup). */ @@ -10,7 +10,7 @@ import { defineGate } from '../../core/gates/index.js' export interface WithFlagConfig { - /** Human-readable name for the flag, recorded in `ctx.state.flag.name`. */ + /** Human-readable name for the flag, recorded in `ctx.flag.name`. */ name: string /** @@ -47,7 +47,7 @@ export interface FlagVerdict { payload?: unknown } -/** Shape contributed at `ctx.state.flag` after a successful evaluation. */ +/** Shape contributed at `ctx.flag` after a successful evaluation. */ export interface FlagState { name: string enabled: true @@ -70,7 +70,7 @@ export interface FlagState { * evaluate: (req) => req.headers.get('x-beta') === '1', * }), * )(async (_req, ctx) => { - * return Response.json({ feature: ctx.state.flag.name }) + * return Response.json({ feature: ctx.flag.name }) * }), * } * ``` @@ -93,7 +93,7 @@ export const withFlag = defineGate< Record, FlagState >({ - namespace: 'flag', + key: 'flag', run: (config) => async (req) => { const result = await config.evaluate(req) const verdict: FlagVerdict = diff --git a/src/gates/rate-limit/README.md b/src/gates/rate-limit/README.md index def7033..1dc876b 100644 --- a/src/gates/rate-limit/README.md +++ b/src/gates/rate-limit/README.md @@ -3,19 +3,17 @@ Fixed-window rate-limit gate. Counts hits per key within a window; rejects with `429 Too Many Requests` once the limit is exceeded. ```ts -import { chain } from '@supabase/server/core/gates' import { withRateLimit } from '@supabase/server/gates/rate-limit' export default { - fetch: chain( - withRateLimit({ + fetch: withRateLimit( + { limit: 60, windowMs: 60_000, key: (req) => req.headers.get('cf-connecting-ip') ?? 'anon', - }), - )(async (req, ctx) => { - return Response.json({ remaining: ctx.state.rateLimit.remaining }) - }), + }, + async (req, ctx) => Response.json({ remaining: ctx.rateLimit.remaining }), + ), } ``` @@ -31,7 +29,7 @@ export default { ## Contribution ```ts -ctx.state.rateLimit = { +ctx.rateLimit = { limit: number // configured limit remaining: number // hits remaining in current window reset: number // ms epoch when the window resets @@ -65,24 +63,24 @@ The `hit` method must atomically increment-or-create the bucket. A SQL function ## Composing with `withSupabase` for per-user limits ```ts +import type { SupabaseContext } from '@supabase/server' +import { withSupabase } from '@supabase/server' +import { withRateLimit } from '@supabase/server/gates/rate-limit' + withSupabase( { allow: 'user' }, - chain( - withRateLimit({ + withRateLimit( + { limit: 30, windowMs: 60_000, - key: async (_req) => { - // Pull the user id from the upstream Supabase context. - // Note: the key extractor doesn't see ctx by default; stash it in - // a closure or use ctx.locals from inside a wrapper gate. - return 'per-user-key' - }, - }), - )(handler), + key: (req) => req.headers.get('cf-connecting-ip') ?? 'anon', + }, + async (_req, ctx) => Response.json({ user: ctx.userClaims!.id }), + ), ) ``` -For per-user limits, key off `ctx.userClaims.id`. The current `key` signature only sees the request — pass user identity via a header you trust (after `withSupabase` validation), or compose the gate after a small "stamp the user id into req" step. +The `` annotation threads the host's keys into the inner handler's `ctx` so `ctx.userClaims` types correctly. For per-user limits keyed off the JWT identity, derive a stable key from a header the auth layer has already validated (or stash one inside `ctx.locals` from an outer step). ## See also diff --git a/src/gates/rate-limit/with-rate-limit.test.ts b/src/gates/rate-limit/with-rate-limit.test.ts index 5e1d564..fb56af4 100644 --- a/src/gates/rate-limit/with-rate-limit.test.ts +++ b/src/gates/rate-limit/with-rate-limit.test.ts @@ -1,6 +1,5 @@ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' -import { chain } from '../../core/gates/index.js' import { withRateLimit, createMemoryStore } from './with-rate-limit.js' const innerOk = async () => Response.json({ ok: true }) @@ -14,15 +13,11 @@ afterEach(() => { }) describe('withRateLimit', () => { - it('admits requests under the limit and contributes ctx.state.rateLimit', async () => { - const handler = chain( - withRateLimit({ - limit: 3, - windowMs: 60_000, - key: () => 'k', - }), - )(async (_req, ctx) => - Response.json({ remaining: ctx.state.rateLimit.remaining }), + it('admits requests under the limit and contributes ctx.rateLimit', async () => { + const handler = withRateLimit( + { limit: 3, windowMs: 60_000, key: () => 'k' }, + async (_req, ctx) => + Response.json({ remaining: ctx.rateLimit.remaining }), ) const r1 = await handler(new Request('http://localhost/')) @@ -38,9 +33,10 @@ describe('withRateLimit', () => { it('rejects with 429 + Retry-After once the limit is exceeded', async () => { vi.setSystemTime(new Date(1_700_000_000_000)) - const handler = chain( - withRateLimit({ limit: 1, windowMs: 60_000, key: () => 'k' }), - )(innerOk) + const handler = withRateLimit( + { limit: 1, windowMs: 60_000, key: () => 'k' }, + innerOk, + ) const ok = await handler(new Request('http://localhost/')) expect(ok.status).toBe(200) @@ -58,13 +54,14 @@ describe('withRateLimit', () => { }) it('isolates buckets by key', async () => { - const handler = chain( - withRateLimit({ + const handler = withRateLimit( + { limit: 1, windowMs: 60_000, key: (req) => new URL(req.url).searchParams.get('user') ?? 'anon', - }), - )(innerOk) + }, + innerOk, + ) expect( (await handler(new Request('http://localhost/?user=a'))).status, @@ -83,9 +80,10 @@ describe('withRateLimit', () => { it('resets after the window elapses', async () => { vi.setSystemTime(new Date(1_700_000_000_000)) - const handler = chain( - withRateLimit({ limit: 1, windowMs: 1_000, key: () => 'k' }), - )(innerOk) + const handler = withRateLimit( + { limit: 1, windowMs: 1_000, key: () => 'k' }, + innerOk, + ) expect((await handler(new Request('http://localhost/'))).status).toBe(200) expect((await handler(new Request('http://localhost/'))).status).toBe(429) diff --git a/src/gates/rate-limit/with-rate-limit.ts b/src/gates/rate-limit/with-rate-limit.ts index c8947e2..fbe222b 100644 --- a/src/gates/rate-limit/with-rate-limit.ts +++ b/src/gates/rate-limit/with-rate-limit.ts @@ -44,7 +44,7 @@ export interface WithRateLimitConfig { store?: RateLimitStore } -/** Shape contributed at `ctx.state.rateLimit` after a successful hit. */ +/** Shape contributed at `ctx.rateLimit` after a successful hit. */ export interface RateLimitState { /** The configured limit for this window. */ limit: number @@ -70,7 +70,7 @@ export interface RateLimitState { * key: (req) => req.headers.get('cf-connecting-ip') ?? 'anon', * }), * )(async (req, ctx) => { - * return Response.json({ remaining: ctx.state.rateLimit.remaining }) + * return Response.json({ remaining: ctx.rateLimit.remaining }) * }), * } * ``` @@ -81,7 +81,7 @@ export const withRateLimit = defineGate< Record, RateLimitState >({ - namespace: 'rateLimit', + key: 'rateLimit', run: (config) => { const store = config.store ?? createMemoryStore() diff --git a/src/gates/webhook/README.md b/src/gates/webhook/README.md index d14e308..75c0154 100644 --- a/src/gates/webhook/README.md +++ b/src/gates/webhook/README.md @@ -1,26 +1,26 @@ # `@supabase/server/gates/webhook` -HMAC signature verification for inbound webhooks. Reads the raw request body, verifies it against a shared secret, checks the replay window, and contributes the parsed event + raw bytes to `ctx.state.webhook`. +HMAC signature verification for inbound webhooks. Reads the raw request body, verifies it against a shared secret, checks the replay window, and contributes the parsed event + raw bytes to `ctx.webhook`. ```ts -import { chain } from '@supabase/server/core/gates' import { withWebhook } from '@supabase/server/gates/webhook' export default { - fetch: chain( - withWebhook({ + fetch: withWebhook( + { provider: { kind: 'stripe', secret: process.env.STRIPE_WEBHOOK_SECRET!, }, - }), - )(async (req, ctx) => { - const event = ctx.state.webhook.event as { type: string } - if (event.type === 'payment_intent.succeeded') { - // … - } - return new Response(null, { status: 204 }) - }), + }, + async (req, ctx) => { + const event = ctx.webhook.event as { type: string } + if (event.type === 'payment_intent.succeeded') { + // … + } + return new Response(null, { status: 204 }) + }, + ), } ``` @@ -77,7 +77,7 @@ withWebhook({ ## Contribution ```ts -ctx.state.webhook = { +ctx.webhook = { event: unknown // parsed JSON body rawBody: string // raw bytes the signature was computed over deliveryId: string // provider-supplied id (Stripe: event.id; GitHub: x-github-delivery) @@ -89,7 +89,7 @@ ctx.state.webhook = { ## Body consumption -The gate reads the request body via `req.text()` once. Downstream handlers that call `req.json()` would fail because the body is already consumed — read from `ctx.state.webhook.event` (parsed) or `ctx.state.webhook.rawBody` (raw) instead. +The gate reads the request body via `req.text()` once. Downstream handlers that call `req.json()` would fail because the body is already consumed — read from `ctx.webhook.event` (parsed) or `ctx.webhook.rawBody` (raw) instead. ## Idempotency diff --git a/src/gates/webhook/with-webhook.test.ts b/src/gates/webhook/with-webhook.test.ts index d9bfaa0..c21439a 100644 --- a/src/gates/webhook/with-webhook.test.ts +++ b/src/gates/webhook/with-webhook.test.ts @@ -1,6 +1,5 @@ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' -import { chain } from '../../core/gates/index.js' import { withWebhook } from './with-webhook.js' const SECRET = 'whsec_test' @@ -42,18 +41,19 @@ describe('withWebhook (stripe)', () => { const v1 = await hmacHex(SECRET, `${t}.${body}`) const inner = vi.fn(async (_req: Request, ctx) => { - expect(ctx.state.webhook.deliveryId).toBe('evt_123') - expect((ctx.state.webhook.event as { type: string }).type).toBe( + expect(ctx.webhook.deliveryId).toBe('evt_123') + expect((ctx.webhook.event as { type: string }).type).toBe( 'payment_intent.succeeded', ) - expect(ctx.state.webhook.timestamp).toBe(1_700_000_000_000) - expect(ctx.state.webhook.rawBody).toBe(body) + expect(ctx.webhook.timestamp).toBe(1_700_000_000_000) + expect(ctx.webhook.rawBody).toBe(body) return Response.json({ ok: true }) }) - const handler = chain( - withWebhook({ provider: { kind: 'stripe', secret: SECRET } }), - )(inner) + const handler = withWebhook( + { provider: { kind: 'stripe', secret: SECRET } }, + inner, + ) const res = await handler( new Request('http://localhost/', { @@ -68,9 +68,10 @@ describe('withWebhook (stripe)', () => { }) it('rejects when the signature header is missing', async () => { - const handler = chain( - withWebhook({ provider: { kind: 'stripe', secret: SECRET } }), - )(innerOk) + const handler = withWebhook( + { provider: { kind: 'stripe', secret: SECRET } }, + innerOk, + ) const res = await handler( new Request('http://localhost/', { method: 'POST', body: '{}' }), @@ -84,9 +85,10 @@ describe('withWebhook (stripe)', () => { const body = '{"id":"evt_1"}' const t = 1_700_000_000 - const handler = chain( - withWebhook({ provider: { kind: 'stripe', secret: SECRET } }), - )(innerOk) + const handler = withWebhook( + { provider: { kind: 'stripe', secret: SECRET } }, + innerOk, + ) const res = await handler( new Request('http://localhost/', { @@ -105,9 +107,10 @@ describe('withWebhook (stripe)', () => { const t = 1_700_000_000 - 600 // 10 min ago, default tolerance is 5 min const v1 = await hmacHex(SECRET, `${t}.${body}`) - const handler = chain( - withWebhook({ provider: { kind: 'stripe', secret: SECRET } }), - )(innerOk) + const handler = withWebhook( + { provider: { kind: 'stripe', secret: SECRET } }, + innerOk, + ) const res = await handler( new Request('http://localhost/', { @@ -127,11 +130,10 @@ describe('withWebhook (stripe)', () => { const oldSecret = 'whsec_old' const v1 = await hmacHex(oldSecret, `${t}.${body}`) - const handler = chain( - withWebhook({ - provider: { kind: 'stripe', secret: ['whsec_new', oldSecret] }, - }), - )(innerOk) + const handler = withWebhook( + { provider: { kind: 'stripe', secret: ['whsec_new', oldSecret] } }, + innerOk, + ) const res = await handler( new Request('http://localhost/', { @@ -155,13 +157,11 @@ describe('withWebhook (custom)', () => { })) const inner = vi.fn(async (_req: Request, ctx) => { - expect(ctx.state.webhook.deliveryId).toBe('d-1') + expect(ctx.webhook.deliveryId).toBe('d-1') return Response.json({ ok: true }) }) - const handler = chain( - withWebhook({ provider: { kind: 'custom', verify } }), - )(inner) + const handler = withWebhook({ provider: { kind: 'custom', verify } }, inner) const res = await handler( new Request('http://localhost/', { @@ -175,14 +175,15 @@ describe('withWebhook (custom)', () => { }) it('rejects when the custom verifier returns failure', async () => { - const handler = chain( - withWebhook({ + const handler = withWebhook( + { provider: { kind: 'custom', verify: () => ({ ok: false, status: 403, error: 'forbidden' }), }, - }), - )(innerOk) + }, + innerOk, + ) const res = await handler( new Request('http://localhost/', { method: 'POST', body: '{}' }), diff --git a/src/gates/webhook/with-webhook.ts b/src/gates/webhook/with-webhook.ts index fa50518..01207e5 100644 --- a/src/gates/webhook/with-webhook.ts +++ b/src/gates/webhook/with-webhook.ts @@ -3,7 +3,7 @@ * * Verifies the HMAC signature on an inbound webhook against a shared secret, * checks the replay window, and contributes the parsed event + raw body to - * `ctx.state.webhook`. Stripe is the canonical provider; supply a custom + * `ctx.webhook`. Stripe is the canonical provider; supply a custom * `verify` function to plug in others (Svix/Resend, GitHub, Slack, Shopify). */ @@ -34,7 +34,7 @@ export interface WithWebhookConfig { provider: WebhookProvider } -/** Shape contributed at `ctx.state.webhook` after a successful verification. */ +/** Shape contributed at `ctx.webhook` after a successful verification. */ export interface WebhookState { /** The parsed JSON event body. */ event: unknown @@ -63,8 +63,8 @@ export interface WebhookState { * }, * }), * )(async (req, ctx) => { - * // ctx.state.webhook.event is the parsed Stripe event - * // ctx.state.webhook.rawBody is the raw bytes (preserved here) + * // ctx.webhook.event is the parsed Stripe event + * // ctx.webhook.rawBody is the raw bytes (preserved here) * return new Response(null, { status: 204 }) * }), * } @@ -76,7 +76,7 @@ export const withWebhook = defineGate< Record, WebhookState >({ - namespace: 'webhook', + key: 'webhook', run: (config) => async (req) => { const rawBody = await req.text() const result = diff --git a/src/gates/x402/README.md b/src/gates/x402/README.md index 20677d3..e004f76 100644 --- a/src/gates/x402/README.md +++ b/src/gates/x402/README.md @@ -2,13 +2,10 @@ > **Experimental:** Stripe's machine-payment crypto deposit mode is a preview API. Both Stripe's surface and this gate may change. -Stripe-facilitated [x402](https://www.x402.org) paywall gate. Charge per-call in USDC for any fetch handler — Stripe issues the deposit address, settles on-chain, and the chain admits the request once the `PaymentIntent` has succeeded. - -Lives under `@supabase/server/gates/x402`. Compose with [`chain`](../../core/gates/README.md) from `@supabase/server/core/gates`. +Stripe-facilitated [x402](https://www.x402.org) paywall gate. Charge per-call in USDC for any fetch handler — Stripe issues the deposit address, settles on-chain, and the gate admits the request once the `PaymentIntent` has succeeded. ```ts import Stripe from 'stripe' -import { chain } from '@supabase/server/core/gates' import { withPayment } from '@supabase/server/gates/x402' const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { @@ -16,30 +13,33 @@ const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { }) export default { - fetch: chain(withPayment({ stripe, amountCents: 1 }))(async (req, ctx) => { - return Response.json({ ok: true, paid: ctx.state.payment.intentId }) + fetch: withPayment({ stripe, amountCents: 1 }, async (req, ctx) => { + return Response.json({ ok: true, paid: ctx.payment.intentId }) }), } ``` ## How it works -1. **First request — no `X-PAYMENT` header.** `withPayment` creates a Stripe `PaymentIntent` in crypto-deposit mode, records the deposit address → PI mapping in the store, and short-circuits the chain with a `402 Payment Required` carrying an [x402 v1](https://www.x402.org) `accepts` body that advertises the address. +1. **First request — no `X-PAYMENT` header.** `withPayment` creates a Stripe `PaymentIntent` in crypto-deposit mode, records the deposit address → PI mapping in the store, and short-circuits with a `402 Payment Required` carrying an [x402 v1](https://www.x402.org) `accepts` body that advertises the address. 2. **Client pays.** An x402-aware client (or agent) sends USDC to the advertised address on the requested network. 3. **Retry with `X-PAYMENT` header.** The header is a base64-encoded JSON envelope of the form `{ payload: { authorization: { to: } } }`. `withPayment` decodes it, looks up the matching `PaymentIntent`, and: - - if `status === "succeeded"`, contributes `{ intentId }` to `ctx.state.payment` and lets the chain proceed, + - if `status === "succeeded"`, contributes `{ intentId }` to `ctx.payment` and runs the handler, - if not yet settled, replies `402` with `{ error: "payment_not_settled", status }`, - if the address is unknown or the header is malformed, falls back to issuing a fresh `402`. ## Config ```ts -withPayment({ - stripe, // a Stripe client (or any structurally compatible object) - amountCents: 1, // price per call in USD cents; Stripe converts to USDC - network: 'base', // 'base' | 'tempo' | 'solana' — default 'base' - store, // deposit-address → PI-id lookup (default: in-memory Map) -}) +withPayment( + { + stripe, // a Stripe client (or any structurally compatible object) + amountCents: 1, // price per call in USD cents; Stripe converts to USDC + network: 'base', // 'base' | 'tempo' | 'solana' — default 'base' + store, // deposit-address → PI-id lookup (default: in-memory Map) + }, + handler, +) ``` `StripeLike` is structurally typed — this package does not depend on the `stripe` SDK at runtime or types-level. Pass any object exposing `paymentIntents.create` and `paymentIntents.retrieve`. @@ -51,7 +51,6 @@ The default store is an in-memory `Map`. That is fine for tests and a single lon Provide a Postgres-, Redis-, or KV-backed `PaymentStore`: ```ts -import { chain } from '@supabase/server/core/gates' import { withPayment, type PaymentStore } from '@supabase/server/gates/x402' const store: PaymentStore = { @@ -64,28 +63,31 @@ const store: PaymentStore = { } export default { - fetch: chain(withPayment({ stripe, amountCents: 1, store }))(handler), + fetch: withPayment({ stripe, amountCents: 1, store }, handler), } ``` ## Composing with `withSupabase` -`withPayment` is a gate; `withSupabase` is the fetch-handler wrapper that establishes Supabase context. Compose by running the chain inside `withSupabase`: +Nest `withPayment` inside `withSupabase` to gate authenticated routes. Pass `` to thread the host's keys into the inner handler's `ctx`: ```ts +import type { SupabaseContext } from '@supabase/server' import { withSupabase } from '@supabase/server' -import { chain } from '@supabase/server/core/gates' import { withPayment } from '@supabase/server/gates/x402' export default { fetch: withSupabase( { allow: 'user' }, - chain(withPayment({ stripe, amountCents: 5 }))(async (req, ctx) => { - // ctx.supabase is the user-scoped client (from withSupabase) - // ctx.state.payment.intentId is the settled PaymentIntent id - const { data } = await ctx.supabase.from('premium_reports').select() - return Response.json({ data, paid: ctx.state.payment.intentId }) - }), + withPayment( + { stripe, amountCents: 5 }, + async (req, ctx) => { + // ctx.supabase is the user-scoped client (from withSupabase) + // ctx.payment.intentId is the settled PaymentIntent id + const { data } = await ctx.supabase.from('premium_reports').select() + return Response.json({ data, paid: ctx.payment.intentId }) + }, + ), ), } ``` @@ -94,51 +96,27 @@ For fully anonymous machine-to-machine paywalls, drop `withSupabase`: ```ts export default { - fetch: chain(withPayment({ stripe, amountCents: 1 }))(async (req, ctx) => { - return Response.json({ paid: ctx.state.payment.intentId }) + fetch: withPayment({ stripe, amountCents: 1 }, async (req, ctx) => { + return Response.json({ paid: ctx.payment.intentId }) }), } ``` -## Migrating from `withPayment(config, handler)` - -Earlier versions exposed `withPayment` as a fetch-handler wrapper (`withPayment(config, handler)`). It is now a gate. Wrap your handler with `chain`: - -```diff -- export default { -- fetch: withPayment( -- { stripe, amountCents: 1 }, -- async (_req, { paymentIntentId }) => { -- return Response.json({ paid: paymentIntentId }) -- }, -- ), -- } -+ import { chain } from '@supabase/server/core/gates' -+ -+ export default { -+ fetch: chain(withPayment({ stripe, amountCents: 1 }))(async (req, ctx) => { -+ return Response.json({ paid: ctx.state.payment.intentId }) -+ }), -+ } -``` - -`PaymentReceipt` is replaced by `PaymentState` (same shape: `{ intentId: string }`, accessible at `ctx.state.payment`). - ## API -| Export | Description | -| --------------------------- | ---------------------------------------------------------------------------------------------- | -| `withPayment(config)` | Returns a gate that contributes `{ intentId }` to `ctx.state.payment` once the PI has settled. | -| `PaymentState` | Shape contributed at `ctx.state.payment`: `{ intentId: string }` | -| `PaymentStore` | Interface for the deposit-address → PaymentIntent-id mapping | -| `WithPaymentConfig` | Config object accepted by `withPayment` | -| `Network` | `'base' \| 'tempo' \| 'solana'` | -| `StripeLike` | Minimal structural type for the Stripe client | -| `PaymentIntent` | Subset of Stripe's `PaymentIntent` used by this wrapper | -| `PaymentIntentCreateParams` | Params shape passed to `stripe.paymentIntents.create` | +| Export | Description | +| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | +| `withPayment(config, handler)` | Wraps `handler` so it only runs once the inbound x402 payment has settled; contributes `{ intentId }` to `ctx.payment`. | +| `PaymentState` | Shape contributed at `ctx.payment`: `{ intentId: string }`. | +| `PaymentStore` | Interface for the deposit-address → PaymentIntent-id mapping. | +| `WithPaymentConfig` | Config object accepted by `withPayment`. | +| `Network` | `'base' \| 'tempo' \| 'solana'`. | +| `StripeLike` | Minimal structural type for the Stripe client. | +| `PaymentIntent` | Subset of Stripe's `PaymentIntent` used by this wrapper. | +| `PaymentIntentCreateParams` | Params shape passed to `stripe.paymentIntents.create`. | ## See also -- [Gate composition primitives](../../core/gates/README.md) — `chain`, `defineGate`, ctx shape +- [Gate composition primitives](../../core/gates/README.md) — `defineGate`, ctx shape, prereqs, conflict detection. - [x402 specification](https://www.x402.org) - [Stripe machine payments docs](https://docs.stripe.com/payments/machine/x402) diff --git a/src/gates/x402/with-payment.test.ts b/src/gates/x402/with-payment.test.ts index f8db389..989f907 100644 --- a/src/gates/x402/with-payment.test.ts +++ b/src/gates/x402/with-payment.test.ts @@ -1,12 +1,10 @@ import { describe, expect, it, vi } from 'vitest' -import { chain } from '../../core/gates/index.js' import { withPayment } from './with-payment.js' import type { PaymentIntent, PaymentState, StripeLike } from './with-payment.js' type Ctx = { - state: { payment: PaymentState } - locals: Record + payment: PaymentState } const innerOk = async () => Response.json({ ok: true }) @@ -36,7 +34,7 @@ const encodePayment = (to: string) => describe('withPayment', () => { it('returns 402 with deposit address when X-PAYMENT is missing', async () => { const { stripe, create } = makeStripeMock() - const handler = chain(withPayment({ stripe, amountCents: 1 }))(innerOk) + const handler = withPayment({ stripe, amountCents: 1 }, innerOk) const res = await handler(new Request('http://localhost/api/foo')) @@ -71,10 +69,10 @@ describe('withPayment', () => { it('runs handler when X-PAYMENT references a succeeded PaymentIntent', async () => { const { stripe, retrieve } = makeStripeMock() const inner = vi.fn(async (_req: Request, ctx: Ctx) => { - expect(ctx.state.payment).toEqual({ intentId: 'pi_test_123' }) + expect(ctx.payment).toEqual({ intentId: 'pi_test_123' }) return Response.json({ ok: true }) }) - const handler = chain(withPayment({ stripe, amountCents: 1 }))(inner) + const handler = withPayment({ stripe, amountCents: 1 }, inner) // Seed the store: first request creates the PI and registers its address. await handler(new Request('http://localhost/api/foo')) @@ -97,7 +95,7 @@ describe('withPayment', () => { it('returns 402 when the PaymentIntent has not settled yet', async () => { const { stripe } = makeStripeMock('requires_action') const inner = vi.fn(innerOk) - const handler = chain(withPayment({ stripe, amountCents: 1 }))(inner) + const handler = withPayment({ stripe, amountCents: 1 }, inner) await handler(new Request('http://localhost/api/foo')) @@ -120,7 +118,7 @@ describe('withPayment', () => { it('issues a fresh 402 when X-PAYMENT references an unknown deposit address', async () => { const { stripe, create, retrieve } = makeStripeMock() const inner = vi.fn(innerOk) - const handler = chain(withPayment({ stripe, amountCents: 1 }))(inner) + const handler = withPayment({ stripe, amountCents: 1 }, inner) const res = await handler( new Request('http://localhost/api/foo', { @@ -138,7 +136,7 @@ describe('withPayment', () => { it('issues a fresh 402 when X-PAYMENT is malformed', async () => { const { stripe, create } = makeStripeMock() - const handler = chain(withPayment({ stripe, amountCents: 1 }))(innerOk) + const handler = withPayment({ stripe, amountCents: 1 }, innerOk) const res = await handler( new Request('http://localhost/api/foo', { @@ -170,9 +168,10 @@ describe('withPayment', () => { get: vi.fn(async () => null), } - const handler = chain( - withPayment({ stripe, amountCents: 5, network: 'solana', store }), - )(innerOk) + const handler = withPayment( + { stripe, amountCents: 5, network: 'solana', store }, + innerOk, + ) const res = await handler(new Request('http://localhost/api/foo')) diff --git a/src/gates/x402/with-payment.ts b/src/gates/x402/with-payment.ts index a5e7df4..77fa58e 100644 --- a/src/gates/x402/with-payment.ts +++ b/src/gates/x402/with-payment.ts @@ -81,7 +81,7 @@ export interface WithPaymentConfig { } /** - * Shape contributed to `ctx.state.payment` once the chain has admitted a + * Shape contributed to `ctx.payment` once the chain has admitted a * paid request. */ export interface PaymentState { @@ -97,7 +97,7 @@ export interface PaymentState { * Stripe `PaymentIntent`'s deposit address. * - With `X-PAYMENT`, decodes the base64 payload, looks up the matching * `PaymentIntent` via `store`, and admits the request iff it has succeeded - * — placing `{ intentId }` at `ctx.state.payment`. + * — placing `{ intentId }` at `ctx.payment`. * * @example * ```ts @@ -111,7 +111,7 @@ export interface PaymentState { * * export default { * fetch: chain(withPayment({ stripe, amountCents: 1 }))(async (req, ctx) => { - * return Response.json({ ok: true, paid: ctx.state.payment.intentId }) + * return Response.json({ ok: true, paid: ctx.payment.intentId }) * }), * } * ``` @@ -122,7 +122,7 @@ export const withPayment = defineGate< Record, PaymentState >({ - namespace: 'payment', + key: 'payment', run: (config) => { const network = config.network ?? 'base' const store = config.store ?? createMemoryStore() From ecd985d0aa8a99d9dc53868203bb04f29d095790 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Sat, 2 May 2026 04:14:23 -0300 Subject: [PATCH 10/22] refactor(gates)!: replace pluggable stores with Supabase RPC persistence --- src/gates/rate-limit/README.md | 99 +++++++---- src/gates/rate-limit/index.ts | 4 +- src/gates/rate-limit/with-rate-limit.test.ts | 133 +++++++++++---- src/gates/rate-limit/with-rate-limit.ts | 141 +++++++++------- src/gates/x402/README.md | 153 +++++++++-------- src/gates/x402/index.ts | 6 +- src/gates/x402/with-payment.test.ts | 113 ++++++++++--- src/gates/x402/with-payment.ts | 166 +++++++++++++------ 8 files changed, 532 insertions(+), 283 deletions(-) diff --git a/src/gates/rate-limit/README.md b/src/gates/rate-limit/README.md index 1dc876b..1e59f77 100644 --- a/src/gates/rate-limit/README.md +++ b/src/gates/rate-limit/README.md @@ -1,30 +1,86 @@ # `@supabase/server/gates/rate-limit` -Fixed-window rate-limit gate. Counts hits per key within a window; rejects with `429 Too Many Requests` once the limit is exceeded. +Fixed-window rate-limit gate backed by Supabase Postgres. Counts hits per key within a window via an atomic SQL function; rejects with `429 Too Many Requests` once the limit is exceeded. ```ts +import { createClient } from '@supabase/supabase-js' import { withRateLimit } from '@supabase/server/gates/rate-limit' +const supabaseAdmin = createClient( + process.env.SUPABASE_URL!, + process.env.SUPABASE_SECRET_KEY!, +) + export default { fetch: withRateLimit( { limit: 60, windowMs: 60_000, key: (req) => req.headers.get('cf-connecting-ip') ?? 'anon', + client: supabaseAdmin, }, async (req, ctx) => Response.json({ remaining: ctx.rateLimit.remaining }), ), } ``` +## One-time migration + +Copy this into `supabase/migrations/_supabase_server_rate_limit.sql` and run `supabase db push`: + +```sql +create table if not exists public._supabase_server_rate_limits ( + key text primary key, + count int not null, + reset_at bigint not null +); + +create or replace function public._supabase_server_rate_limit_hit( + p_key text, + p_window_ms bigint +) +returns json +language plpgsql +as $$ +declare + now_ms bigint := (extract(epoch from clock_timestamp()) * 1000)::bigint; + result_count int; + result_reset bigint; +begin + insert into public._supabase_server_rate_limits (key, count, reset_at) + values (p_key, 1, now_ms + p_window_ms) + on conflict (key) do update + set + count = case + when public._supabase_server_rate_limits.reset_at <= now_ms then 1 + else public._supabase_server_rate_limits.count + 1 + end, + reset_at = case + when public._supabase_server_rate_limits.reset_at <= now_ms + then now_ms + p_window_ms + else public._supabase_server_rate_limits.reset_at + end + returning count, reset_at into result_count, result_reset; + + return json_build_object('count', result_count, 'reset_at', result_reset); +end; +$$; + +-- Service role only; never exposed via RLS. +alter table public._supabase_server_rate_limits enable row level security; +``` + +The gate calls `client.rpc('_supabase_server_rate_limit_hit', { p_key, p_window_ms })`. Override the function name via `rpc:` in the config if you'd rather pick your own. + ## Config -| Field | Type | Description | -| ---------- | --------------------------------------------- | ----------------------------------------------------------------- | -| `limit` | `number` | Maximum hits per `windowMs` per key. | -| `windowMs` | `number` | Window length in milliseconds. | -| `key` | `(req: Request) => string \| Promise` | Bucketing key. Per-IP, per-user, per-tenant, etc. | -| `store` | `RateLimitStore?` | Backing store. Defaults to in-memory `Map` (single-process only). | +| Field | Type | Description | +| ---------- | --------------------------------------------- | ----------------------------------------------------------- | +| `limit` | `number` | Maximum hits per `windowMs` per key. | +| `windowMs` | `number` | Window length in milliseconds. | +| `key` | `(req: Request) => string \| Promise` | Bucketing key. Per-IP, per-user, per-tenant, etc. | +| `client` | `SupabaseRpcClient` | Supabase admin client (any structurally compatible object). | +| `rpc` | `string?` | RPC name. Default: `_supabase_server_rate_limit_hit`. | ## Contribution @@ -38,34 +94,14 @@ ctx.rateLimit = { ## Errors -`429 Too Many Requests` with `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining: 0`, and `X-RateLimit-Reset` headers. Body: `{ error: 'rate_limit_exceeded', retryAfter: }`. +- **429 Too Many Requests** with `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining: 0`, `X-RateLimit-Reset` headers. Body: `{ error: 'rate_limit_exceeded', retryAfter: }`. +- If the RPC isn't installed, the gate throws with a hint pointing at this README's migration block. -## Production stores - -The default in-memory store works for tests and a single long-lived process. Multi-instance / serverless deployments need a shared store so windows aren't reset by request affinity. Implement the `RateLimitStore` interface against Postgres, Redis, or KV: - -```ts -import type { RateLimitStore } from '@supabase/server/gates/rate-limit' - -const postgresStore: RateLimitStore = { - async hit(key, windowMs) { - const { rows } = await db.query(`select * from rate_limit_hit($1, $2)`, [ - key, - windowMs, - ]) - return { count: rows[0].count, resetAt: rows[0].reset_at } - }, -} -``` - -The `hit` method must atomically increment-or-create the bucket. A SQL function is the simplest correct implementation. - -## Composing with `withSupabase` for per-user limits +## Composing with `withSupabase` ```ts import type { SupabaseContext } from '@supabase/server' import { withSupabase } from '@supabase/server' -import { withRateLimit } from '@supabase/server/gates/rate-limit' withSupabase( { allow: 'user' }, @@ -74,13 +110,14 @@ withSupabase( limit: 30, windowMs: 60_000, key: (req) => req.headers.get('cf-connecting-ip') ?? 'anon', + client: supabaseAdmin, }, async (_req, ctx) => Response.json({ user: ctx.userClaims!.id }), ), ) ``` -The `` annotation threads the host's keys into the inner handler's `ctx` so `ctx.userClaims` types correctly. For per-user limits keyed off the JWT identity, derive a stable key from a header the auth layer has already validated (or stash one inside `ctx.locals` from an outer step). +The `` annotation threads the host's keys into the inner handler's `ctx`. ## See also diff --git a/src/gates/rate-limit/index.ts b/src/gates/rate-limit/index.ts index 4bb6afa..3496355 100644 --- a/src/gates/rate-limit/index.ts +++ b/src/gates/rate-limit/index.ts @@ -4,9 +4,9 @@ * @packageDocumentation */ -export { withRateLimit, createMemoryStore } from './with-rate-limit.js' +export { withRateLimit } from './with-rate-limit.js' export type { RateLimitState, - RateLimitStore, + SupabaseRpcClient, WithRateLimitConfig, } from './with-rate-limit.js' diff --git a/src/gates/rate-limit/with-rate-limit.test.ts b/src/gates/rate-limit/with-rate-limit.test.ts index fb56af4..79ae3a0 100644 --- a/src/gates/rate-limit/with-rate-limit.test.ts +++ b/src/gates/rate-limit/with-rate-limit.test.ts @@ -1,47 +1,87 @@ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' -import { withRateLimit, createMemoryStore } from './with-rate-limit.js' +import { withRateLimit, type SupabaseRpcClient } from './with-rate-limit.js' const innerOk = async () => Response.json({ ok: true }) beforeAll(() => { vi.useFakeTimers() + vi.setSystemTime(new Date(1_700_000_000_000)) }) afterEach(() => { - vi.setSystemTime(new Date(0)) + vi.setSystemTime(new Date(1_700_000_000_000)) }) +/** + * In-memory fake of the Supabase RPC client that mimics the SQL function + * the gate expects. + */ +function makeFakeAdmin(): SupabaseRpcClient & { + rpc: ReturnType +} { + const buckets = new Map() + const rpc = vi.fn( + async ( + _fn: string, + args: { p_key: string; p_window_ms: number }, + ): Promise<{ data: { count: number; reset_at: number }; error: null }> => { + const now = Date.now() + const existing = buckets.get(args.p_key) + let next: { count: number; reset_at: number } + if (!existing || existing.reset_at <= now) { + next = { count: 1, reset_at: now + args.p_window_ms } + } else { + next = { count: existing.count + 1, reset_at: existing.reset_at } + } + buckets.set(args.p_key, next) + return { data: { ...next }, error: null } + }, + ) + return { rpc } as SupabaseRpcClient & { rpc: typeof rpc } +} + describe('withRateLimit', () => { it('admits requests under the limit and contributes ctx.rateLimit', async () => { + const supabaseAdmin = makeFakeAdmin() const handler = withRateLimit( { limit: 3, windowMs: 60_000, key: () => 'k' }, async (_req, ctx) => Response.json({ remaining: ctx.rateLimit.remaining }), ) - const r1 = await handler(new Request('http://localhost/')) - const r2 = await handler(new Request('http://localhost/')) - const r3 = await handler(new Request('http://localhost/')) + const r1 = await handler(new Request('http://localhost/'), { + supabaseAdmin, + }) + const r2 = await handler(new Request('http://localhost/'), { + supabaseAdmin, + }) + const r3 = await handler(new Request('http://localhost/'), { + supabaseAdmin, + }) expect(r1.status).toBe(200) expect(await r1.json()).toEqual({ remaining: 2 }) expect(await r2.json()).toEqual({ remaining: 1 }) expect(await r3.json()).toEqual({ remaining: 0 }) + expect(supabaseAdmin.rpc).toHaveBeenCalledTimes(3) }) it('rejects with 429 + Retry-After once the limit is exceeded', async () => { - vi.setSystemTime(new Date(1_700_000_000_000)) - + const supabaseAdmin = makeFakeAdmin() const handler = withRateLimit( { limit: 1, windowMs: 60_000, key: () => 'k' }, innerOk, ) - const ok = await handler(new Request('http://localhost/')) + const ok = await handler(new Request('http://localhost/'), { + supabaseAdmin, + }) expect(ok.status).toBe(200) - const blocked = await handler(new Request('http://localhost/')) + const blocked = await handler(new Request('http://localhost/'), { + supabaseAdmin, + }) expect(blocked.status).toBe(429) expect(blocked.headers.get('Retry-After')).toBe('60') expect(blocked.headers.get('X-RateLimit-Limit')).toBe('1') @@ -54,6 +94,7 @@ describe('withRateLimit', () => { }) it('isolates buckets by key', async () => { + const supabaseAdmin = makeFakeAdmin() const handler = withRateLimit( { limit: 1, @@ -64,45 +105,71 @@ describe('withRateLimit', () => { ) expect( - (await handler(new Request('http://localhost/?user=a'))).status, + ( + await handler(new Request('http://localhost/?user=a'), { + supabaseAdmin, + }) + ).status, ).toBe(200) expect( - (await handler(new Request('http://localhost/?user=b'))).status, + ( + await handler(new Request('http://localhost/?user=b'), { + supabaseAdmin, + }) + ).status, ).toBe(200) expect( - (await handler(new Request('http://localhost/?user=a'))).status, + ( + await handler(new Request('http://localhost/?user=a'), { + supabaseAdmin, + }) + ).status, ).toBe(429) expect( - (await handler(new Request('http://localhost/?user=b'))).status, + ( + await handler(new Request('http://localhost/?user=b'), { + supabaseAdmin, + }) + ).status, ).toBe(429) }) - it('resets after the window elapses', async () => { - vi.setSystemTime(new Date(1_700_000_000_000)) - + it('honors a custom rpc name', async () => { + const supabaseAdmin = makeFakeAdmin() const handler = withRateLimit( - { limit: 1, windowMs: 1_000, key: () => 'k' }, + { + limit: 1, + windowMs: 60_000, + key: () => 'k', + rpc: 'my_custom_rate_limit', + }, innerOk, ) - expect((await handler(new Request('http://localhost/'))).status).toBe(200) - expect((await handler(new Request('http://localhost/'))).status).toBe(429) - - vi.setSystemTime(new Date(1_700_000_001_500)) - expect((await handler(new Request('http://localhost/'))).status).toBe(200) + await handler(new Request('http://localhost/'), { supabaseAdmin }) + expect(supabaseAdmin.rpc).toHaveBeenCalledWith('my_custom_rate_limit', { + p_key: 'k', + p_window_ms: 60_000, + }) }) -}) -describe('createMemoryStore', () => { - it('returns a fresh window when the previous has expired', async () => { - vi.setSystemTime(new Date(1_700_000_000_000)) - const store = createMemoryStore() - - const first = await store.hit('k', 1_000) - expect(first).toEqual({ count: 1, resetAt: 1_700_000_001_000 }) + it('throws a helpful error when the rpc is missing', async () => { + const supabaseAdmin = { + rpc: vi.fn(async () => ({ + data: null, + error: { + code: '42883', + message: 'function _supabase_server_rate_limit_hit does not exist', + }, + })), + } satisfies SupabaseRpcClient + const handler = withRateLimit( + { limit: 1, windowMs: 60_000, key: () => 'k' }, + innerOk, + ) - vi.setSystemTime(new Date(1_700_000_002_000)) - const fresh = await store.hit('k', 1_000) - expect(fresh).toEqual({ count: 1, resetAt: 1_700_000_003_000 }) + await expect( + handler(new Request('http://localhost/'), { supabaseAdmin }), + ).rejects.toThrow(/RPC .* not found/) }) }) diff --git a/src/gates/rate-limit/with-rate-limit.ts b/src/gates/rate-limit/with-rate-limit.ts index fbe222b..dc564eb 100644 --- a/src/gates/rate-limit/with-rate-limit.ts +++ b/src/gates/rate-limit/with-rate-limit.ts @@ -1,24 +1,35 @@ /** * Fixed-window rate-limit gate. * - * Counts hits per key within a rolling window; rejects with 429 when the - * count exceeds `limit`. The store is pluggable — defaults to a per-process - * in-memory `Map`. For multi-instance / serverless deployments, supply a - * Postgres-, Redis-, or KV-backed implementation. + * Counts hits per key within a rolling window via a Supabase Postgres RPC; + * rejects with 429 when the count exceeds `limit`. + * + * The user owns the schema. Run the migration in this gate's README to + * install the table + atomic-increment function. The gate then calls + * `ctx.supabaseAdmin.rpc(, { p_key, p_window_ms })` and expects + * back `{ count, reset_at }` (ms epoch). The admin client comes from + * `withSupabase` upstream — this gate must be wrapped by it (or any wrapper + * that provides `supabaseAdmin`). */ import { defineGate } from '../../core/gates/index.js' -export interface RateLimitStore { - /** - * Atomically increment the hit count for `key` within a window of length - * `windowMs` milliseconds. Returns the post-increment count and the - * absolute timestamp (ms epoch) when the current window resets. - */ - hit( - key: string, - windowMs: number, - ): Promise<{ count: number; resetAt: number }> +const DEFAULT_RPC = '_supabase_server_rate_limit_hit' + +/** + * Structural subset of the Supabase admin client surface used by this gate. + * Any client whose `rpc` resolves to Supabase-shaped `{ data, error }` + * works — typed as `PromiseLike` so `supabase-js`'s `PostgrestFilterBuilder` + * (a thenable, not a strict `Promise`) satisfies it. + */ +export interface SupabaseRpcClient { + rpc( + fn: string, + args?: Record, + ): PromiseLike<{ + data: T | null + error: { message: string; code?: string } | null + }> } export interface WithRateLimitConfig { @@ -31,17 +42,18 @@ export interface WithRateLimitConfig { /** * Extracts the bucketing key from the request. Common choices: * - `req => req.headers.get('cf-connecting-ip') ?? 'anon'` for per-IP limits - * - `(_, ctx) => ctx.userClaims?.id ?? 'anon'` for per-user limits (when - * composed inside `withSupabase`) + * - `(req) => req.headers.get('authorization') ?? 'anon'` for per-bearer limits */ key: (req: Request) => string | Promise /** - * Backing store. Defaults to an in-memory `Map` suitable for tests and - * single-process dev. Production multi-instance deployments need a shared - * store so windows aren't reset by request affinity. + * Name of the SQL function the user registered. The function must accept + * `p_key text` and `p_window_ms bigint` and return + * `{ count: int, reset_at: bigint }` (ms epoch). + * + * @defaultValue `'_supabase_server_rate_limit_hit'` */ - store?: RateLimitStore + rpc?: string } /** Shape contributed at `ctx.rateLimit` after a successful hit. */ @@ -54,45 +66,76 @@ export interface RateLimitState { reset: number } +interface RpcResult { + count: number + reset_at: number +} + /** - * Fixed-window rate-limit gate. + * Fixed-window rate-limit gate. Must be wrapped by `withSupabase` (or any + * wrapper that provides `supabaseAdmin`) — the gate calls into it. * * @example * ```ts - * import { chain } from '@supabase/server/core/gates' + * import { withSupabase } from '@supabase/server' * import { withRateLimit } from '@supabase/server/gates/rate-limit' * * export default { - * fetch: chain( - * withRateLimit({ - * limit: 60, - * windowMs: 60_000, - * key: (req) => req.headers.get('cf-connecting-ip') ?? 'anon', - * }), - * )(async (req, ctx) => { - * return Response.json({ remaining: ctx.rateLimit.remaining }) - * }), + * fetch: withSupabase( + * { allow: 'always' }, + * withRateLimit( + * { + * limit: 60, + * windowMs: 60_000, + * key: (req) => req.headers.get('cf-connecting-ip') ?? 'anon', + * }, + * async (req, ctx) => + * Response.json({ remaining: ctx.rateLimit.remaining }), + * ), + * ), * } * ``` */ export const withRateLimit = defineGate< 'rateLimit', WithRateLimitConfig, - Record, + { supabaseAdmin: SupabaseRpcClient }, RateLimitState >({ key: 'rateLimit', run: (config) => { - const store = config.store ?? createMemoryStore() + const rpc = config.rpc ?? DEFAULT_RPC - return async (req) => { + return async (req, ctx) => { const key = await config.key(req) - const { count, resetAt } = await store.hit(key, config.windowMs) - const remaining = Math.max(0, config.limit - count) - const resetSec = Math.floor(resetAt / 1000) + const { data, error } = await ctx.supabaseAdmin.rpc(rpc, { + p_key: key, + p_window_ms: config.windowMs, + }) + + if (error || !data) { + if ( + error?.code === '42883' || + error?.message?.toLowerCase().includes('function') + ) { + throw new Error( + `withRateLimit: RPC '${rpc}' not found. Install the migration ` + + `from this gate's README before calling.`, + ) + } + throw new Error( + `withRateLimit: rpc failed: ${error?.message ?? 'no data returned'}`, + ) + } - if (count > config.limit) { - const retryAfter = Math.max(1, Math.ceil((resetAt - Date.now()) / 1000)) + const remaining = Math.max(0, config.limit - data.count) + const resetSec = Math.floor(data.reset_at / 1000) + + if (data.count > config.limit) { + const retryAfter = Math.max( + 1, + Math.ceil((data.reset_at - Date.now()) / 1000), + ) return { kind: 'reject', response: Response.json( @@ -115,27 +158,9 @@ export const withRateLimit = defineGate< contribution: { limit: config.limit, remaining, - reset: resetAt, + reset: data.reset_at, }, } } }, }) - -/** Default in-memory store. Single-process only. */ -export function createMemoryStore(): RateLimitStore { - const buckets = new Map() - return { - async hit(key, windowMs) { - const now = Date.now() - const existing = buckets.get(key) - if (!existing || existing.resetAt <= now) { - const fresh = { count: 1, resetAt: now + windowMs } - buckets.set(key, fresh) - return { ...fresh } - } - existing.count += 1 - return { count: existing.count, resetAt: existing.resetAt } - }, - } -} diff --git a/src/gates/x402/README.md b/src/gates/x402/README.md index e004f76..1290cb4 100644 --- a/src/gates/x402/README.md +++ b/src/gates/x402/README.md @@ -4,119 +4,116 @@ Stripe-facilitated [x402](https://www.x402.org) paywall gate. Charge per-call in USDC for any fetch handler — Stripe issues the deposit address, settles on-chain, and the gate admits the request once the `PaymentIntent` has succeeded. +Persistence (deposit-address → PaymentIntent-id mapping) lives in Supabase Postgres via two RPCs the user installs once. Stripe explicitly assumes the server holds this mapping; there's no `paymentIntents.retrieveByDepositAddress` to fall back on. + ```ts import Stripe from 'stripe' +import { createClient } from '@supabase/supabase-js' import { withPayment } from '@supabase/server/gates/x402' const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2026-03-04.preview' as never, }) +const supabaseAdmin = createClient( + process.env.SUPABASE_URL!, + process.env.SUPABASE_SECRET_KEY!, +) export default { - fetch: withPayment({ stripe, amountCents: 1 }, async (req, ctx) => { - return Response.json({ ok: true, paid: ctx.payment.intentId }) - }), + fetch: withPayment( + { stripe, amountCents: 1, client: supabaseAdmin }, + async (req, ctx) => Response.json({ ok: true, paid: ctx.payment.intentId }), + ), } ``` -## How it works +## One-time migration -1. **First request — no `X-PAYMENT` header.** `withPayment` creates a Stripe `PaymentIntent` in crypto-deposit mode, records the deposit address → PI mapping in the store, and short-circuits with a `402 Payment Required` carrying an [x402 v1](https://www.x402.org) `accepts` body that advertises the address. -2. **Client pays.** An x402-aware client (or agent) sends USDC to the advertised address on the requested network. -3. **Retry with `X-PAYMENT` header.** The header is a base64-encoded JSON envelope of the form `{ payload: { authorization: { to: } } }`. `withPayment` decodes it, looks up the matching `PaymentIntent`, and: - - if `status === "succeeded"`, contributes `{ intentId }` to `ctx.payment` and runs the handler, - - if not yet settled, replies `402` with `{ error: "payment_not_settled", status }`, - - if the address is unknown or the header is malformed, falls back to issuing a fresh `402`. +Copy this into `supabase/migrations/_supabase_server_x402.sql` and run `supabase db push`: -## Config +```sql +create table if not exists public._supabase_server_x402_intents ( + deposit_address text primary key, + payment_intent_id text not null, + created_at timestamptz not null default now() +); -```ts -withPayment( - { - stripe, // a Stripe client (or any structurally compatible object) - amountCents: 1, // price per call in USD cents; Stripe converts to USDC - network: 'base', // 'base' | 'tempo' | 'solana' — default 'base' - store, // deposit-address → PI-id lookup (default: in-memory Map) - }, - handler, +create or replace function public._supabase_server_x402_register( + p_deposit_address text, + p_payment_intent_id text +) +returns void +language sql +as $$ + insert into public._supabase_server_x402_intents + (deposit_address, payment_intent_id) + values (p_deposit_address, p_payment_intent_id) + on conflict (deposit_address) do nothing; +$$; + +create or replace function public._supabase_server_x402_lookup( + p_deposit_address text ) +returns text +language sql +as $$ + select payment_intent_id + from public._supabase_server_x402_intents + where deposit_address = p_deposit_address; +$$; + +-- Service role only. +alter table public._supabase_server_x402_intents enable row level security; ``` -`StripeLike` is structurally typed — this package does not depend on the `stripe` SDK at runtime or types-level. Pass any object exposing `paymentIntents.create` and `paymentIntents.retrieve`. +Override the function names via `registerRpc` / `lookupRpc` in the config if you'd rather pick your own. -## Production deployments need a real store +## How it works -The default store is an in-memory `Map`. That is fine for tests and a single long-lived process, but it loses the deposit-address → PI mapping across restarts and cannot be shared between instances — meaning a paid client may hit a different worker on retry and be asked to pay again. +1. **First request — no `X-PAYMENT` header.** `withPayment` creates a Stripe `PaymentIntent` in crypto-deposit mode, records the deposit address → PI id via `registerRpc`, and short-circuits with a `402 Payment Required` carrying an [x402 v1](https://www.x402.org) `accepts` body that advertises the address. +2. **Client pays.** An x402-aware client (or agent) sends USDC to the advertised address on the requested network. +3. **Retry with `X-PAYMENT` header.** The header is a base64-encoded JSON envelope of the form `{ payload: { authorization: { to: } } }`. `withPayment` decodes it, looks up the matching `PaymentIntent` via `lookupRpc`, and: + - if `status === "succeeded"`, contributes `{ intentId }` to `ctx.payment` and runs the handler, + - if not yet settled, replies `402` with `{ error: "payment_not_settled", status }`, + - if the address is unknown or the header is malformed, falls back to issuing a fresh `402`. -Provide a Postgres-, Redis-, or KV-backed `PaymentStore`: +## Config -```ts -import { withPayment, type PaymentStore } from '@supabase/server/gates/x402' - -const store: PaymentStore = { - async set(depositAddress, paymentIntentId) { - await kv.set(`x402:${depositAddress}`, paymentIntentId, { ex: 3600 }) - }, - async get(depositAddress) { - return kv.get(`x402:${depositAddress}`) - }, -} +| Field | Type | Description | +| ------------- | ------------------- | ------------------------------------------------------ | +| `stripe` | `StripeLike` | Stripe client (or any structurally compatible object). | +| `amountCents` | `number` | Price per call in USD cents. Stripe converts to USDC. | +| `network` | `Network?` | `'base' \| 'tempo' \| 'solana'`. Default `'base'`. | +| `client` | `SupabaseRpcClient` | Supabase admin client. | +| `registerRpc` | `string?` | Default: `_supabase_server_x402_register`. | +| `lookupRpc` | `string?` | Default: `_supabase_server_x402_lookup`. | -export default { - fetch: withPayment({ stripe, amountCents: 1, store }, handler), -} -``` +`StripeLike` is structurally typed — this package does not depend on the `stripe` SDK at runtime or types-level. Pass any object exposing `paymentIntents.create` and `paymentIntents.retrieve`. ## Composing with `withSupabase` -Nest `withPayment` inside `withSupabase` to gate authenticated routes. Pass `` to thread the host's keys into the inner handler's `ctx`: - ```ts import type { SupabaseContext } from '@supabase/server' import { withSupabase } from '@supabase/server' -import { withPayment } from '@supabase/server/gates/x402' -export default { - fetch: withSupabase( - { allow: 'user' }, - withPayment( - { stripe, amountCents: 5 }, - async (req, ctx) => { - // ctx.supabase is the user-scoped client (from withSupabase) - // ctx.payment.intentId is the settled PaymentIntent id - const { data } = await ctx.supabase.from('premium_reports').select() - return Response.json({ data, paid: ctx.payment.intentId }) - }, - ), +withSupabase( + { allow: 'user' }, + withPayment( + { stripe, amountCents: 5, client: supabaseAdmin }, + async (req, ctx) => { + // ctx.supabase is the user-scoped client (from withSupabase) + // ctx.payment.intentId is the settled PaymentIntent id + const { data } = await ctx.supabase.from('premium_reports').select() + return Response.json({ data, paid: ctx.payment.intentId }) + }, ), -} -``` - -For fully anonymous machine-to-machine paywalls, drop `withSupabase`: - -```ts -export default { - fetch: withPayment({ stripe, amountCents: 1 }, async (req, ctx) => { - return Response.json({ paid: ctx.payment.intentId }) - }), -} +) ``` -## API - -| Export | Description | -| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | -| `withPayment(config, handler)` | Wraps `handler` so it only runs once the inbound x402 payment has settled; contributes `{ intentId }` to `ctx.payment`. | -| `PaymentState` | Shape contributed at `ctx.payment`: `{ intentId: string }`. | -| `PaymentStore` | Interface for the deposit-address → PaymentIntent-id mapping. | -| `WithPaymentConfig` | Config object accepted by `withPayment`. | -| `Network` | `'base' \| 'tempo' \| 'solana'`. | -| `StripeLike` | Minimal structural type for the Stripe client. | -| `PaymentIntent` | Subset of Stripe's `PaymentIntent` used by this wrapper. | -| `PaymentIntentCreateParams` | Params shape passed to `stripe.paymentIntents.create`. | +For fully anonymous machine-to-machine paywalls, drop `withSupabase`. ## See also -- [Gate composition primitives](../../core/gates/README.md) — `defineGate`, ctx shape, prereqs, conflict detection. +- [Gate composition primitives](../../core/gates/README.md) - [x402 specification](https://www.x402.org) - [Stripe machine payments docs](https://docs.stripe.com/payments/machine/x402) diff --git a/src/gates/x402/index.ts b/src/gates/x402/index.ts index 7fa1efd..3aee494 100644 --- a/src/gates/x402/index.ts +++ b/src/gates/x402/index.ts @@ -1,10 +1,6 @@ /** * Stripe-facilitated x402 paywall gate. * - * Compose with {@link chain} from `@supabase/server/core/gates`. Optionally - * wrap with {@link withSupabase} to gate authenticated routes; use stand-alone - * for fully anonymous machine-to-machine paywalls. - * * @packageDocumentation */ @@ -14,7 +10,7 @@ export type { PaymentIntent, PaymentIntentCreateParams, PaymentState, - PaymentStore, StripeLike, + SupabaseRpcClient, WithPaymentConfig, } from './with-payment.js' diff --git a/src/gates/x402/with-payment.test.ts b/src/gates/x402/with-payment.test.ts index 989f907..75e6f81 100644 --- a/src/gates/x402/with-payment.test.ts +++ b/src/gates/x402/with-payment.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it, vi } from 'vitest' -import { withPayment } from './with-payment.js' -import type { PaymentIntent, PaymentState, StripeLike } from './with-payment.js' +import { + withPayment, + type PaymentIntent, + type PaymentState, + type StripeLike, + type SupabaseRpcClient, +} from './with-payment.js' type Ctx = { payment: PaymentState @@ -28,15 +33,44 @@ const makeStripeMock = (initialStatus = 'requires_action') => { return { stripe, create, retrieve } } +/** In-memory fake of the deposit-address → PI-id table the gate calls into. */ +function makeFakeAdmin(): SupabaseRpcClient & { + rpc: ReturnType +} { + const map = new Map() + const rpc = vi.fn( + async ( + fn: string, + args: Record, + ): Promise<{ data: unknown; error: null }> => { + if (fn === '_supabase_server_x402_register') { + const addr = args.p_deposit_address as string + const pi = args.p_payment_intent_id as string + map.set(addr, pi) + return { data: null, error: null } + } + if (fn === '_supabase_server_x402_lookup') { + const addr = args.p_deposit_address as string + return { data: map.get(addr) ?? null, error: null } + } + throw new Error(`unexpected rpc: ${fn}`) + }, + ) + return { rpc } as SupabaseRpcClient & { rpc: typeof rpc } +} + const encodePayment = (to: string) => btoa(JSON.stringify({ payload: { authorization: { to } } })) describe('withPayment', () => { it('returns 402 with deposit address when X-PAYMENT is missing', async () => { const { stripe, create } = makeStripeMock() + const supabaseAdmin = makeFakeAdmin() const handler = withPayment({ stripe, amountCents: 1 }, innerOk) - const res = await handler(new Request('http://localhost/api/foo')) + const res = await handler(new Request('http://localhost/api/foo'), { + supabaseAdmin, + }) expect(res.status).toBe(402) const body = await res.json() @@ -55,27 +89,26 @@ describe('withPayment', () => { ], }) expect(create).toHaveBeenCalledOnce() - expect(create.mock.calls[0][0]).toMatchObject({ - amount: 1, - currency: 'usd', - payment_method_types: ['crypto'], - payment_method_options: { - crypto: { mode: 'deposit', deposit_options: { networks: ['base'] } }, + expect(supabaseAdmin.rpc).toHaveBeenCalledWith( + '_supabase_server_x402_register', + { + p_deposit_address: DEPOSIT_ADDRESS, + p_payment_intent_id: 'pi_test_123', }, - confirm: true, - }) + ) }) it('runs handler when X-PAYMENT references a succeeded PaymentIntent', async () => { const { stripe, retrieve } = makeStripeMock() + const supabaseAdmin = makeFakeAdmin() const inner = vi.fn(async (_req: Request, ctx: Ctx) => { expect(ctx.payment).toEqual({ intentId: 'pi_test_123' }) return Response.json({ ok: true }) }) const handler = withPayment({ stripe, amountCents: 1 }, inner) - // Seed the store: first request creates the PI and registers its address. - await handler(new Request('http://localhost/api/foo')) + // Seed the store via a first request. + await handler(new Request('http://localhost/api/foo'), { supabaseAdmin }) // Stripe reports the PI as settled on the retry. retrieve.mockResolvedValueOnce(makePI('succeeded')) @@ -84,6 +117,7 @@ describe('withPayment', () => { new Request('http://localhost/api/foo', { headers: { 'x-payment': encodePayment(DEPOSIT_ADDRESS) }, }), + { supabaseAdmin }, ) expect(res.status).toBe(200) @@ -94,15 +128,17 @@ describe('withPayment', () => { it('returns 402 when the PaymentIntent has not settled yet', async () => { const { stripe } = makeStripeMock('requires_action') + const supabaseAdmin = makeFakeAdmin() const inner = vi.fn(innerOk) const handler = withPayment({ stripe, amountCents: 1 }, inner) - await handler(new Request('http://localhost/api/foo')) + await handler(new Request('http://localhost/api/foo'), { supabaseAdmin }) const res = await handler( new Request('http://localhost/api/foo', { headers: { 'x-payment': encodePayment(DEPOSIT_ADDRESS) }, }), + { supabaseAdmin }, ) expect(res.status).toBe(402) @@ -117,6 +153,7 @@ describe('withPayment', () => { it('issues a fresh 402 when X-PAYMENT references an unknown deposit address', async () => { const { stripe, create, retrieve } = makeStripeMock() + const supabaseAdmin = makeFakeAdmin() const inner = vi.fn(innerOk) const handler = withPayment({ stripe, amountCents: 1 }, inner) @@ -124,6 +161,7 @@ describe('withPayment', () => { new Request('http://localhost/api/foo', { headers: { 'x-payment': encodePayment('0xUNKNOWN') }, }), + { supabaseAdmin }, ) expect(res.status).toBe(402) @@ -136,19 +174,21 @@ describe('withPayment', () => { it('issues a fresh 402 when X-PAYMENT is malformed', async () => { const { stripe, create } = makeStripeMock() + const supabaseAdmin = makeFakeAdmin() const handler = withPayment({ stripe, amountCents: 1 }, innerOk) const res = await handler( new Request('http://localhost/api/foo', { headers: { 'x-payment': 'not-base64-json' }, }), + { supabaseAdmin }, ) expect(res.status).toBe(402) expect(create).toHaveBeenCalledOnce() }) - it('honors a custom store and network', async () => { + it('honors a custom network', async () => { const { stripe } = makeStripeMock() stripe.paymentIntents.create = vi.fn().mockResolvedValue({ id: 'pi_custom', @@ -159,26 +199,47 @@ describe('withPayment', () => { }, }, }) - - const writes: Array<[string, string]> = [] - const store = { - set: vi.fn(async (a: string, b: string) => { - writes.push([a, b]) - }), - get: vi.fn(async () => null), - } + const supabaseAdmin = makeFakeAdmin() const handler = withPayment( - { stripe, amountCents: 5, network: 'solana', store }, + { stripe, amountCents: 5, network: 'solana' }, innerOk, ) - const res = await handler(new Request('http://localhost/api/foo')) + const res = await handler(new Request('http://localhost/api/foo'), { + supabaseAdmin, + }) expect(res.status).toBe(402) const body = await res.json() expect(body.accepts[0].network).toBe('solana') expect(body.accepts[0].payTo).toBe('SOLADDRESS') - expect(writes).toEqual([['SOLADDRESS', 'pi_custom']]) + expect(supabaseAdmin.rpc).toHaveBeenCalledWith( + '_supabase_server_x402_register', + { p_deposit_address: 'SOLADDRESS', p_payment_intent_id: 'pi_custom' }, + ) + }) + + it('throws a helpful error when the lookup rpc is missing', async () => { + const { stripe } = makeStripeMock() + const supabaseAdmin = { + rpc: vi.fn(async () => ({ + data: null, + error: { + code: '42883', + message: 'function _supabase_server_x402_lookup does not exist', + }, + })), + } satisfies SupabaseRpcClient + const handler = withPayment({ stripe, amountCents: 1 }, innerOk) + + await expect( + handler( + new Request('http://localhost/api/foo', { + headers: { 'x-payment': encodePayment(DEPOSIT_ADDRESS) }, + }), + { supabaseAdmin }, + ), + ).rejects.toThrow(/lookup RPC .* not found/) }) }) diff --git a/src/gates/x402/with-payment.ts b/src/gates/x402/with-payment.ts index 77fa58e..829bf14 100644 --- a/src/gates/x402/with-payment.ts +++ b/src/gates/x402/with-payment.ts @@ -2,30 +2,25 @@ * Stripe-facilitated x402 paywall gate. * * Issues an HTTP 402 with a Stripe-generated USDC deposit address on - * unauthenticated requests, and lets the chain proceed once the corresponding + * unauthenticated requests, and lets the handler run once the corresponding * PaymentIntent has settled on-chain. * + * Persistence (deposit-address → PaymentIntent-id mapping) lives in Supabase + * Postgres via two RPCs the user installs once. See this gate's README for + * the migration. + * * @see https://docs.stripe.com/payments/machine/x402 * @see https://www.x402.org */ import { defineGate } from '../../core/gates/index.js' +const DEFAULT_REGISTER_RPC = '_supabase_server_x402_register' +const DEFAULT_LOOKUP_RPC = '_supabase_server_x402_lookup' + /** Networks supported by Stripe's machine-payment crypto deposit mode. */ export type Network = 'base' | 'tempo' | 'solana' -/** - * Maps a Stripe-issued deposit address back to the PaymentIntent that owns it. - * - * Implementations must persist across requests in any deployment that runs more - * than one instance (i.e. anything other than a single long-lived process). - * The default in-memory store is suitable only for tests and single-process dev. - */ -export interface PaymentStore { - set(depositAddress: string, paymentIntentId: string): Promise - get(depositAddress: string): Promise -} - /** * Subset of the `Stripe` client surface used by `withPayment`. Structurally * typed so callers pass their own `Stripe` instance without this package @@ -38,6 +33,21 @@ export interface StripeLike { } } +/** + * Structural subset of the Supabase admin client surface used by this gate. + * Typed as `PromiseLike` so `supabase-js`'s `PostgrestFilterBuilder` (a + * thenable, not a strict `Promise`) satisfies it. + */ +export interface SupabaseRpcClient { + rpc( + fn: string, + args?: Record, + ): PromiseLike<{ + data: T | null + error: { message: string; code?: string } | null + }> +} + export interface PaymentIntent { id: string status: string @@ -73,16 +83,25 @@ export interface WithPaymentConfig { network?: Network /** - * Lookup table mapping deposit address → PaymentIntent id. Defaults to an - * in-memory `Map`. Production deployments should pass a Postgres-, Redis-, - * or KV-backed implementation so the mapping survives across instances. + * RPC that registers a deposit address against a PaymentIntent id. Called + * with `{ p_deposit_address: text, p_payment_intent_id: text }`. + * + * @defaultValue `'_supabase_server_x402_register'` + */ + registerRpc?: string + + /** + * RPC that looks up a PaymentIntent id by deposit address. Called with + * `{ p_deposit_address: text }` and must return the PaymentIntent id + * (or `null` if unknown). + * + * @defaultValue `'_supabase_server_x402_lookup'` */ - store?: PaymentStore + lookupRpc?: string } /** - * Shape contributed to `ctx.payment` once the chain has admitted a - * paid request. + * Shape contributed at `ctx.payment` once the gate has admitted a paid request. */ export interface PaymentState { /** The id of the settled Stripe `PaymentIntent` that paid for this call. */ @@ -90,19 +109,14 @@ export interface PaymentState { } /** - * x402 paywall gate. Compose with {@link chain} (and optionally - * {@link withSupabase}) to gate a handler behind a Stripe-settled USDC payment. - * - * - Without `X-PAYMENT`, rejects with a 402 advertising a freshly-created - * Stripe `PaymentIntent`'s deposit address. - * - With `X-PAYMENT`, decodes the base64 payload, looks up the matching - * `PaymentIntent` via `store`, and admits the request iff it has succeeded - * — placing `{ intentId }` at `ctx.payment`. + * x402 paywall gate. Must be wrapped by `withSupabase` (or any wrapper that + * provides `supabaseAdmin`) — the gate calls into it for the deposit-address + * → PaymentIntent-id mapping. * * @example * ```ts * import Stripe from 'stripe' - * import { chain } from '@supabase/server/core/gates' + * import { withSupabase } from '@supabase/server' * import { withPayment } from '@supabase/server/gates/x402' * * const stripe = new Stripe(env.STRIPE_SECRET_KEY, { @@ -110,29 +124,39 @@ export interface PaymentState { * }) * * export default { - * fetch: chain(withPayment({ stripe, amountCents: 1 }))(async (req, ctx) => { - * return Response.json({ ok: true, paid: ctx.payment.intentId }) - * }), + * fetch: withSupabase( + * { allow: 'always' }, + * withPayment( + * { stripe, amountCents: 1 }, + * async (req, ctx) => + * Response.json({ ok: true, paid: ctx.payment.intentId }), + * ), + * ), * } * ``` */ export const withPayment = defineGate< 'payment', WithPaymentConfig, - Record, + { supabaseAdmin: SupabaseRpcClient }, PaymentState >({ key: 'payment', run: (config) => { const network = config.network ?? 'base' - const store = config.store ?? createMemoryStore() + const registerRpc = config.registerRpc ?? DEFAULT_REGISTER_RPC + const lookupRpc = config.lookupRpc ?? DEFAULT_LOOKUP_RPC - return async (req) => { + return async (req, ctx) => { const header = req.headers.get('x-payment') if (header) { const toAddress = decodePaymentHeader(header) if (toAddress) { - const paymentIntentId = await store.get(toAddress) + const paymentIntentId = await lookupPaymentIntent( + ctx.supabaseAdmin, + lookupRpc, + toAddress, + ) if (paymentIntentId) { const pi = await config.stripe.paymentIntents.retrieve(paymentIntentId) @@ -159,7 +183,13 @@ export const withPayment = defineGate< return { kind: 'reject', - response: await issuePaymentRequired(req, config, network, store), + response: await issuePaymentRequired( + req, + ctx.supabaseAdmin, + config, + network, + registerRpc, + ), } } }, @@ -177,11 +207,59 @@ function decodePaymentHeader(header: string): string | null { } } +async function lookupPaymentIntent( + client: SupabaseRpcClient, + rpc: string, + depositAddress: string, +): Promise { + const { data, error } = await client.rpc(rpc, { + p_deposit_address: depositAddress, + }) + if (error) { + if ( + error.code === '42883' || + error.message.toLowerCase().includes('function') + ) { + throw new Error( + `withPayment: lookup RPC '${rpc}' not found. Install the migration ` + + `from this gate's README before calling.`, + ) + } + throw new Error(`withPayment: lookup rpc failed: ${error.message}`) + } + return typeof data === 'string' && data.length > 0 ? data : null +} + +async function registerPaymentIntent( + client: SupabaseRpcClient, + rpc: string, + depositAddress: string, + paymentIntentId: string, +): Promise { + const { error } = await client.rpc(rpc, { + p_deposit_address: depositAddress, + p_payment_intent_id: paymentIntentId, + }) + if (error) { + if ( + error.code === '42883' || + error.message.toLowerCase().includes('function') + ) { + throw new Error( + `withPayment: register RPC '${rpc}' not found. Install the migration ` + + `from this gate's README before calling.`, + ) + } + throw new Error(`withPayment: register rpc failed: ${error.message}`) + } +} + async function issuePaymentRequired( req: Request, + client: SupabaseRpcClient, config: WithPaymentConfig, network: Network, - store: PaymentStore, + registerRpc: string, ): Promise { const pi = await config.stripe.paymentIntents.create({ amount: config.amountCents, @@ -205,7 +283,7 @@ async function issuePaymentRequired( `Stripe PaymentIntent ${pi.id} did not return a deposit address for ${network}`, ) } - await store.set(address, pi.id) + await registerPaymentIntent(client, registerRpc, address, pi.id) return Response.json( { @@ -225,15 +303,3 @@ async function issuePaymentRequired( { status: 402 }, ) } - -function createMemoryStore(): PaymentStore { - const map = new Map() - return { - async set(addr, id) { - map.set(addr, id) - }, - async get(addr) { - return map.get(addr) ?? null - }, - } -} From b579ea5cff693c2324bced1060295cc8b662be41 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Thu, 7 May 2026 02:10:05 -0300 Subject: [PATCH 11/22] refactor(gates)!: replace GateResult union with direct Response or keyed-slot return --- src/core/gates/README.md | 40 ++++--------- src/core/gates/define-gate.test.ts | 50 +++++++--------- src/core/gates/define-gate.ts | 76 +++++++++++++---------- src/core/gates/index.ts | 2 +- src/core/gates/types.ts | 8 --- src/gates/cloudflare/index.ts | 4 +- src/gates/cloudflare/with-access.ts | 40 +++++-------- src/gates/cloudflare/with-turnstile.ts | 80 ++++++++++--------------- src/gates/flag/with-flag.ts | 26 ++++---- src/gates/rate-limit/with-rate-limit.ts | 28 ++++----- src/gates/webhook/with-webhook.ts | 32 +++++----- src/gates/x402/with-payment.ts | 41 +++++-------- 12 files changed, 180 insertions(+), 247 deletions(-) diff --git a/src/core/gates/README.md b/src/core/gates/README.md index d57c4b6..fc318b7 100644 --- a/src/core/gates/README.md +++ b/src/core/gates/README.md @@ -1,6 +1,6 @@ # `@supabase/server/core/gates` -Composable preconditions for fetch handlers. A **gate** is a small unit that runs against an inbound `Request` and either short-circuits with a `Response` or contributes typed data to a flat key on `ctx` for the handler. +Composable preconditions for fetch handlers. A **gate** is a small unit that runs against an inbound `Request` and either short-circuits by returning a `Response` or contributes typed data to a flat key on `ctx` for the handler. This module exports: @@ -85,12 +85,9 @@ export const withFlag = defineGate< run: (config) => async (req) => { const enabled = config.evaluate(req) if (!enabled) { - return { - kind: 'reject', - response: Response.json({ error: 'feature_disabled' }, { status: 404 }), - } + return Response.json({ error: 'feature_disabled' }, { status: 404 }) } - return { kind: 'pass', contribution: { enabled } } + return { flag: { enabled } } // ← keyed slot, visible at ctx.flag }, }) ``` @@ -108,16 +105,12 @@ withFlag({ name: 'beta', evaluate: ... }, async (req, ctx) => { ```ts run: (config: Config) => (req: Request, ctx: In) => - Promise> - -type GateResult = - | { kind: 'pass'; contribution: C } - | { kind: 'reject'; response: Response } + 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 `{ kind: 'pass', contribution }` to admit the request and contribute typed state. Return `{ kind: 'reject', response }` to short-circuit with a canonical 4xx response. +Return a `Response` to short-circuit. Otherwise, return a single-key object `{ [key]: contribution }` — the gate author types the slot key directly in the return position, so the relationship between the gate's `key` and where its data lands on `ctx` is visible at the call site. The runtime picks `result[key]` and ignores any other fields, so accidentally returning a wider object (e.g. `{ ...ctx, [key]: ... }`) is a runtime no-op for upstream values, and TypeScript flags excess keys on fresh-literal returns. ### Declaring upstream prerequisites @@ -135,19 +128,13 @@ export const withSubscription = defineGate< key: 'subscription', run: (config) => async (_req, ctx) => { if (!ctx.userClaims) { - return { - kind: 'reject', - response: Response.json({ error: 'unauthenticated' }, { status: 401 }), - } + return Response.json({ error: 'unauthenticated' }, { status: 401 }) } const plan = await config.lookup(ctx.userClaims.id) if (!plan) { - return { - kind: 'reject', - response: Response.json({ error: 'no_plan' }, { status: 402 }), - } + return Response.json({ error: 'no_plan' }, { status: 402 }) } - return { kind: 'pass', contribution: { plan } } + return { subscription: { plan } } }, }) ``` @@ -198,9 +185,8 @@ Without the explicit ``, the inner handler's `ctx` only types the gate's o ## API -| Export | Description | -| -------------------------------------------- | --------------------------------------------------------------------------------------- | -| `defineGate(spec)` | Author helper: declare a gate. Returns a `(config, handler)` factory. | -| `GateResult` | Discriminated union: `{ kind: 'pass', contribution }` / `{ kind: 'reject', response }`. | -| `Conflict` | Sentinel string returned when a gate would shadow an upstream key. | -| `GateFactory` | The shape of a gate factory produced by `defineGate`. | +| Export | Description | +| -------------------------------------------- | --------------------------------------------------------------------- | +| `defineGate(spec)` | Author helper: declare a gate. Returns a `(config, handler)` factory. | +| `Conflict` | Sentinel string returned when a gate would shadow an upstream key. | +| `GateFactory` | The shape of a gate factory produced by `defineGate`. | diff --git a/src/core/gates/define-gate.test.ts b/src/core/gates/define-gate.test.ts index 74c138c..be79a78 100644 --- a/src/core/gates/define-gate.test.ts +++ b/src/core/gates/define-gate.test.ts @@ -4,19 +4,19 @@ import { defineGate } from './define-gate.js' const innerOk = async () => Response.json({ ok: true }) -const passingGate = (key: Key, contribution: C) => +const passingGate = ( + key: Key, + contribution: C, +) => defineGate, C>({ key, - run: () => async () => ({ kind: 'pass', contribution }), + run: () => async () => ({ [key]: contribution }) as { [K in Key]: C }, }) const rejectingGate = (key: Key, status = 401) => defineGate, Record>({ key, - run: () => async () => ({ - kind: 'reject', - response: new Response(`rejected by ${key}`, { status }), - }), + run: () => async () => new Response(`rejected by ${key}`, { status }), }) describe('defineGate', () => { @@ -28,10 +28,7 @@ describe('defineGate', () => { { hello: string } >({ key: 'greeting', - run: (config) => async () => ({ - kind: 'pass', - contribution: { hello: config.who }, - }), + run: (config) => async () => ({ greeting: { hello: config.who } }), }) const fetchHandler = withGreeting({ who: 'world' }, async (_req, ctx) => @@ -70,16 +67,16 @@ describe('defineGate', () => { it('refuses to compose where the gate would shadow an upstream key', () => { const withFoo = passingGate('foo', { v: 1 }) - // Calling the gate with a Base that already includes 'foo' returns a - // `Conflict<'foo'>` sentinel string instead of a fetch handler. The error - // surfaces when the result is used in a function position. - const conflicted = withFoo<{ foo: { v: number } }>(undefined, async () => - Response.json({ ok: true }), - ) - - // @ts-expect-error — Conflict string is not assignable to a fetch handler - const _fn: (req: Request) => Promise = conflicted - void _fn + // 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 () => { @@ -99,8 +96,7 @@ describe('defineGate', () => { // ctx is typed as Upstream — `from` is callable here const probe = ctx.supabase.from(`reports:${config.reportId}`) return { - kind: 'pass', - contribution: { allowed: probe.ok && ctx.userClaims.id !== '' }, + reportAccess: { allowed: probe.ok && ctx.userClaims.id !== '' }, } }, }) @@ -143,15 +139,9 @@ describe('defineGate', () => { key: 'tenant', run: (config) => async (_req, ctx) => { if (!config.allowed.includes(ctx.tenantId)) { - return { - kind: 'reject', - response: Response.json( - { error: 'tenant_forbidden' }, - { status: 403 }, - ), - } + return Response.json({ error: 'tenant_forbidden' }, { status: 403 }) } - return { kind: 'pass', contribution: { tenantId: ctx.tenantId } } + return { tenant: { tenantId: ctx.tenantId } } }, }) diff --git a/src/core/gates/define-gate.ts b/src/core/gates/define-gate.ts index 65ae68b..449cccd 100644 --- a/src/core/gates/define-gate.ts +++ b/src/core/gates/define-gate.ts @@ -1,12 +1,15 @@ -import type { Conflict, GateResult } from './types.js' +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 with a `Response` (rejection) or - * contributes a typed value at `ctx[key]` (pass), then calls the inner - * handler with the merged context. + * 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 factory has the shape `withFoo(config, handler) → fetchHandler`, * so gates nest the same way `withSupabase` does — no separate composer. @@ -30,7 +33,9 @@ import type { Conflict, GateResult } from './types.js' * @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]`. + * @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 @@ -45,12 +50,9 @@ import type { Conflict, GateResult } from './types.js' * key: 'flag', * run: (config) => async (req) => { * if (!config.evaluate(req)) { - * return { - * kind: 'reject', - * response: Response.json({ error: 'feature_disabled' }, { status: 404 }), - * } + * return Response.json({ error: 'feature_disabled' }, { status: 404 }) * } - * return { kind: 'pass', contribution: { name: config.name, enabled: true } } + * return { flag: { name: config.name, enabled: true } } * }, * }) * @@ -73,12 +75,9 @@ import type { Conflict, GateResult } from './types.js' * // ctx is typed as `{ supabase, userClaims }` — the In shape. * const allowed = await canRead(ctx.supabase, ctx.userClaims, config.reportId) * if (!allowed) { - * return { - * kind: 'reject', - * response: Response.json({ error: 'forbidden' }, { status: 403 }), - * } + * return Response.json({ error: 'forbidden' }, { status: 403 }) * } - * return { kind: 'pass', contribution: { allowed } } + * return { reportAccess: { allowed } } * }, * }) * @@ -101,15 +100,21 @@ export function defineGate< key: Key run: ( config: Config, - ) => (req: Request, ctx: In) => Promise> + ) => ( + req: Request, + ctx: In, + ) => Promise }): GateFactory { 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.kind === 'reject') return result.response - const ctx = { ...upstream, [spec.key]: result.contribution } + if (result instanceof Response) return result + const ctx = { + ...upstream, + [spec.key]: (result as Record)[spec.key], + } return ( handler as unknown as (req: Request, ctx: object) => Promise )(req, ctx) @@ -143,22 +148,31 @@ type Wrapped = keyof In extends never : (req: Request, baseCtx: Base) => Promise /** - * Result of calling a gate factory: either the wrapped handler (no conflict), - * or a `Conflict` sentinel string (key already on `Base`). The sentinel - * surfaces at the *use site* of the returned value — when it's passed as a - * handler to an outer wrapper that expected a function, TypeScript reports - * "Type '…' is not assignable to type 'gate-conflict: …'", citing the literal - * conflict message. + * 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. * - * `any` Base (common in tests via `vi.fn` inference) skips conflict detection - * because `keyof any` would false-positive every key. + * 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 FactoryReturn = +type NoConflict = IsAny extends true - ? Wrapped + ? object : Key extends keyof Base ? Conflict - : Wrapped + : object export interface GateFactory< Key extends string, @@ -166,11 +180,11 @@ export interface GateFactory< In extends object, Contribution, > { - ( + >( config: Config, handler: ( req: Request, ctx: Base & { [K in Key]: Contribution }, ) => Promise, - ): FactoryReturn + ): Wrapped } diff --git a/src/core/gates/index.ts b/src/core/gates/index.ts index b67099c..4220a1d 100644 --- a/src/core/gates/index.ts +++ b/src/core/gates/index.ts @@ -13,4 +13,4 @@ export { defineGate } from './define-gate.js' export type { GateFactory } from './define-gate.js' -export type { Conflict, GateResult } from './types.js' +export type { Conflict } from './types.js' diff --git a/src/core/gates/types.ts b/src/core/gates/types.ts index 0d7f214..44ebbd0 100644 --- a/src/core/gates/types.ts +++ b/src/core/gates/types.ts @@ -4,14 +4,6 @@ * @packageDocumentation */ -/** - * The result a gate's `run` function returns: either a successful contribution - * to be merged into `ctx[key]`, or a `Response` that short-circuits. - */ -export type GateResult = - | { kind: 'pass'; contribution: Contribution } - | { kind: 'reject'; response: Response } - /** * 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. diff --git a/src/gates/cloudflare/index.ts b/src/gates/cloudflare/index.ts index 453e9ce..850a1f4 100644 --- a/src/gates/cloudflare/index.ts +++ b/src/gates/cloudflare/index.ts @@ -1,8 +1,8 @@ /** * Cloudflare gates. * - * Each gate slots into {@link chain} from `@supabase/server/core/gates` and - * contributes typed state to `ctx.state[namespace]`. + * Each gate is a fetch-handler wrapper — compose by direct nesting — and + * contributes typed state under its own key on `ctx`. * * @packageDocumentation */ diff --git a/src/gates/cloudflare/with-access.ts b/src/gates/cloudflare/with-access.ts index 98e32bc..a72408b 100644 --- a/src/gates/cloudflare/with-access.ts +++ b/src/gates/cloudflare/with-access.ts @@ -60,18 +60,16 @@ export interface AccessState { * * @example * ```ts - * import { chain } from '@supabase/server/core/gates' * import { withAccess } from '@supabase/server/gates/cloudflare' * * export default { - * fetch: chain( - * withAccess({ + * fetch: withAccess( + * { * teamDomain: 'acme.cloudflareaccess.com', * audience: process.env.CF_ACCESS_AUD!, - * }), - * )(async (req, ctx) => { - * return Response.json({ user: ctx.access.email }) - * }), + * }, + * async (req, ctx) => Response.json({ user: ctx.access.email }), + * ), * } * ``` */ @@ -90,13 +88,7 @@ export const withAccess = defineGate< return async (req) => { const token = req.headers.get(HEADER_NAME) if (!token) { - return { - kind: 'reject', - response: Response.json( - { error: 'access_token_missing' }, - { status: 401 }, - ), - } + return Response.json({ error: 'access_token_missing' }, { status: 401 }) } try { @@ -112,8 +104,7 @@ export const withAccess = defineGate< : null return { - kind: 'pass', - contribution: { + access: { email, sub: payload.sub ?? '', identityNonce, @@ -122,16 +113,13 @@ export const withAccess = defineGate< }, } } catch (err) { - return { - kind: 'reject', - response: Response.json( - { - error: 'access_token_invalid', - detail: err instanceof Error ? err.message : 'unknown', - }, - { status: 401 }, - ), - } + return Response.json( + { + error: 'access_token_invalid', + detail: err instanceof Error ? err.message : 'unknown', + }, + { status: 401 }, + ) } } }, diff --git a/src/gates/cloudflare/with-turnstile.ts b/src/gates/cloudflare/with-turnstile.ts index 42c677c..0262d74 100644 --- a/src/gates/cloudflare/with-turnstile.ts +++ b/src/gates/cloudflare/with-turnstile.ts @@ -74,18 +74,17 @@ interface SiteverifyResponse { * * @example * ```ts - * import { chain } from '@supabase/server/core/gates' * import { withTurnstile } from '@supabase/server/gates/cloudflare' * * export default { - * fetch: chain( - * withTurnstile({ + * fetch: withTurnstile( + * { * secretKey: process.env.TURNSTILE_SECRET_KEY!, * expectedAction: 'login', - * }), - * )(async (req, ctx) => { - * return Response.json({ ok: true, action: ctx.turnstile.action }) - * }), + * }, + * async (req, ctx) => + * Response.json({ ok: true, action: ctx.turnstile.action }), + * ), * } * ``` */ @@ -103,13 +102,10 @@ export const withTurnstile = defineGate< return async (req) => { const token = await getToken(req) if (!token) { - return { - kind: 'reject', - response: Response.json( - { error: 'turnstile_token_missing' }, - { status: 401 }, - ), - } + return Response.json( + { error: 'turnstile_token_missing' }, + { status: 401 }, + ) } const params = new URLSearchParams() @@ -124,53 +120,43 @@ export const withTurnstile = defineGate< }) if (!verifyResponse.ok) { - return { - kind: 'reject', - response: Response.json( - { - error: 'turnstile_verification_unavailable', - status: verifyResponse.status, - }, - { status: 503 }, - ), - } + return Response.json( + { + error: 'turnstile_verification_unavailable', + status: verifyResponse.status, + }, + { status: 503 }, + ) } const result = (await verifyResponse.json()) as SiteverifyResponse if (!result.success) { - return { - kind: 'reject', - response: Response.json( - { - error: 'turnstile_verification_failed', - codes: result['error-codes'] ?? [], - }, - { status: 401 }, - ), - } + return Response.json( + { + error: 'turnstile_verification_failed', + codes: result['error-codes'] ?? [], + }, + { status: 401 }, + ) } if ( config.expectedAction !== undefined && result.action !== config.expectedAction ) { - return { - kind: 'reject', - response: Response.json( - { - error: 'turnstile_action_mismatch', - expected: config.expectedAction, - actual: result.action ?? null, - }, - { status: 401 }, - ), - } + return Response.json( + { + error: 'turnstile_action_mismatch', + expected: config.expectedAction, + actual: result.action ?? null, + }, + { status: 401 }, + ) } return { - kind: 'pass', - contribution: { + turnstile: { challengeTs: result.challenge_ts ?? '', hostname: result.hostname ?? '', action: result.action ?? '', diff --git a/src/gates/flag/with-flag.ts b/src/gates/flag/with-flag.ts index b51fe84..77a41ab 100644 --- a/src/gates/flag/with-flag.ts +++ b/src/gates/flag/with-flag.ts @@ -60,18 +60,16 @@ export interface FlagState { * * @example * ```ts - * import { chain } from '@supabase/server/core/gates' * import { withFlag } from '@supabase/server/gates/flag' * * export default { - * fetch: chain( - * withFlag({ + * fetch: withFlag( + * { * name: 'beta-checkout', * evaluate: (req) => req.headers.get('x-beta') === '1', - * }), - * )(async (_req, ctx) => { - * return Response.json({ feature: ctx.flag.name }) - * }), + * }, + * async (_req, ctx) => Response.json({ feature: ctx.flag.name }), + * ), * } * ``` * @@ -100,18 +98,14 @@ export const withFlag = defineGate< typeof result === 'boolean' ? { enabled: result } : result if (!verdict.enabled) { - return { - kind: 'reject', - response: Response.json( - config.rejectBody ?? { error: 'feature_disabled', flag: config.name }, - { status: config.rejectStatus ?? 404 }, - ), - } + return Response.json( + config.rejectBody ?? { error: 'feature_disabled', flag: config.name }, + { status: config.rejectStatus ?? 404 }, + ) } return { - kind: 'pass', - contribution: { + flag: { name: config.name, enabled: true, variant: verdict.variant ?? null, diff --git a/src/gates/rate-limit/with-rate-limit.ts b/src/gates/rate-limit/with-rate-limit.ts index dc564eb..3507ba8 100644 --- a/src/gates/rate-limit/with-rate-limit.ts +++ b/src/gates/rate-limit/with-rate-limit.ts @@ -136,26 +136,22 @@ export const withRateLimit = defineGate< 1, Math.ceil((data.reset_at - Date.now()) / 1000), ) - return { - kind: 'reject', - response: Response.json( - { error: 'rate_limit_exceeded', retryAfter }, - { - status: 429, - headers: { - 'Retry-After': String(retryAfter), - 'X-RateLimit-Limit': String(config.limit), - 'X-RateLimit-Remaining': '0', - 'X-RateLimit-Reset': String(resetSec), - }, + return Response.json( + { error: 'rate_limit_exceeded', retryAfter }, + { + status: 429, + headers: { + 'Retry-After': String(retryAfter), + 'X-RateLimit-Limit': String(config.limit), + 'X-RateLimit-Remaining': '0', + 'X-RateLimit-Reset': String(resetSec), }, - ), - } + }, + ) } return { - kind: 'pass', - contribution: { + rateLimit: { limit: config.limit, remaining, reset: data.reset_at, diff --git a/src/gates/webhook/with-webhook.ts b/src/gates/webhook/with-webhook.ts index 01207e5..975baf4 100644 --- a/src/gates/webhook/with-webhook.ts +++ b/src/gates/webhook/with-webhook.ts @@ -51,22 +51,22 @@ export interface WebhookState { * * @example * ```ts - * import { chain } from '@supabase/server/core/gates' * import { withWebhook } from '@supabase/server/gates/webhook' * * export default { - * fetch: chain( - * withWebhook({ + * fetch: withWebhook( + * { * provider: { * kind: 'stripe', * secret: process.env.STRIPE_WEBHOOK_SECRET!, * }, - * }), - * )(async (req, ctx) => { - * // ctx.webhook.event is the parsed Stripe event - * // ctx.webhook.rawBody is the raw bytes (preserved here) - * return new Response(null, { status: 204 }) - * }), + * }, + * async (req, ctx) => { + * // ctx.webhook.event is the parsed Stripe event + * // ctx.webhook.rawBody is the raw bytes (preserved here) + * return new Response(null, { status: 204 }) + * }, + * ), * } * ``` */ @@ -85,18 +85,14 @@ export const withWebhook = defineGate< : await verifyStripe(req, rawBody, config.provider) if (!result.ok) { - return { - kind: 'reject', - response: Response.json( - { error: result.error ?? 'invalid_signature' }, - { status: result.status ?? 401 }, - ), - } + return Response.json( + { error: result.error ?? 'invalid_signature' }, + { status: result.status ?? 401 }, + ) } return { - kind: 'pass', - contribution: { + webhook: { event: result.event, rawBody, deliveryId: result.deliveryId, diff --git a/src/gates/x402/with-payment.ts b/src/gates/x402/with-payment.ts index 829bf14..d1fa735 100644 --- a/src/gates/x402/with-payment.ts +++ b/src/gates/x402/with-payment.ts @@ -161,36 +161,27 @@ export const withPayment = defineGate< const pi = await config.stripe.paymentIntents.retrieve(paymentIntentId) if (pi.status === 'succeeded') { - return { - kind: 'pass', - contribution: { intentId: paymentIntentId }, - } - } - return { - kind: 'reject', - response: Response.json( - { - x402Version: 1, - error: 'payment_not_settled', - status: pi.status, - }, - { status: 402 }, - ), + return { payment: { intentId: paymentIntentId } } } + return Response.json( + { + x402Version: 1, + error: 'payment_not_settled', + status: pi.status, + }, + { status: 402 }, + ) } } } - return { - kind: 'reject', - response: await issuePaymentRequired( - req, - ctx.supabaseAdmin, - config, - network, - registerRpc, - ), - } + return issuePaymentRequired( + req, + ctx.supabaseAdmin, + config, + network, + registerRpc, + ) } }, }) From 6508c4a7d9b0eced2556e345d3ca9ce29b50ac41 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Thu, 7 May 2026 03:23:25 -0300 Subject: [PATCH 12/22] refactor(gates)!: infer nested ctx without explicit Base annotations --- README.md | 16 ++++------ src/core/gates/README.md | 26 +++++++++-------- src/core/gates/define-gate.test.ts | 47 ++++++++++++++++++++++++++++++ src/core/gates/define-gate.ts | 16 ++++++++-- src/gates/flag/README.md | 3 +- src/gates/rate-limit/README.md | 46 +++++++++++++---------------- src/gates/x402/README.md | 45 ++++++++++++---------------- src/with-supabase.ts | 8 +++++ 8 files changed, 128 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index cdaa513..e76fb32 100644 --- a/README.md +++ b/README.md @@ -308,26 +308,22 @@ The adapter does not handle CORS — use H3's CORS utilities for that. Compose preconditions around a handler. A **gate** runs against the inbound `Request`, either short-circuits with a `Response` or contributes typed data to a flat key on `ctx`. Each gate is a fetch-handler wrapper — nest them directly the same way `withSupabase` nests, no separate composer. ```ts -import type { SupabaseContext } from '@supabase/server' import { withSupabase } from '@supabase/server' import { withPayment } from '@supabase/server/gates/x402' export default { fetch: withSupabase( { allow: 'user' }, - withPayment( - { stripe, amountCents: 5 }, - async (req, ctx) => { - // ctx.supabase, ctx.userClaims — from withSupabase - // ctx.payment.intentId — from withPayment - return Response.json({ paid: ctx.payment.intentId }) - }, - ), + withPayment({ stripe, amountCents: 5 }, async (req, ctx) => { + // ctx.supabase, ctx.userClaims — from withSupabase + // ctx.payment.intentId — from withPayment + return Response.json({ paid: ctx.payment.intentId }) + }), ), } ``` -`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). Pass `` to thread upstream keys into the gate handler's `ctx` type. +`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). - [`@supabase/server/gates/cloudflare`](src/gates/cloudflare/README.md) — `withTurnstile`, `withAccess`. diff --git a/src/core/gates/README.md b/src/core/gates/README.md index fc318b7..2787986 100644 --- a/src/core/gates/README.md +++ b/src/core/gates/README.md @@ -11,14 +11,13 @@ Gates compose by direct nesting — each `withFoo(config, handler)` is a fetch-h ## Quick start (consumer) ```ts -import type { SupabaseContext } from '@supabase/server' import { withSupabase } from '@supabase/server' import { withFlag } from './gates/with-flag.ts' export default { fetch: withSupabase( { allow: 'user' }, - withFlag( + withFlag( { name: 'beta', evaluate: (req) => req.headers.has('x-beta') }, async (req, ctx) => { // ctx.supabase, ctx.userClaims — from withSupabase @@ -153,13 +152,11 @@ Pick a different key for each gate. Gates that may be applied multiple times can ### Threading state through nested gates -When a gate is wrapped by another (e.g. `withSupabase(... withRateLimit(... handler))`), the outer's keys land on `Base` for the inner. TypeScript can't bidirectionally infer this from the outer call site, so the inner gate's `Base` must be passed explicitly to surface the upstream keys in the handler's `ctx` type: +When a gate is wrapped by another (e.g. `withSupabase(... withRateLimit(... 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 -import type { SupabaseContext } from '@supabase/server' - withSupabase({ allow: 'user' }, - withRateLimit({ limit: 30, windowMs: 60_000, key: ... }, + withRateLimit({ limit: 30, windowMs: 60_000, key: ... }, async (_req, ctx) => { // ctx.userClaims — from withSupabase // ctx.rateLimit — from withRateLimit @@ -169,19 +166,24 @@ withSupabase({ allow: 'user' }, ) ``` -For multi-gate stacks, intersect the accumulated types: +For multi-gate stacks, keep nesting directly: ```ts -type AfterRateLimit = SupabaseContext & { rateLimit: RateLimitState } - withSupabase({ allow: 'user' }, - withRateLimit(..., - withFlag(..., handler), + withRateLimit(..., + withFlag(..., + withTurnstile(..., async (_req, ctx) => { + // ctx.userClaims — from withSupabase + // ctx.rateLimit — from withRateLimit + // ctx.flag — from withFlag + // ctx.turnstile — from withTurnstile + }), + ), ), ) ``` -Without the explicit ``, the inner handler's `ctx` only types the gate's own key — runtime works, types narrow to that one gate's slice. +If you manually call a prerequisite-free gate with a `baseCtx` and no contextual outer wrapper, you can still pass `` explicitly to describe that base context. ## API diff --git a/src/core/gates/define-gate.test.ts b/src/core/gates/define-gate.test.ts index be79a78..f8dd08a 100644 --- a/src/core/gates/define-gate.test.ts +++ b/src/core/gates/define-gate.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi } from 'vitest' +import { withSupabase } from '../../with-supabase.js' +import { withTurnstile } from '../../gates/cloudflare/with-turnstile.js' +import { withFlag } from '../../gates/flag/with-flag.js' +import { withRateLimit } from '../../gates/rate-limit/with-rate-limit.js' import { defineGate } from './define-gate.js' const innerOk = async () => Response.json({ ok: true }) @@ -175,4 +179,47 @@ describe('defineGate', () => { }) expect(await res.json()).toEqual({ tenant: 'acme', stamp: 42 }) }) + + it('infers upstream context through a Supabase gate stack without annotations', () => { + const fetchHandler = withSupabase( + { allow: 'user', cors: false }, + withRateLimit( + { + limit: 30, + windowMs: 60_000, + key: () => 'anon', + }, + withFlag( + { + name: 'beta-feedback', + evaluate: () => true, + }, + withTurnstile( + { + secretKey: 'secret', + expectedAction: 'submit-feedback', + siteverifyUrl: 'http://localhost/siteverify', + }, + async (_req, ctx) => { + const userId: string | undefined = ctx.userClaims?.id + const remaining: number = ctx.rateLimit.remaining + const flagName: string = ctx.flag.name + const action: string = ctx.turnstile.action + const authType: string = ctx.authType + + void userId + void remaining + void flagName + void action + void authType + + return Response.json({ ok: true }) + }, + ), + ), + ), + ) + + void fetchHandler + }) }) diff --git a/src/core/gates/define-gate.ts b/src/core/gates/define-gate.ts index 449cccd..d0e34be 100644 --- a/src/core/gates/define-gate.ts +++ b/src/core/gates/define-gate.ts @@ -140,11 +140,21 @@ export function defineGate< type IsAny = boolean extends (T extends never ? true : false) ? true : false /** - * The shape of a wrapped fetch handler. Required `baseCtx` for gates with - * prerequisites, optional otherwise. + * 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, baseCtx: Base) => Promise) & + ((req: Request) => Promise) : (req: Request, baseCtx: Base) => Promise /** diff --git a/src/gates/flag/README.md b/src/gates/flag/README.md index 8f7c4e3..9771a8e 100644 --- a/src/gates/flag/README.md +++ b/src/gates/flag/README.md @@ -55,13 +55,12 @@ Soft reveal. A `403 Forbidden` tells the caller "this exists, but you can't see Place `withFlag` _after_ `withSupabase` to target by user identity: ```ts -import type { SupabaseContext } from '@supabase/server' import { withSupabase } from '@supabase/server' import { withFlag } from '@supabase/server/gates/flag' withSupabase( { allow: 'user' }, - withFlag( + withFlag( { name: 'beta-checkout', evaluate: async (req) => { diff --git a/src/gates/rate-limit/README.md b/src/gates/rate-limit/README.md index 1e59f77..2960ba6 100644 --- a/src/gates/rate-limit/README.md +++ b/src/gates/rate-limit/README.md @@ -3,23 +3,20 @@ Fixed-window rate-limit gate backed by Supabase Postgres. Counts hits per key within a window via an atomic SQL function; rejects with `429 Too Many Requests` once the limit is exceeded. ```ts -import { createClient } from '@supabase/supabase-js' +import { withSupabase } from '@supabase/server' import { withRateLimit } from '@supabase/server/gates/rate-limit' -const supabaseAdmin = createClient( - process.env.SUPABASE_URL!, - process.env.SUPABASE_SECRET_KEY!, -) - export default { - fetch: withRateLimit( - { - limit: 60, - windowMs: 60_000, - key: (req) => req.headers.get('cf-connecting-ip') ?? 'anon', - client: supabaseAdmin, - }, - async (req, ctx) => Response.json({ remaining: ctx.rateLimit.remaining }), + fetch: withSupabase( + { allow: 'always' }, + withRateLimit( + { + limit: 60, + windowMs: 60_000, + key: (req) => req.headers.get('cf-connecting-ip') ?? 'anon', + }, + async (req, ctx) => Response.json({ remaining: ctx.rateLimit.remaining }), + ), ), } ``` @@ -70,17 +67,16 @@ $$; alter table public._supabase_server_rate_limits enable row level security; ``` -The gate calls `client.rpc('_supabase_server_rate_limit_hit', { p_key, p_window_ms })`. Override the function name via `rpc:` in the config if you'd rather pick your own. +The gate calls `ctx.supabaseAdmin.rpc('_supabase_server_rate_limit_hit', { p_key, p_window_ms })`. Override the function name via `rpc:` in the config if you'd rather pick your own. ## Config -| Field | Type | Description | -| ---------- | --------------------------------------------- | ----------------------------------------------------------- | -| `limit` | `number` | Maximum hits per `windowMs` per key. | -| `windowMs` | `number` | Window length in milliseconds. | -| `key` | `(req: Request) => string \| Promise` | Bucketing key. Per-IP, per-user, per-tenant, etc. | -| `client` | `SupabaseRpcClient` | Supabase admin client (any structurally compatible object). | -| `rpc` | `string?` | RPC name. Default: `_supabase_server_rate_limit_hit`. | +| Field | Type | Description | +| ---------- | --------------------------------------------- | ----------------------------------------------------- | +| `limit` | `number` | Maximum hits per `windowMs` per key. | +| `windowMs` | `number` | Window length in milliseconds. | +| `key` | `(req: Request) => string \| Promise` | Bucketing key. Per-IP, per-user, per-tenant, etc. | +| `rpc` | `string?` | RPC name. Default: `_supabase_server_rate_limit_hit`. | ## Contribution @@ -100,24 +96,22 @@ ctx.rateLimit = { ## Composing with `withSupabase` ```ts -import type { SupabaseContext } from '@supabase/server' import { withSupabase } from '@supabase/server' withSupabase( { allow: 'user' }, - withRateLimit( + withRateLimit( { limit: 30, windowMs: 60_000, key: (req) => req.headers.get('cf-connecting-ip') ?? 'anon', - client: supabaseAdmin, }, async (_req, ctx) => Response.json({ user: ctx.userClaims!.id }), ), ) ``` -The `` annotation threads the host's keys into the inner handler's `ctx`. +The inner handler's `ctx` includes both `withSupabase` keys and `ctx.rateLimit`. ## See also diff --git a/src/gates/x402/README.md b/src/gates/x402/README.md index 1290cb4..0c0940c 100644 --- a/src/gates/x402/README.md +++ b/src/gates/x402/README.md @@ -8,21 +8,19 @@ Persistence (deposit-address → PaymentIntent-id mapping) lives in Supabase Pos ```ts import Stripe from 'stripe' -import { createClient } from '@supabase/supabase-js' +import { withSupabase } from '@supabase/server' import { withPayment } from '@supabase/server/gates/x402' const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2026-03-04.preview' as never, }) -const supabaseAdmin = createClient( - process.env.SUPABASE_URL!, - process.env.SUPABASE_SECRET_KEY!, -) export default { - fetch: withPayment( - { stripe, amountCents: 1, client: supabaseAdmin }, - async (req, ctx) => Response.json({ ok: true, paid: ctx.payment.intentId }), + fetch: withSupabase( + { allow: 'always' }, + withPayment({ stripe, amountCents: 1 }, async (req, ctx) => + Response.json({ ok: true, paid: ctx.payment.intentId }), + ), ), } ``` @@ -79,34 +77,29 @@ Override the function names via `registerRpc` / `lookupRpc` in the config if you ## Config -| Field | Type | Description | -| ------------- | ------------------- | ------------------------------------------------------ | -| `stripe` | `StripeLike` | Stripe client (or any structurally compatible object). | -| `amountCents` | `number` | Price per call in USD cents. Stripe converts to USDC. | -| `network` | `Network?` | `'base' \| 'tempo' \| 'solana'`. Default `'base'`. | -| `client` | `SupabaseRpcClient` | Supabase admin client. | -| `registerRpc` | `string?` | Default: `_supabase_server_x402_register`. | -| `lookupRpc` | `string?` | Default: `_supabase_server_x402_lookup`. | +| Field | Type | Description | +| ------------- | ------------ | ------------------------------------------------------ | +| `stripe` | `StripeLike` | Stripe client (or any structurally compatible object). | +| `amountCents` | `number` | Price per call in USD cents. Stripe converts to USDC. | +| `network` | `Network?` | `'base' \| 'tempo' \| 'solana'`. Default `'base'`. | +| `registerRpc` | `string?` | Default: `_supabase_server_x402_register`. | +| `lookupRpc` | `string?` | Default: `_supabase_server_x402_lookup`. | `StripeLike` is structurally typed — this package does not depend on the `stripe` SDK at runtime or types-level. Pass any object exposing `paymentIntents.create` and `paymentIntents.retrieve`. ## Composing with `withSupabase` ```ts -import type { SupabaseContext } from '@supabase/server' import { withSupabase } from '@supabase/server' withSupabase( { allow: 'user' }, - withPayment( - { stripe, amountCents: 5, client: supabaseAdmin }, - async (req, ctx) => { - // ctx.supabase is the user-scoped client (from withSupabase) - // ctx.payment.intentId is the settled PaymentIntent id - const { data } = await ctx.supabase.from('premium_reports').select() - return Response.json({ data, paid: ctx.payment.intentId }) - }, - ), + withPayment({ stripe, amountCents: 5 }, async (req, ctx) => { + // ctx.supabase is the user-scoped client (from withSupabase) + // ctx.payment.intentId is the settled PaymentIntent id + const { data } = await ctx.supabase.from('premium_reports').select() + return Response.json({ data, paid: ctx.payment.intentId }) + }), ) ``` diff --git a/src/with-supabase.ts b/src/with-supabase.ts index 54a7aab..2e849a0 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, From 20e2b899002082d845ffb3d79f983770c7267369 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Thu, 7 May 2026 03:50:46 -0300 Subject: [PATCH 13/22] test(gates): add regression tests and runtime check for missing gate key --- src/core/gates/README.md | 4 +- src/core/gates/define-gate.test.ts | 61 ++++++++++++++++++++++++++++++ src/core/gates/define-gate.ts | 12 ++++++ 3 files changed, 76 insertions(+), 1 deletion(-) diff --git a/src/core/gates/README.md b/src/core/gates/README.md index 2787986..4f8704f 100644 --- a/src/core/gates/README.md +++ b/src/core/gates/README.md @@ -152,7 +152,9 @@ Pick a different key for each gate. Gates that may be applied multiple times can ### Threading state through nested gates -When a gate is wrapped by another (e.g. `withSupabase(... withRateLimit(... 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: +When a gate is wrapped by another (e.g. `withSupabase(... withRateLimit(... 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. + +> **How this works.** The inference is enabled by a callable-intersection in `Wrapped` (see the JSDoc on that type in `define-gate.ts`). The two-signature form is load-bearing — collapsing it to a single optional `(req, baseCtx?: Base)` looks equivalent at runtime but breaks contextual `Base` propagation through nested generic calls. Don't simplify it without reading the comment. ```ts withSupabase({ allow: 'user' }, diff --git a/src/core/gates/define-gate.test.ts b/src/core/gates/define-gate.test.ts index f8dd08a..ec0c3fd 100644 --- a/src/core/gates/define-gate.test.ts +++ b/src/core/gates/define-gate.test.ts @@ -180,6 +180,67 @@ describe('defineGate', () => { 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', () => { const fetchHandler = withSupabase( { allow: 'user', cors: false }, diff --git a/src/core/gates/define-gate.ts b/src/core/gates/define-gate.ts index d0e34be..02ec1b4 100644 --- a/src/core/gates/define-gate.ts +++ b/src/core/gates/define-gate.ts @@ -111,6 +111,18 @@ export function defineGate< 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], From 06830535f2af03d35cce2f7cee9d71de8617269a Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Thu, 7 May 2026 04:32:37 -0300 Subject: [PATCH 14/22] test(gates): rename authType to authMode in nested ctx test --- src/core/gates/define-gate.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/gates/define-gate.test.ts b/src/core/gates/define-gate.test.ts index ec0c3fd..c8190d2 100644 --- a/src/core/gates/define-gate.test.ts +++ b/src/core/gates/define-gate.test.ts @@ -266,13 +266,13 @@ describe('defineGate', () => { const remaining: number = ctx.rateLimit.remaining const flagName: string = ctx.flag.name const action: string = ctx.turnstile.action - const authType: string = ctx.authType + const authMode: string = ctx.authMode void userId void remaining void flagName void action - void authType + void authMode return Response.json({ ok: true }) }, From 359f757f442eb187c3dae3ff6da2f86c1a2373eb Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Thu, 7 May 2026 04:39:24 -0300 Subject: [PATCH 15/22] refactor(gates): annotate built-in gates with explicit GateFactory types --- src/gates/cloudflare/with-access.ts | 6 +++--- src/gates/cloudflare/with-turnstile.ts | 9 +++++++-- src/gates/flag/with-flag.ts | 6 +++--- src/gates/rate-limit/with-rate-limit.ts | 9 +++++++-- src/gates/webhook/with-webhook.ts | 9 +++++++-- src/gates/x402/with-payment.ts | 9 +++++++-- 6 files changed, 34 insertions(+), 14 deletions(-) diff --git a/src/gates/cloudflare/with-access.ts b/src/gates/cloudflare/with-access.ts index a72408b..00f0c4f 100644 --- a/src/gates/cloudflare/with-access.ts +++ b/src/gates/cloudflare/with-access.ts @@ -13,7 +13,7 @@ import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose' -import { defineGate } from '../../core/gates/index.js' +import { defineGate, type GateFactory } from '../../core/gates/index.js' const HEADER_NAME = 'cf-access-jwt-assertion' @@ -73,12 +73,12 @@ export interface AccessState { * } * ``` */ -export const withAccess = defineGate< +export const withAccess: GateFactory< 'access', WithAccessConfig, Record, AccessState ->({ +> = defineGate<'access', WithAccessConfig, Record, AccessState>({ key: 'access', run: (config) => { const issuer = `https://${config.teamDomain}` diff --git a/src/gates/cloudflare/with-turnstile.ts b/src/gates/cloudflare/with-turnstile.ts index 0262d74..4d110fa 100644 --- a/src/gates/cloudflare/with-turnstile.ts +++ b/src/gates/cloudflare/with-turnstile.ts @@ -8,7 +8,7 @@ * @see https://developers.cloudflare.com/turnstile/get-started/server-side-validation/ */ -import { defineGate } from '../../core/gates/index.js' +import { defineGate, type GateFactory } from '../../core/gates/index.js' const SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify' @@ -88,7 +88,12 @@ interface SiteverifyResponse { * } * ``` */ -export const withTurnstile = defineGate< +export const withTurnstile: GateFactory< + 'turnstile', + WithTurnstileConfig, + Record, + TurnstileState +> = defineGate< 'turnstile', WithTurnstileConfig, Record, diff --git a/src/gates/flag/with-flag.ts b/src/gates/flag/with-flag.ts index 77a41ab..b6fae9c 100644 --- a/src/gates/flag/with-flag.ts +++ b/src/gates/flag/with-flag.ts @@ -7,7 +7,7 @@ * Statsig, a header check, a database lookup). */ -import { defineGate } from '../../core/gates/index.js' +import { defineGate, type GateFactory } from '../../core/gates/index.js' export interface WithFlagConfig { /** Human-readable name for the flag, recorded in `ctx.flag.name`. */ @@ -85,12 +85,12 @@ export interface FlagState { * }) * ``` */ -export const withFlag = defineGate< +export const withFlag: GateFactory< 'flag', WithFlagConfig, Record, FlagState ->({ +> = defineGate<'flag', WithFlagConfig, Record, FlagState>({ key: 'flag', run: (config) => async (req) => { const result = await config.evaluate(req) diff --git a/src/gates/rate-limit/with-rate-limit.ts b/src/gates/rate-limit/with-rate-limit.ts index 3507ba8..71bc386 100644 --- a/src/gates/rate-limit/with-rate-limit.ts +++ b/src/gates/rate-limit/with-rate-limit.ts @@ -12,7 +12,7 @@ * that provides `supabaseAdmin`). */ -import { defineGate } from '../../core/gates/index.js' +import { defineGate, type GateFactory } from '../../core/gates/index.js' const DEFAULT_RPC = '_supabase_server_rate_limit_hit' @@ -96,7 +96,12 @@ interface RpcResult { * } * ``` */ -export const withRateLimit = defineGate< +export const withRateLimit: GateFactory< + 'rateLimit', + WithRateLimitConfig, + { supabaseAdmin: SupabaseRpcClient }, + RateLimitState +> = defineGate< 'rateLimit', WithRateLimitConfig, { supabaseAdmin: SupabaseRpcClient }, diff --git a/src/gates/webhook/with-webhook.ts b/src/gates/webhook/with-webhook.ts index 975baf4..31cb329 100644 --- a/src/gates/webhook/with-webhook.ts +++ b/src/gates/webhook/with-webhook.ts @@ -7,7 +7,7 @@ * `verify` function to plug in others (Svix/Resend, GitHub, Slack, Shopify). */ -import { defineGate } from '../../core/gates/index.js' +import { defineGate, type GateFactory } from '../../core/gates/index.js' const FIVE_MIN_MS = 5 * 60 * 1000 @@ -70,7 +70,12 @@ export interface WebhookState { * } * ``` */ -export const withWebhook = defineGate< +export const withWebhook: GateFactory< + 'webhook', + WithWebhookConfig, + Record, + WebhookState +> = defineGate< 'webhook', WithWebhookConfig, Record, diff --git a/src/gates/x402/with-payment.ts b/src/gates/x402/with-payment.ts index d1fa735..95637cd 100644 --- a/src/gates/x402/with-payment.ts +++ b/src/gates/x402/with-payment.ts @@ -13,7 +13,7 @@ * @see https://www.x402.org */ -import { defineGate } from '../../core/gates/index.js' +import { defineGate, type GateFactory } from '../../core/gates/index.js' const DEFAULT_REGISTER_RPC = '_supabase_server_x402_register' const DEFAULT_LOOKUP_RPC = '_supabase_server_x402_lookup' @@ -135,7 +135,12 @@ export interface PaymentState { * } * ``` */ -export const withPayment = defineGate< +export const withPayment: GateFactory< + 'payment', + WithPaymentConfig, + { supabaseAdmin: SupabaseRpcClient }, + PaymentState +> = defineGate< 'payment', WithPaymentConfig, { supabaseAdmin: SupabaseRpcClient }, From e62e8305b1d41471c7afcea37adfd0725c9f8e4f Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Thu, 7 May 2026 04:46:06 -0300 Subject: [PATCH 16/22] refactor(gates)!: rename GateFactory type to Gate --- src/core/gates/README.md | 10 +++++----- src/core/gates/define-gate.ts | 13 +++++++------ src/core/gates/index.ts | 2 +- src/gates/cloudflare/with-access.ts | 4 ++-- src/gates/cloudflare/with-turnstile.ts | 4 ++-- src/gates/flag/with-flag.ts | 4 ++-- src/gates/rate-limit/with-rate-limit.ts | 4 ++-- src/gates/webhook/with-webhook.ts | 4 ++-- src/gates/x402/with-payment.ts | 4 ++-- 9 files changed, 25 insertions(+), 24 deletions(-) diff --git a/src/core/gates/README.md b/src/core/gates/README.md index 4f8704f..274bf7d 100644 --- a/src/core/gates/README.md +++ b/src/core/gates/README.md @@ -189,8 +189,8 @@ If you manually call a prerequisite-free gate with a `baseCtx` and no contextual ## API -| Export | Description | -| -------------------------------------------- | --------------------------------------------------------------------- | -| `defineGate(spec)` | Author helper: declare a gate. Returns a `(config, handler)` factory. | -| `Conflict` | Sentinel string returned when a gate would shadow an upstream key. | -| `GateFactory` | The shape of a gate factory produced by `defineGate`. | +| 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.ts b/src/core/gates/define-gate.ts index 02ec1b4..4c98450 100644 --- a/src/core/gates/define-gate.ts +++ b/src/core/gates/define-gate.ts @@ -11,7 +11,7 @@ import type { Conflict } from './types.js' * returned object are ignored at runtime, and TypeScript flags them at * fresh-literal returns via excess-property checks. * - * The returned factory has the shape `withFoo(config, handler) → fetchHandler`, + * 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: @@ -29,7 +29,7 @@ import type { Conflict } from './types.js' * * @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 factory accepts. + * @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 }`. @@ -104,7 +104,7 @@ export function defineGate< req: Request, ctx: In, ) => Promise -}): GateFactory { +}): Gate { return ((config: Config, handler: never) => { const inner = spec.run(config) return async (req: Request, baseCtx?: object) => { @@ -131,11 +131,12 @@ export function defineGate< handler as unknown as (req: Request, ctx: object) => Promise )(req, ctx) } - }) as GateFactory + }) as Gate } /** - * The factory shape that {@link defineGate} produces. Two arms: + * 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. @@ -196,7 +197,7 @@ type NoConflict = ? Conflict : object -export interface GateFactory< +export interface Gate< Key extends string, Config, In extends object, diff --git a/src/core/gates/index.ts b/src/core/gates/index.ts index 4220a1d..53283d7 100644 --- a/src/core/gates/index.ts +++ b/src/core/gates/index.ts @@ -12,5 +12,5 @@ */ export { defineGate } from './define-gate.js' -export type { GateFactory } from './define-gate.js' +export type { Gate } from './define-gate.js' export type { Conflict } from './types.js' diff --git a/src/gates/cloudflare/with-access.ts b/src/gates/cloudflare/with-access.ts index 00f0c4f..8ab3dda 100644 --- a/src/gates/cloudflare/with-access.ts +++ b/src/gates/cloudflare/with-access.ts @@ -13,7 +13,7 @@ import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose' -import { defineGate, type GateFactory } from '../../core/gates/index.js' +import { defineGate, type Gate } from '../../core/gates/index.js' const HEADER_NAME = 'cf-access-jwt-assertion' @@ -73,7 +73,7 @@ export interface AccessState { * } * ``` */ -export const withAccess: GateFactory< +export const withAccess: Gate< 'access', WithAccessConfig, Record, diff --git a/src/gates/cloudflare/with-turnstile.ts b/src/gates/cloudflare/with-turnstile.ts index 4d110fa..0c7715d 100644 --- a/src/gates/cloudflare/with-turnstile.ts +++ b/src/gates/cloudflare/with-turnstile.ts @@ -8,7 +8,7 @@ * @see https://developers.cloudflare.com/turnstile/get-started/server-side-validation/ */ -import { defineGate, type GateFactory } from '../../core/gates/index.js' +import { defineGate, type Gate } from '../../core/gates/index.js' const SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify' @@ -88,7 +88,7 @@ interface SiteverifyResponse { * } * ``` */ -export const withTurnstile: GateFactory< +export const withTurnstile: Gate< 'turnstile', WithTurnstileConfig, Record, diff --git a/src/gates/flag/with-flag.ts b/src/gates/flag/with-flag.ts index b6fae9c..80b80e9 100644 --- a/src/gates/flag/with-flag.ts +++ b/src/gates/flag/with-flag.ts @@ -7,7 +7,7 @@ * Statsig, a header check, a database lookup). */ -import { defineGate, type GateFactory } from '../../core/gates/index.js' +import { defineGate, type Gate } from '../../core/gates/index.js' export interface WithFlagConfig { /** Human-readable name for the flag, recorded in `ctx.flag.name`. */ @@ -85,7 +85,7 @@ export interface FlagState { * }) * ``` */ -export const withFlag: GateFactory< +export const withFlag: Gate< 'flag', WithFlagConfig, Record, diff --git a/src/gates/rate-limit/with-rate-limit.ts b/src/gates/rate-limit/with-rate-limit.ts index 71bc386..3fec188 100644 --- a/src/gates/rate-limit/with-rate-limit.ts +++ b/src/gates/rate-limit/with-rate-limit.ts @@ -12,7 +12,7 @@ * that provides `supabaseAdmin`). */ -import { defineGate, type GateFactory } from '../../core/gates/index.js' +import { defineGate, type Gate } from '../../core/gates/index.js' const DEFAULT_RPC = '_supabase_server_rate_limit_hit' @@ -96,7 +96,7 @@ interface RpcResult { * } * ``` */ -export const withRateLimit: GateFactory< +export const withRateLimit: Gate< 'rateLimit', WithRateLimitConfig, { supabaseAdmin: SupabaseRpcClient }, diff --git a/src/gates/webhook/with-webhook.ts b/src/gates/webhook/with-webhook.ts index 31cb329..3d6ba89 100644 --- a/src/gates/webhook/with-webhook.ts +++ b/src/gates/webhook/with-webhook.ts @@ -7,7 +7,7 @@ * `verify` function to plug in others (Svix/Resend, GitHub, Slack, Shopify). */ -import { defineGate, type GateFactory } from '../../core/gates/index.js' +import { defineGate, type Gate } from '../../core/gates/index.js' const FIVE_MIN_MS = 5 * 60 * 1000 @@ -70,7 +70,7 @@ export interface WebhookState { * } * ``` */ -export const withWebhook: GateFactory< +export const withWebhook: Gate< 'webhook', WithWebhookConfig, Record, diff --git a/src/gates/x402/with-payment.ts b/src/gates/x402/with-payment.ts index 95637cd..1910f11 100644 --- a/src/gates/x402/with-payment.ts +++ b/src/gates/x402/with-payment.ts @@ -13,7 +13,7 @@ * @see https://www.x402.org */ -import { defineGate, type GateFactory } from '../../core/gates/index.js' +import { defineGate, type Gate } from '../../core/gates/index.js' const DEFAULT_REGISTER_RPC = '_supabase_server_x402_register' const DEFAULT_LOOKUP_RPC = '_supabase_server_x402_lookup' @@ -135,7 +135,7 @@ export interface PaymentState { * } * ``` */ -export const withPayment: GateFactory< +export const withPayment: Gate< 'payment', WithPaymentConfig, { supabaseAdmin: SupabaseRpcClient }, From 4b6827fd994566be946ac4ba170bc382735540c0 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Fri, 8 May 2026 10:10:29 -0300 Subject: [PATCH 17/22] feat(gates/webhook): add built-in GitHub provider --- README.md | 2 +- src/gates/webhook/README.md | 69 ++++++++++++--- src/gates/webhook/with-webhook.test.ts | 113 +++++++++++++++++++++++++ src/gates/webhook/with-webhook.ts | 77 ++++++++++++++--- 4 files changed, 237 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 7d5442d..3b99199 100644 --- a/README.md +++ b/README.md @@ -472,7 +472,7 @@ No. `@supabase/ssr` handles cookie-based session management for frameworks like | `@supabase/server/gates/cloudflare` | `withTurnstile`, `withAccess` (Cloudflare bot-check + Zero Trust JWT) | | `@supabase/server/gates/flag` | `withFlag` (provider-agnostic feature-flag gate) | | `@supabase/server/gates/rate-limit` | `withRateLimit` (fixed-window rate limit; pluggable store) | -| `@supabase/server/gates/webhook` | `withWebhook` (HMAC signature verification, Stripe + custom) | +| `@supabase/server/gates/webhook` | `withWebhook` (HMAC signature verification, Stripe + GitHub + custom) | | `@supabase/server/gates/x402` | `withPayment` (Stripe-facilitated x402 paywall gate) | ## Documentation diff --git a/src/gates/webhook/README.md b/src/gates/webhook/README.md index 75c0154..8e7199c 100644 --- a/src/gates/webhook/README.md +++ b/src/gates/webhook/README.md @@ -9,13 +9,14 @@ export default { fetch: withWebhook( { provider: { - kind: 'stripe', - secret: process.env.STRIPE_WEBHOOK_SECRET!, + kind: 'github', + secret: process.env.GITHUB_WEBHOOK_SECRET!, }, }, async (req, ctx) => { - const event = ctx.webhook.event as { type: string } - if (event.type === 'payment_intent.succeeded') { + const event = req.headers.get('x-github-event') + if (event === 'pull_request') { + const pr = ctx.webhook.event as { action: string } // … } return new Response(null, { status: 204 }) @@ -47,27 +48,71 @@ withWebhook({ }) ``` +### GitHub + +Verifies the `X-Hub-Signature-256` header (`sha256=`), rejects on: + +- missing header (`signature_missing`) +- missing `sha256=` prefix (`signature_malformed`) +- HMAC mismatch (`signature_invalid`) + +GitHub's signing scheme has no timestamp, so there's no replay window — pin events to the `X-GitHub-Delivery` UUID for idempotency (see [Idempotency](#idempotency)). The event type is delivered out-of-band in the `X-GitHub-Event` header; the gate exposes it via `req.headers`. + +Key rotation works the same as Stripe: pass `secret: ['new', 'old']` to accept either. + +```ts +withWebhook( + { + provider: { + kind: 'github', + secret: process.env.GITHUB_WEBHOOK_SECRET!, + }, + }, + async (req, ctx) => { + switch (req.headers.get('x-github-event')) { + case 'pull_request': { + const pr = ctx.webhook.event as { action: string } + // … + break + } + case 'push': { + // … + break + } + } + return new Response(null, { status: 204 }) + }, +) +``` + ### Custom -For any other provider (Svix/Resend, GitHub, Slack, Shopify, in-house) supply a `verify` function. The gate calls it with the raw body already consumed: +For any other provider (Svix/Resend, Slack, Shopify, in-house) supply a `verify` function. The gate calls it with the raw body already consumed. Slack, for instance, signs `v0::` and exposes both pieces in headers: ```ts withWebhook({ provider: { kind: 'custom', async verify(req, rawBody) { - const signature = req.headers.get('x-hub-signature-256') ?? '' + const ts = req.headers.get('x-slack-request-timestamp') ?? '' + const sig = req.headers.get('x-slack-signature') ?? '' + if (Math.abs(Date.now() / 1000 - Number(ts)) > 5 * 60) { + return { ok: false, error: 'signature_expired' } + } const expected = - 'sha256=' + (await hmacHex(process.env.GH_WEBHOOK_SECRET!, rawBody)) - if (!timingSafeEqual(signature, expected)) { + 'v0=' + + (await hmacHex( + process.env.SLACK_SIGNING_SECRET!, + `v0:${ts}:${rawBody}`, + )) + if (!timingSafeEqual(sig, expected)) { return { ok: false, error: 'signature_invalid' } } - const event = JSON.parse(rawBody) return { ok: true, - event, - deliveryId: req.headers.get('x-github-delivery') ?? '', - timestamp: Date.now(), + event: JSON.parse(rawBody), + deliveryId: req.headers.get('x-slack-request-id') ?? '', + timestamp: Number(ts) * 1000, } }, }, diff --git a/src/gates/webhook/with-webhook.test.ts b/src/gates/webhook/with-webhook.test.ts index c21439a..f7a6806 100644 --- a/src/gates/webhook/with-webhook.test.ts +++ b/src/gates/webhook/with-webhook.test.ts @@ -147,6 +147,119 @@ describe('withWebhook (stripe)', () => { }) }) +describe('withWebhook (github)', () => { + const GH_SECRET = 'ghsec_test' + + it('admits a valid GitHub signature and contributes parsed event', async () => { + const body = JSON.stringify({ action: 'opened', number: 7 }) + const sig = 'sha256=' + (await hmacHex(GH_SECRET, body)) + + const inner = vi.fn(async (_req: Request, ctx) => { + expect(ctx.webhook.deliveryId).toBe( + '72d3162e-cc78-11e3-81ab-4c9367dc0958', + ) + expect((ctx.webhook.event as { action: string }).action).toBe('opened') + expect(ctx.webhook.rawBody).toBe(body) + expect(ctx.webhook.timestamp).toBe(1_700_000_000_000) + return Response.json({ ok: true }) + }) + + const handler = withWebhook( + { provider: { kind: 'github', secret: GH_SECRET } }, + inner, + ) + + const res = await handler( + new Request('http://localhost/', { + method: 'POST', + headers: { + 'x-hub-signature-256': sig, + 'x-github-delivery': '72d3162e-cc78-11e3-81ab-4c9367dc0958', + 'x-github-event': 'pull_request', + }, + body, + }), + ) + + expect(res.status).toBe(200) + expect(inner).toHaveBeenCalledOnce() + }) + + it('rejects when the signature header is missing', async () => { + const handler = withWebhook( + { provider: { kind: 'github', secret: GH_SECRET } }, + innerOk, + ) + + const res = await handler( + new Request('http://localhost/', { method: 'POST', body: '{}' }), + ) + + expect(res.status).toBe(401) + expect((await res.json()).error).toBe('signature_missing') + }) + + it('rejects when the signature header is missing the sha256= prefix', async () => { + const body = '{}' + const v = await hmacHex(GH_SECRET, body) + + const handler = withWebhook( + { provider: { kind: 'github', secret: GH_SECRET } }, + innerOk, + ) + + const res = await handler( + new Request('http://localhost/', { + method: 'POST', + headers: { 'x-hub-signature-256': v }, // no sha256= prefix + body, + }), + ) + + expect(res.status).toBe(401) + expect((await res.json()).error).toBe('signature_malformed') + }) + + it('rejects on a bad signature', async () => { + const handler = withWebhook( + { provider: { kind: 'github', secret: GH_SECRET } }, + innerOk, + ) + + const res = await handler( + new Request('http://localhost/', { + method: 'POST', + headers: { 'x-hub-signature-256': 'sha256=' + 'de'.repeat(32) }, + body: '{"action":"opened"}', + }), + ) + + expect(res.status).toBe(401) + expect((await res.json()).error).toBe('signature_invalid') + }) + + it('accepts any of multiple secrets (rotation)', async () => { + const body = '{"action":"opened"}' + const oldSecret = 'ghsec_old' + const sig = 'sha256=' + (await hmacHex(oldSecret, body)) + + const handler = withWebhook( + { provider: { kind: 'github', secret: ['ghsec_new', oldSecret] } }, + innerOk, + ) + + const res = await handler( + new Request('http://localhost/', { + method: 'POST', + headers: { 'x-hub-signature-256': sig }, + body, + }), + ) + + expect(res.status).toBe(200) + }) +}) + describe('withWebhook (custom)', () => { it('passes when the custom verifier returns ok', async () => { const verify = vi.fn(async (_req: Request, body: string) => ({ diff --git a/src/gates/webhook/with-webhook.ts b/src/gates/webhook/with-webhook.ts index 3d6ba89..6b09a6d 100644 --- a/src/gates/webhook/with-webhook.ts +++ b/src/gates/webhook/with-webhook.ts @@ -2,9 +2,10 @@ * Webhook signature verification gate. * * Verifies the HMAC signature on an inbound webhook against a shared secret, - * checks the replay window, and contributes the parsed event + raw body to - * `ctx.webhook`. Stripe is the canonical provider; supply a custom - * `verify` function to plug in others (Svix/Resend, GitHub, Slack, Shopify). + * checks the replay window where the provider supplies one, and contributes + * the parsed event + raw body to `ctx.webhook`. Stripe and GitHub are + * built-in; supply a custom `verify` function to plug in others + * (Svix/Resend, Slack, Shopify, in-house). */ import { defineGate, type Gate } from '../../core/gates/index.js' @@ -13,6 +14,7 @@ const FIVE_MIN_MS = 5 * 60 * 1000 export type WebhookProvider = | { kind: 'stripe'; secret: string | string[]; toleranceMs?: number } + | { kind: 'github'; secret: string | string[] } | { kind: 'custom' /** @@ -57,13 +59,14 @@ export interface WebhookState { * fetch: withWebhook( * { * provider: { - * kind: 'stripe', - * secret: process.env.STRIPE_WEBHOOK_SECRET!, + * kind: 'github', + * secret: process.env.GITHUB_WEBHOOK_SECRET!, * }, * }, * async (req, ctx) => { - * // ctx.webhook.event is the parsed Stripe event - * // ctx.webhook.rawBody is the raw bytes (preserved here) + * // ctx.webhook.event is the parsed GitHub event payload + * // ctx.webhook.deliveryId is the X-GitHub-Delivery uuid + * // req.headers.get('x-github-event') tells you which event fired * return new Response(null, { status: 204 }) * }, * ), @@ -84,10 +87,19 @@ export const withWebhook: Gate< key: 'webhook', run: (config) => async (req) => { const rawBody = await req.text() - const result = - config.provider.kind === 'custom' - ? await config.provider.verify(req, rawBody) - : await verifyStripe(req, rawBody, config.provider) + const { provider } = config + let result: WebhookVerifyResult + switch (provider.kind) { + case 'stripe': + result = await verifyStripe(req, rawBody, provider) + break + case 'github': + result = await verifyGithub(req, rawBody, provider) + break + case 'custom': + result = await provider.verify(req, rawBody) + break + } if (!result.ok) { return Response.json( @@ -160,6 +172,49 @@ async function verifyStripe( } } +async function verifyGithub( + req: Request, + rawBody: string, + provider: { kind: 'github'; secret: string | string[] }, +): Promise { + const header = req.headers.get('x-hub-signature-256') + if (!header) return { ok: false, error: 'signature_missing' } + + const eq = header.indexOf('=') + if (eq < 0 || header.slice(0, eq) !== 'sha256') { + return { ok: false, error: 'signature_malformed' } + } + const provided = header.slice(eq + 1) + + const secrets = Array.isArray(provider.secret) + ? provider.secret + : [provider.secret] + + let matched = false + for (const secret of secrets) { + const expected = await hmacSha256Hex(secret, rawBody) + if (timingSafeEqualHex(expected, provided)) { + matched = true + break + } + } + if (!matched) return { ok: false, error: 'signature_invalid' } + + let event: unknown + try { + event = JSON.parse(rawBody) + } catch { + return { ok: false, error: 'body_not_json' } + } + + return { + ok: true, + event, + deliveryId: req.headers.get('x-github-delivery') ?? '', + timestamp: Date.now(), + } +} + function parseStripeHeader(header: string): { t: number; v1: string[] } | null { const parts = header.split(',') let t: number | null = null From 69eda65585b3f13009be09cc86ae2991866340e4 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Fri, 8 May 2026 11:02:47 -0300 Subject: [PATCH 18/22] docs(gates): reframe as portable extensibility layer --- README.md | 4 ++-- src/core/gates/README.md | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3b99199..409525e 100644 --- a/README.md +++ b/README.md @@ -299,7 +299,7 @@ See [docs/adapters/h3.md](docs/adapters/h3.md) for per-route auth, Nuxt server-m ## Gates -Compose preconditions around a handler. A **gate** runs against the inbound `Request`, either short-circuits with a `Response` or contributes typed data to a flat key on `ctx`. Each gate is a fetch-handler wrapper — nest them directly the same way `withSupabase` nests, no separate composer. +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' @@ -490,7 +490,7 @@ No. `@supabase/ssr` handles cookie-based session management for frameworks like | 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 compose preconditions (gates) around a handler? | [`src/core/gates/README.md`](src/core/gates/README.md) | +| How do I extend a handler with a gate (rate-limit, webhook, …)? | [`src/core/gates/README.md`](src/core/gates/README.md) | | How do I gate a route behind a Cloudflare check? | [`src/gates/cloudflare/README.md`](src/gates/cloudflare/README.md) | | How do I gate a route behind a feature flag? | [`src/gates/flag/README.md`](src/gates/flag/README.md) | | How do I rate-limit a route? | [`src/gates/rate-limit/README.md`](src/gates/rate-limit/README.md) | diff --git a/src/core/gates/README.md b/src/core/gates/README.md index 274bf7d..03a69cf 100644 --- a/src/core/gates/README.md +++ b/src/core/gates/README.md @@ -1,6 +1,8 @@ # `@supabase/server/core/gates` -Composable preconditions for fetch handlers. A **gate** is a small unit that runs against an inbound `Request` and either short-circuits by returning a `Response` or contributes typed data to a flat key on `ctx` for the handler. +The portable extensibility layer for `@supabase/server`. A **gate** is a small, typed plugin — a fetch-handler wrapper that runs against an inbound `Request` and either short-circuits with a `Response` or contributes typed data to a flat key on `ctx` for the handler. Anyone can publish a gate as a standalone npm package; the built-ins (`withRateLimit`, `withWebhook`, `withPayment`, …) sit alongside third-party gates with no special status, all built on the same primitive. + +Because gates are plain `(req, ctx) => Response` wrappers 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: From b5b48516f6ec8d20f72ee6222a573e9aa5020770 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Fri, 8 May 2026 11:06:58 -0300 Subject: [PATCH 19/22] docs(gates): document composition rules for stacking gates --- src/core/gates/README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/core/gates/README.md b/src/core/gates/README.md index 03a69cf..42f9125 100644 --- a/src/core/gates/README.md +++ b/src/core/gates/README.md @@ -58,6 +58,14 @@ 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. Gates with `In` keys also require `baseCtx`, so they can't be the outermost handler unless wrapped. +## 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. From 64af25e0e4d2529d343d4c9b63a19b07ae16c0c7 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Fri, 8 May 2026 11:12:05 -0300 Subject: [PATCH 20/22] docs(gates): lead README with withSupabase analogy --- src/core/gates/README.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/core/gates/README.md b/src/core/gates/README.md index 42f9125..9433dfa 100644 --- a/src/core/gates/README.md +++ b/src/core/gates/README.md @@ -1,15 +1,13 @@ # `@supabase/server/core/gates` -The portable extensibility layer for `@supabase/server`. A **gate** is a small, typed plugin — a fetch-handler wrapper that runs against an inbound `Request` and either short-circuits with a `Response` or contributes typed data to a flat key on `ctx` for the handler. Anyone can publish a gate as a standalone npm package; the built-ins (`withRateLimit`, `withWebhook`, `withPayment`, …) sit alongside third-party gates with no special status, all built on the same primitive. +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. -Because gates are plain `(req, ctx) => Response` wrappers 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). +Gates are how `@supabase/server` is extended past auth. Anyone can publish one as a standalone npm package; the built-ins (`withRateLimit`, `withWebhook`, `withPayment`, …) sit 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. -Gates compose by direct nesting — each `withFoo(config, handler)` is a fetch-handler wrapper, the same shape as `withSupabase`. No separate composer. - ## Quick start (consumer) ```ts From 677efea09827d38be77e9c7dcab3f5d2f89a55a0 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Fri, 8 May 2026 12:22:45 -0300 Subject: [PATCH 21/22] docs(gates): tighten README prose and drop internal asides --- src/core/gates/README.md | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/core/gates/README.md b/src/core/gates/README.md index 9433dfa..fd1e17c 100644 --- a/src/core/gates/README.md +++ b/src/core/gates/README.md @@ -54,7 +54,7 @@ Inside a gated handler, ctx is a flat intersection — each gate contributes a t 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. Gates with `In` keys also require `baseCtx`, so they can't be the outermost handler unless wrapped. +- **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 @@ -117,7 +117,7 @@ run: (config: Config) => (req: Request, ctx: In) => 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. Otherwise, return a single-key object `{ [key]: contribution }` — the gate author types the slot key directly in the return position, so the relationship between the gate's `key` and where its data lands on `ctx` is visible at the call site. The runtime picks `result[key]` and ignores any other fields, so accidentally returning a wider object (e.g. `{ ...ctx, [key]: ... }`) is a runtime no-op for upstream values, and TypeScript flags excess keys on fresh-literal returns. +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 @@ -146,7 +146,7 @@ export const withSubscription = defineGate< }) ``` -A consumer using this gate must supply `userClaims` upstream — typically by wrapping with `withSupabase`. Standalone use without `userClaims` won't compile, and `baseCtx` becomes required (no optional `?`). +A consumer using this gate must supply `userClaims` upstream — typically by wrapping with `withSupabase`. Standalone use won't compile. ### Conflict detection @@ -162,8 +162,6 @@ Pick a different key for each gate. Gates that may be applied multiple times can When a gate is wrapped by another (e.g. `withSupabase(... withRateLimit(... 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. -> **How this works.** The inference is enabled by a callable-intersection in `Wrapped` (see the JSDoc on that type in `define-gate.ts`). The two-signature form is load-bearing — collapsing it to a single optional `(req, baseCtx?: Base)` looks equivalent at runtime but breaks contextual `Base` propagation through nested generic calls. Don't simplify it without reading the comment. - ```ts withSupabase({ allow: 'user' }, withRateLimit({ limit: 30, windowMs: 60_000, key: ... }, @@ -193,8 +191,6 @@ withSupabase({ allow: 'user' }, ) ``` -If you manually call a prerequisite-free gate with a `baseCtx` and no contextual outer wrapper, you can still pass `` explicitly to describe that base context. - ## API | Export | Description | From cbffc4761f9ef5b519ba4d548c5b39f2d8e58c9b Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Fri, 15 May 2026 04:41:16 -0300 Subject: [PATCH 22/22] refactor(gates): consolidate built-ins to feature-flag worked example --- README.md | 79 ++--- jsr.json | 6 +- package.json | 28 +- src/core/gates/README.md | 71 ++-- src/core/gates/define-gate.test.ts | 67 ++-- src/core/gates/define-gate.ts | 14 +- src/gates/README.md | 120 +++++++ src/gates/cloudflare/README.md | 128 -------- src/gates/cloudflare/index.ts | 13 - src/gates/cloudflare/with-access.test.ts | 113 ------- src/gates/cloudflare/with-access.ts | 126 ------- src/gates/cloudflare/with-turnstile.test.ts | 177 ---------- src/gates/cloudflare/with-turnstile.ts | 177 ---------- src/gates/{flag => feature-flag}/README.md | 39 ++- src/gates/feature-flag/index.ts | 12 + .../with-feature-flag.test.ts} | 30 +- src/gates/feature-flag/with-feature-flag.ts | 154 +++++++++ src/gates/flag/index.ts | 8 - src/gates/flag/with-flag.ts | 116 ------- src/gates/rate-limit/README.md | 118 ------- src/gates/rate-limit/index.ts | 12 - src/gates/rate-limit/with-rate-limit.test.ts | 175 ---------- src/gates/rate-limit/with-rate-limit.ts | 167 ---------- src/gates/webhook/README.md | 146 --------- src/gates/webhook/index.ts | 13 - src/gates/webhook/with-webhook.test.ts | 308 ------------------ src/gates/webhook/with-webhook.ts | 259 --------------- src/gates/x402/README.md | 112 ------- src/gates/x402/index.ts | 16 - src/gates/x402/with-payment.test.ts | 245 -------------- src/gates/x402/with-payment.ts | 301 ----------------- tsdown.config.ts | 6 +- 32 files changed, 440 insertions(+), 2916 deletions(-) create mode 100644 src/gates/README.md delete mode 100644 src/gates/cloudflare/README.md delete mode 100644 src/gates/cloudflare/index.ts delete mode 100644 src/gates/cloudflare/with-access.test.ts delete mode 100644 src/gates/cloudflare/with-access.ts delete mode 100644 src/gates/cloudflare/with-turnstile.test.ts delete mode 100644 src/gates/cloudflare/with-turnstile.ts rename src/gates/{flag => feature-flag}/README.md (55%) create mode 100644 src/gates/feature-flag/index.ts rename src/gates/{flag/with-flag.test.ts => feature-flag/with-feature-flag.test.ts} (78%) create mode 100644 src/gates/feature-flag/with-feature-flag.ts delete mode 100644 src/gates/flag/index.ts delete mode 100644 src/gates/flag/with-flag.ts delete mode 100644 src/gates/rate-limit/README.md delete mode 100644 src/gates/rate-limit/index.ts delete mode 100644 src/gates/rate-limit/with-rate-limit.test.ts delete mode 100644 src/gates/rate-limit/with-rate-limit.ts delete mode 100644 src/gates/webhook/README.md delete mode 100644 src/gates/webhook/index.ts delete mode 100644 src/gates/webhook/with-webhook.test.ts delete mode 100644 src/gates/webhook/with-webhook.ts delete mode 100644 src/gates/x402/README.md delete mode 100644 src/gates/x402/index.ts delete mode 100644 src/gates/x402/with-payment.test.ts delete mode 100644 src/gates/x402/with-payment.ts diff --git a/README.md b/README.md index aeee0de..a03c953 100644 --- a/README.md +++ b/README.md @@ -303,16 +303,19 @@ The portable extensibility layer for `@supabase/server`. A **gate** is a fetch-h ```ts import { withSupabase } from '@supabase/server' -import { withPayment } from '@supabase/server/gates/x402' +import { withFeatureFlag } from '@supabase/server/gates/feature-flag' export default { fetch: withSupabase( - { allow: 'user' }, - withPayment({ stripe, amountCents: 5 }, async (req, ctx) => { - // ctx.supabase, ctx.userClaims — from withSupabase - // ctx.payment.intentId — from withPayment - return Response.json({ paid: ctx.payment.intentId }) - }), + { 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 }) + }, + ), ), } ``` @@ -320,11 +323,8 @@ export default { `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). -- [`@supabase/server/gates/cloudflare`](src/gates/cloudflare/README.md) — `withTurnstile`, `withAccess`. -- [`@supabase/server/gates/flag`](src/gates/flag/README.md) — `withFlag`, provider-agnostic feature flag. -- [`@supabase/server/gates/rate-limit`](src/gates/rate-limit/README.md) — `withRateLimit`, fixed-window with pluggable store. -- [`@supabase/server/gates/webhook`](src/gates/webhook/README.md) — `withWebhook`, HMAC signature verification. -- [`@supabase/server/gates/x402`](src/gates/x402/README.md) — `withPayment`, Stripe-facilitated x402 paywall. +- [`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 @@ -463,40 +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) | -| `@supabase/server/core/gates` | `defineGate` (gate composition primitives) | -| `@supabase/server/gates/cloudflare` | `withTurnstile`, `withAccess` (Cloudflare bot-check + Zero Trust JWT) | -| `@supabase/server/gates/flag` | `withFlag` (provider-agnostic feature-flag gate) | -| `@supabase/server/gates/rate-limit` | `withRateLimit` (fixed-window rate limit; pluggable store) | -| `@supabase/server/gates/webhook` | `withWebhook` (HMAC signature verification, Stripe + GitHub + custom) | -| `@supabase/server/gates/x402` | `withPayment` (Stripe-facilitated x402 paywall gate) | +| 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) | -| How do I extend a handler with a gate (rate-limit, webhook, …)? | [`src/core/gates/README.md`](src/core/gates/README.md) | -| How do I gate a route behind a Cloudflare check? | [`src/gates/cloudflare/README.md`](src/gates/cloudflare/README.md) | -| How do I gate a route behind a feature flag? | [`src/gates/flag/README.md`](src/gates/flag/README.md) | -| How do I rate-limit a route? | [`src/gates/rate-limit/README.md`](src/gates/rate-limit/README.md) | -| How do I verify webhook signatures? | [`src/gates/webhook/README.md`](src/gates/webhook/README.md) | -| How do I charge per call with x402 + Stripe? | [`src/gates/x402/README.md`](src/gates/x402/README.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 6981e83..4366da3 100644 --- a/jsr.json +++ b/jsr.json @@ -6,11 +6,7 @@ "./core": "./src/core/index.ts", "./core/gates": "./src/core/gates/index.ts", "./adapters/hono": "./src/adapters/hono/index.ts", - "./gates/cloudflare": "./src/gates/cloudflare/index.ts", - "./gates/flag": "./src/gates/flag/index.ts", - "./gates/rate-limit": "./src/gates/rate-limit/index.ts", - "./gates/webhook": "./src/gates/webhook/index.ts", - "./gates/x402": "./src/gates/x402/index.ts" + "./gates/feature-flag": "./src/gates/feature-flag/index.ts" }, "publish": { "include": ["src/**/*.ts", "README.md", "LICENSE"], diff --git a/package.json b/package.json index 88158a7..1bbe698 100644 --- a/package.json +++ b/package.json @@ -44,30 +44,10 @@ "import": "./dist/adapters/h3/index.mjs", "require": "./dist/adapters/h3/index.cjs" }, - "./gates/cloudflare": { - "types": "./dist/gates/cloudflare/index.d.mts", - "import": "./dist/gates/cloudflare/index.mjs", - "require": "./dist/gates/cloudflare/index.cjs" - }, - "./gates/flag": { - "types": "./dist/gates/flag/index.d.mts", - "import": "./dist/gates/flag/index.mjs", - "require": "./dist/gates/flag/index.cjs" - }, - "./gates/rate-limit": { - "types": "./dist/gates/rate-limit/index.d.mts", - "import": "./dist/gates/rate-limit/index.mjs", - "require": "./dist/gates/rate-limit/index.cjs" - }, - "./gates/webhook": { - "types": "./dist/gates/webhook/index.d.mts", - "import": "./dist/gates/webhook/index.mjs", - "require": "./dist/gates/webhook/index.cjs" - }, - "./gates/x402": { - "types": "./dist/gates/x402/index.d.mts", - "import": "./dist/gates/x402/index.mjs", - "require": "./dist/gates/x402/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" }, diff --git a/src/core/gates/README.md b/src/core/gates/README.md index fd1e17c..e00eb46 100644 --- a/src/core/gates/README.md +++ b/src/core/gates/README.md @@ -2,7 +2,7 @@ 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-ins (`withRateLimit`, `withWebhook`, `withPayment`, …) sit 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). +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: @@ -12,19 +12,20 @@ This module exports: ```ts import { withSupabase } from '@supabase/server' -import { withFlag } from './gates/with-flag.ts' +import { withFeatureFlag } from '@supabase/server/gates/feature-flag' export default { fetch: withSupabase( - { allow: 'user' }, - withFlag( + { auth: 'user' }, + withFeatureFlag( { name: 'beta', evaluate: (req) => req.headers.has('x-beta') }, async (req, ctx) => { // ctx.supabase, ctx.userClaims — from withSupabase - // ctx.flag — from withFlag - if (!ctx.flag.enabled) - return new Response('not enabled', { status: 404 }) - return Response.json({ user: ctx.userClaims!.id }) + // ctx.featureFlag — from withFeatureFlag + return Response.json({ + user: ctx.userClaims!.id, + variant: ctx.featureFlag.variant, + }) }, ), ), @@ -35,9 +36,9 @@ Standalone (no `withSupabase`): ```ts export default { - fetch: withFlag( + fetch: withFeatureFlag( { name: 'beta', evaluate: (req) => req.headers.has('x-beta') }, - async (req, ctx) => Response.json({ enabled: ctx.flag.enabled }), + async (req, ctx) => Response.json({ flag: ctx.featureFlag.name }), ), } ``` @@ -46,10 +47,10 @@ export default { 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.flag`, `ctx.payment`) | the corresponding gate | read-only by convention | +| 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: @@ -82,19 +83,19 @@ export interface FlagState { enabled: boolean } -export const withFlag = defineGate< - 'flag', // Key +export const withFeatureFlag = defineGate< + 'featureFlag', // Key FlagConfig, // Config {}, // In: no upstream prerequisites - FlagState // Contribution: shape under ctx.flag + FlagState // Contribution: shape under ctx.featureFlag >({ - key: 'flag', + key: 'featureFlag', run: (config) => async (req) => { const enabled = config.evaluate(req) if (!enabled) { return Response.json({ error: 'feature_disabled' }, { status: 404 }) } - return { flag: { enabled } } // ← keyed slot, visible at ctx.flag + return { featureFlag: { enabled } } // ← keyed slot, visible at ctx.featureFlag }, }) ``` @@ -102,9 +103,8 @@ export const withFlag = defineGate< Used as: ```ts -withFlag({ name: 'beta', evaluate: ... }, async (req, ctx) => { - if (!ctx.flag.enabled) return new Response('not enabled', { status: 404 }) - return Response.json({ ok: true }) +withFeatureFlag({ name: 'beta', evaluate: ... }, async (req, ctx) => { + return Response.json({ enabled: ctx.featureFlag.enabled }) }) ``` @@ -160,14 +160,16 @@ Pick a different key for each gate. Gates that may be applied multiple times can ### Threading state through nested gates -When a gate is wrapped by another (e.g. `withSupabase(... withRateLimit(... 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. +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({ allow: 'user' }, - withRateLimit({ limit: 30, windowMs: 60_000, key: ... }, +withSupabase( + { auth: 'user' }, + withFeatureFlag( + { name: 'beta', evaluate: (req) => req.headers.has('x-beta') }, async (_req, ctx) => { // ctx.userClaims — from withSupabase - // ctx.rateLimit — from withRateLimit + // ctx.featureFlag — from withFeatureFlag return Response.json({ user: ctx.userClaims!.id }) }, ), @@ -177,16 +179,13 @@ withSupabase({ allow: 'user' }, For multi-gate stacks, keep nesting directly: ```ts -withSupabase({ allow: 'user' }, - withRateLimit(..., - withFlag(..., - withTurnstile(..., async (_req, ctx) => { - // ctx.userClaims — from withSupabase - // ctx.rateLimit — from withRateLimit - // ctx.flag — from withFlag - // ctx.turnstile — from withTurnstile - }), - ), +withSupabase({ auth: 'user' }, + withFeatureFlag(..., + withMyGate(..., async (_req, ctx) => { + // ctx.userClaims — from withSupabase + // ctx.featureFlag — from withFeatureFlag + // ctx.myGate — from withMyGate + }), ), ) ``` diff --git a/src/core/gates/define-gate.test.ts b/src/core/gates/define-gate.test.ts index c8190d2..dc41958 100644 --- a/src/core/gates/define-gate.test.ts +++ b/src/core/gates/define-gate.test.ts @@ -1,9 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { withSupabase } from '../../with-supabase.js' -import { withTurnstile } from '../../gates/cloudflare/with-turnstile.js' -import { withFlag } from '../../gates/flag/with-flag.js' -import { withRateLimit } from '../../gates/rate-limit/with-rate-limit.js' +import { withFeatureFlag } from '../../gates/feature-flag/with-feature-flag.js' import { defineGate } from './define-gate.js' const innerOk = async () => Response.json({ ok: true }) @@ -242,42 +240,39 @@ describe('defineGate', () => { }) 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( - { allow: 'user', cors: false }, - withRateLimit( + { auth: 'user', cors: false }, + withFeatureFlag( { - limit: 30, - windowMs: 60_000, - key: () => 'anon', + name: 'beta-feedback', + evaluate: () => true, }, - withFlag( - { - name: 'beta-feedback', - evaluate: () => true, - }, - withTurnstile( - { - secretKey: 'secret', - expectedAction: 'submit-feedback', - siteverifyUrl: 'http://localhost/siteverify', - }, - async (_req, ctx) => { - const userId: string | undefined = ctx.userClaims?.id - const remaining: number = ctx.rateLimit.remaining - const flagName: string = ctx.flag.name - const action: string = ctx.turnstile.action - const authMode: string = ctx.authMode - - void userId - void remaining - void flagName - void action - void authMode - - return Response.json({ ok: 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 }) + }), ), ) diff --git a/src/core/gates/define-gate.ts b/src/core/gates/define-gate.ts index 4c98450..782e284 100644 --- a/src/core/gates/define-gate.ts +++ b/src/core/gates/define-gate.ts @@ -41,24 +41,24 @@ import type { Conflict } from './types.js' * ```ts * import { defineGate } from '@supabase/server/core/gates' * - * export const withFlag = defineGate< - * 'flag', + * export const withFeatureFlag = defineGate< + * 'featureFlag', * { name: string; evaluate: (req: Request) => boolean }, * {}, * { name: string; enabled: true } * >({ - * key: 'flag', + * key: 'featureFlag', * run: (config) => async (req) => { * if (!config.evaluate(req)) { * return Response.json({ error: 'feature_disabled' }, { status: 404 }) * } - * return { flag: { name: config.name, enabled: true } } + * return { featureFlag: { name: config.name, enabled: true } } * }, * }) * * // Standalone: - * withFlag({ name: 'beta', evaluate: ... }, async (req, ctx) => { - * return Response.json({ flag: ctx.flag.name }) + * withFeatureFlag({ name: 'beta', evaluate: ... }, async (req, ctx) => { + * return Response.json({ flag: ctx.featureFlag.name }) * }) * ``` * @@ -82,7 +82,7 @@ import type { Conflict } from './types.js' * }) * * // Composes only inside `withSupabase` (or a wrapper that provides those keys): - * withSupabase({ allow: 'user' }, + * withSupabase({ auth: 'user' }, * withReportAccess({ reportId: 'r1' }, async (req, ctx) => { * ctx.supabase // from withSupabase * ctx.userClaims // from withSupabase 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/cloudflare/README.md b/src/gates/cloudflare/README.md deleted file mode 100644 index 3243a34..0000000 --- a/src/gates/cloudflare/README.md +++ /dev/null @@ -1,128 +0,0 @@ -# Cloudflare gates - -Gates that integrate with Cloudflare-issued credentials, headers, and APIs. Each is a fetch-handler wrapper; nest them directly the way `withSupabase` does (see [the gate composition primitives](../../core/gates/README.md)). - -```ts -import { withTurnstile } from '@supabase/server/gates/cloudflare' - -export default { - fetch: withTurnstile( - { - secretKey: process.env.TURNSTILE_SECRET_KEY!, - expectedAction: 'login', - }, - async (req, ctx) => - Response.json({ ok: true, hostname: ctx.turnstile.hostname }), - ), -} -``` - -## Available gates - -| Gate | Namespace | Purpose | -| --------------- | ----------- | ------------------------------------------------------------------------------- | -| `withTurnstile` | `turnstile` | Verifies a Cloudflare Turnstile bot-check token against `siteverify`. | -| `withAccess` | `access` | Validates a Cloudflare Zero Trust JWT (`Cf-Access-Jwt-Assertion`) against JWKS. | - -More gates (geofencing, bot management) are planned — see the package roadmap. - -## `withTurnstile` - -Verifies the `cf-turnstile-response` token a client widget produces against Cloudflare's siteverify endpoint. On success, contributes the verified challenge metadata to `ctx.turnstile`. On failure, short-circuits with a 401 (or 503 if siteverify is unreachable). - -### Config - -```ts -withTurnstile({ - secretKey, // Turnstile secret key (required) - expectedAction, // optional: reject if `action` doesn't match - getToken, // optional: custom token extractor (default: cf-turnstile-response header) - siteverifyUrl, // optional: override the verify endpoint (useful for tests) -}) -``` - -### Contribution - -```ts -ctx.turnstile = { - challengeTs: string // ISO 8601 timestamp the challenge was solved - hostname: string // hostname of the page the widget rendered on - action: string // the widget's action label - cdata: string | null // any cdata the client attached -} -``` - -### Token location - -Turnstile tokens are typically returned to the client by the widget and submitted alongside the form / API call. The default extractor reads the `cf-turnstile-response` request header. For form-encoded or JSON bodies, supply `getToken`: - -```ts -withTurnstile({ - secretKey, - getToken: async (req) => { - const form = await req.clone().formData() - return (form.get('cf-turnstile-response') as string | null) ?? null - }, -}) -``` - -`req.clone()` preserves the body for downstream handlers — without it, the body is consumed by the gate. - -### Action binding - -If you bind your widget's client-side `action` to a value (e.g. `"login"`) and pass `expectedAction: 'login'`, the gate rejects when the verified action doesn't match. This prevents a token issued for one form from being replayed against a different endpoint. - -### Errors - -| Status | `error` | Meaning | -| ------ | ------------------------------------ | ------------------------------------------------------------------------------- | -| 401 | `turnstile_token_missing` | No token was found by `getToken`. | -| 401 | `turnstile_verification_failed` | Cloudflare reported `success: false`. Body includes `codes` from `error-codes`. | -| 401 | `turnstile_action_mismatch` | `expectedAction` was set and the verified action differs. | -| 503 | `turnstile_verification_unavailable` | Siteverify returned a non-2xx status. Treat as transient. | - -### Forwarded IP - -If `cf-connecting-ip` is present on the request, it's forwarded to siteverify as `remoteip` — recommended by Cloudflare to harden the check against token replay from other IPs. No-op if you're not behind Cloudflare or the header isn't set. - -## `withAccess` - -Validates the `Cf-Access-Jwt-Assertion` header that Cloudflare attaches to every request to an Access-protected origin. Verifies the signature against your team's JWKS and checks that the `aud` claim matches your application's audience tag. On success, contributes the verified identity at `ctx.access`. - -### Config - -```ts -withAccess({ - teamDomain: 'acme.cloudflareaccess.com', // your team domain - audience: process.env.CF_ACCESS_AUD!, // your application's AUD tag -}) -``` - -### Contribution - -```ts -ctx.access = { - email: string | null - sub: string // Cloudflare's stable identity id - identityNonce: string | null - audience: string // the AUD that was validated - claims: JWTPayload // full payload for custom claims -} -``` - -### Errors - -| Status | `error` | Meaning | -| ------ | ---------------------- | ----------------------------------------------------- | -| 401 | `access_token_missing` | The `Cf-Access-Jwt-Assertion` header was not present. | -| 401 | `access_token_invalid` | Signature, audience, or expiration check failed. | - -### When to use it - -For backend services behind a Cloudflare tunnel + Access policy. Cloudflare authenticates the user at the edge and signs every request with a JWT — `withAccess` is the verifier on the origin side. No need to roll your own SSO flow. - -## See also - -- [Gate composition primitives](../../core/gates/README.md) — `defineGate`, ctx shape, prereqs -- [Turnstile docs](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/) -- [Access JWT validation](https://developers.cloudflare.com/cloudflare-one/identity/authorization-cookie/validating-json/) diff --git a/src/gates/cloudflare/index.ts b/src/gates/cloudflare/index.ts deleted file mode 100644 index 850a1f4..0000000 --- a/src/gates/cloudflare/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Cloudflare gates. - * - * Each gate is a fetch-handler wrapper — compose by direct nesting — and - * contributes typed state under its own key on `ctx`. - * - * @packageDocumentation - */ - -export { withAccess } from './with-access.js' -export type { AccessState, WithAccessConfig } from './with-access.js' -export { withTurnstile } from './with-turnstile.js' -export type { TurnstileState, WithTurnstileConfig } from './with-turnstile.js' diff --git a/src/gates/cloudflare/with-access.test.ts b/src/gates/cloudflare/with-access.test.ts deleted file mode 100644 index 80e4ddf..0000000 --- a/src/gates/cloudflare/with-access.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { exportJWK, generateKeyPair, SignJWT, type KeyObject } from 'jose' -import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' - -import { withAccess } from './with-access.js' - -const TEAM_DOMAIN = 'acme.cloudflareaccess.com' -const ISSUER = `https://${TEAM_DOMAIN}` -const AUDIENCE = - '8c6f8a7d36b4f8e9d6e7c5a4b3a2f1e0d9c8b7a6e5f4d3c2b1a0f9e8d7c6b5a4' - -const fetchMock = vi.fn() -vi.stubGlobal('fetch', fetchMock) - -let privateKey: KeyObject -let kid: string - -beforeAll(async () => { - const pair = await generateKeyPair('RS256') - privateKey = pair.privateKey as KeyObject - const jwk = await exportJWK(pair.publicKey) - kid = 'test-key' - const jwks = { keys: [{ ...jwk, kid, alg: 'RS256', use: 'sig' }] } - // Every fetch in these tests goes to the JWKS endpoint. - fetchMock.mockImplementation(async () => - Response.json(jwks, { headers: { 'cache-control': 'max-age=60' } }), - ) -}) - -afterEach(() => { - fetchMock.mockClear() -}) - -const sign = ( - overrides: { aud?: string | string[]; email?: string; sub?: string } = {}, -) => - new SignJWT({ - email: overrides.email ?? 'user@example.com', - identity_nonce: 'nonce-abc', - }) - .setProtectedHeader({ alg: 'RS256', kid }) - .setIssuer(ISSUER) - .setAudience(overrides.aud ?? AUDIENCE) - .setSubject(overrides.sub ?? 'user-123') - .setIssuedAt() - .setExpirationTime('5m') - .sign(privateKey) - -const baseConfig = { teamDomain: TEAM_DOMAIN, audience: AUDIENCE } -const innerOk = async () => Response.json({ ok: true }) - -describe('withAccess', () => { - it('rejects when the assertion header is missing', async () => { - const handler = withAccess(baseConfig, innerOk) - - const res = await handler(new Request('http://localhost/')) - - expect(res.status).toBe(401) - expect(await res.json()).toEqual({ error: 'access_token_missing' }) - }) - - it('admits a valid token and contributes identity to ctx.access', async () => { - const token = await sign() - - const inner = vi.fn(async (_req: Request, ctx) => { - expect(ctx.access.email).toBe('user@example.com') - expect(ctx.access.sub).toBe('user-123') - expect(ctx.access.identityNonce).toBe('nonce-abc') - expect(ctx.access.audience).toBe(AUDIENCE) - expect(ctx.access.claims.iss).toBe(ISSUER) - return Response.json({ ok: true }) - }) - - const handler = withAccess(baseConfig, inner) - - const res = await handler( - new Request('http://localhost/', { - headers: { 'cf-access-jwt-assertion': token }, - }), - ) - - expect(res.status).toBe(200) - expect(inner).toHaveBeenCalledOnce() - }) - - it('rejects a token with the wrong audience', async () => { - const token = await sign({ aud: 'someone-elses-audience' }) - - const handler = withAccess(baseConfig, innerOk) - - const res = await handler( - new Request('http://localhost/', { - headers: { 'cf-access-jwt-assertion': token }, - }), - ) - - expect(res.status).toBe(401) - const body = await res.json() - expect(body.error).toBe('access_token_invalid') - }) - - it('rejects a malformed assertion', async () => { - const handler = withAccess(baseConfig, innerOk) - - const res = await handler( - new Request('http://localhost/', { - headers: { 'cf-access-jwt-assertion': 'not.a.jwt' }, - }), - ) - - expect(res.status).toBe(401) - expect((await res.json()).error).toBe('access_token_invalid') - }) -}) diff --git a/src/gates/cloudflare/with-access.ts b/src/gates/cloudflare/with-access.ts deleted file mode 100644 index 8ab3dda..0000000 --- a/src/gates/cloudflare/with-access.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * Cloudflare Zero Trust Access gate. - * - * Validates the `Cf-Access-Jwt-Assertion` header against the team's JWKS, - * checks the audience tag binding, and contributes the identity claims to - * `ctx.access`. - * - * Use this for backend services that sit behind a Cloudflare tunnel + Access - * policy — every request is signed by Cloudflare on the way in. - * - * @see https://developers.cloudflare.com/cloudflare-one/identity/authorization-cookie/validating-json/ - */ - -import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose' - -import { defineGate, type Gate } from '../../core/gates/index.js' - -const HEADER_NAME = 'cf-access-jwt-assertion' - -export interface WithAccessConfig { - /** - * Your Cloudflare team domain — `.cloudflareaccess.com` (no protocol, - * no path). Used to derive the JWKS URL and the expected `iss` claim. - * - * Find it at https://one.dash.cloudflare.com/ → Settings → Custom Pages. - */ - teamDomain: string - - /** - * The Application Audience (AUD) tag from your Access policy. The gate - * rejects tokens whose `aud` claim doesn't include this value. - * - * Find it at Zero Trust → Access → Applications → → Overview. - */ - audience: string - - /** - * Override the JWKS URL. By default derived from `teamDomain`. Useful for - * tests; otherwise leave unset. - */ - jwksUrl?: string -} - -/** Shape contributed at `ctx.access` after a successful verification. */ -export interface AccessState { - /** The user's email address from the verified token. */ - email: string | null - /** The `sub` claim — Cloudflare's stable identity id for this user. */ - sub: string - /** Cloudflare's identity nonce, useful for cache-busting per session. */ - identityNonce: string | null - /** The `aud` claim that was validated. */ - audience: string - /** The full verified JWT payload, for accessing custom claims. */ - claims: JWTPayload -} - -/** - * Cloudflare Zero Trust Access gate. - * - * @example - * ```ts - * import { withAccess } from '@supabase/server/gates/cloudflare' - * - * export default { - * fetch: withAccess( - * { - * teamDomain: 'acme.cloudflareaccess.com', - * audience: process.env.CF_ACCESS_AUD!, - * }, - * async (req, ctx) => Response.json({ user: ctx.access.email }), - * ), - * } - * ``` - */ -export const withAccess: Gate< - 'access', - WithAccessConfig, - Record, - AccessState -> = defineGate<'access', WithAccessConfig, Record, AccessState>({ - key: 'access', - run: (config) => { - const issuer = `https://${config.teamDomain}` - const jwksUrl = config.jwksUrl ?? `${issuer}/cdn-cgi/access/certs` - const jwks = createRemoteJWKSet(new URL(jwksUrl)) - - return async (req) => { - const token = req.headers.get(HEADER_NAME) - if (!token) { - return Response.json({ error: 'access_token_missing' }, { status: 401 }) - } - - try { - const { payload } = await jwtVerify(token, jwks, { - issuer, - audience: config.audience, - }) - - const email = typeof payload.email === 'string' ? payload.email : null - const identityNonce = - typeof payload.identity_nonce === 'string' - ? payload.identity_nonce - : null - - return { - access: { - email, - sub: payload.sub ?? '', - identityNonce, - audience: config.audience, - claims: payload, - }, - } - } catch (err) { - return Response.json( - { - error: 'access_token_invalid', - detail: err instanceof Error ? err.message : 'unknown', - }, - { status: 401 }, - ) - } - } - }, -}) diff --git a/src/gates/cloudflare/with-turnstile.test.ts b/src/gates/cloudflare/with-turnstile.test.ts deleted file mode 100644 index 2a09ec7..0000000 --- a/src/gates/cloudflare/with-turnstile.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' - -import { withTurnstile } from './with-turnstile.js' - -const SITEVERIFY = 'https://verify.test/turnstile' - -const baseConfig = { - secretKey: 'sk_test', - siteverifyUrl: SITEVERIFY, -} - -const fetchMock = vi.fn() -vi.stubGlobal('fetch', fetchMock) - -afterEach(() => { - fetchMock.mockReset() -}) - -const okBody = { - success: true, - challenge_ts: '2026-01-01T00:00:00Z', - hostname: 'app.example.com', - action: 'login', - cdata: 'abc', -} - -const innerOk = async () => Response.json({ ok: true }) - -describe('withTurnstile', () => { - it('rejects when no token is present', async () => { - const handler = withTurnstile(baseConfig, innerOk) - - const res = await handler(new Request('http://localhost/')) - - expect(res.status).toBe(401) - expect(await res.json()).toEqual({ error: 'turnstile_token_missing' }) - expect(fetchMock).not.toHaveBeenCalled() - }) - - it('passes when verification succeeds and contributes state', async () => { - fetchMock.mockResolvedValueOnce( - new Response(JSON.stringify(okBody), { status: 200 }), - ) - - const inner = vi.fn(async (_req: Request, ctx) => { - expect(ctx.turnstile).toEqual({ - challengeTs: '2026-01-01T00:00:00Z', - hostname: 'app.example.com', - action: 'login', - cdata: 'abc', - }) - return Response.json({ ok: true }) - }) - - const handler = withTurnstile(baseConfig, inner) - - const res = await handler( - new Request('http://localhost/', { - headers: { 'cf-turnstile-response': 'tok_abc' }, - }), - ) - - expect(res.status).toBe(200) - expect(inner).toHaveBeenCalledOnce() - expect(fetchMock).toHaveBeenCalledOnce() - const [calledUrl, calledInit] = fetchMock.mock.calls[0] - expect(calledUrl).toBe(SITEVERIFY) - expect(calledInit.method).toBe('POST') - const sent = calledInit.body as URLSearchParams - expect(sent.get('secret')).toBe('sk_test') - expect(sent.get('response')).toBe('tok_abc') - expect(sent.get('remoteip')).toBeNull() - }) - - it('rejects when verification fails', async () => { - fetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - success: false, - 'error-codes': ['invalid-input-response'], - }), - ), - ) - - const handler = withTurnstile(baseConfig, innerOk) - - const res = await handler( - new Request('http://localhost/', { - headers: { 'cf-turnstile-response': 'tok_bad' }, - }), - ) - - expect(res.status).toBe(401) - expect(await res.json()).toEqual({ - error: 'turnstile_verification_failed', - codes: ['invalid-input-response'], - }) - }) - - it('rejects on action mismatch', async () => { - fetchMock.mockResolvedValueOnce( - new Response(JSON.stringify({ ...okBody, action: 'signup' })), - ) - - const handler = withTurnstile( - { ...baseConfig, expectedAction: 'login' }, - innerOk, - ) - - const res = await handler( - new Request('http://localhost/', { - headers: { 'cf-turnstile-response': 'tok' }, - }), - ) - - expect(res.status).toBe(401) - expect(await res.json()).toMatchObject({ - error: 'turnstile_action_mismatch', - expected: 'login', - actual: 'signup', - }) - }) - - it('returns 503 when siteverify is unreachable', async () => { - fetchMock.mockResolvedValueOnce( - new Response('upstream error', { status: 502 }), - ) - - const handler = withTurnstile(baseConfig, innerOk) - - const res = await handler( - new Request('http://localhost/', { - headers: { 'cf-turnstile-response': 'tok' }, - }), - ) - - expect(res.status).toBe(503) - }) - - it('forwards remoteip when cf-connecting-ip is present', async () => { - fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(okBody))) - - const handler = withTurnstile(baseConfig, innerOk) - - await handler( - new Request('http://localhost/', { - headers: { - 'cf-turnstile-response': 'tok', - 'cf-connecting-ip': '1.2.3.4', - }, - }), - ) - - const sent = fetchMock.mock.calls[0][1].body as URLSearchParams - expect(sent.get('remoteip')).toBe('1.2.3.4') - }) - - it('honors a custom getToken extractor', async () => { - fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(okBody))) - - const handler = withTurnstile( - { - ...baseConfig, - getToken: (req) => new URL(req.url).searchParams.get('captcha'), - }, - innerOk, - ) - - const res = await handler( - new Request('http://localhost/?captcha=tok_query'), - ) - - expect(res.status).toBe(200) - const sent = fetchMock.mock.calls[0][1].body as URLSearchParams - expect(sent.get('response')).toBe('tok_query') - }) -}) diff --git a/src/gates/cloudflare/with-turnstile.ts b/src/gates/cloudflare/with-turnstile.ts deleted file mode 100644 index 0c7715d..0000000 --- a/src/gates/cloudflare/with-turnstile.ts +++ /dev/null @@ -1,177 +0,0 @@ -/** - * Cloudflare Turnstile bot-check gate. - * - * Verifies the `cf-turnstile-response` token a client widget produced against - * Cloudflare's siteverify endpoint, then either short-circuits with a 401 or - * contributes the verified challenge metadata to `ctx.turnstile`. - * - * @see https://developers.cloudflare.com/turnstile/get-started/server-side-validation/ - */ - -import { defineGate, type Gate } from '../../core/gates/index.js' - -const SITEVERIFY_URL = - 'https://challenges.cloudflare.com/turnstile/v0/siteverify' - -export interface WithTurnstileConfig { - /** - * Turnstile secret key for your widget. Get one at - * https://dash.cloudflare.com/?to=/:account/turnstile. - */ - secretKey: string - - /** - * If set, the gate rejects when the verified `action` doesn't match. Bind - * your widget's client-side `action` to this so a token issued for one form - * can't be replayed against a different endpoint. - */ - expectedAction?: string - - /** - * Where to find the Turnstile token on the inbound request. Defaults to - * the `cf-turnstile-response` header. For form-encoded or JSON bodies, - * supply a custom extractor — but be aware that consuming the body here - * makes it unavailable to downstream handlers unless you `req.clone()` first. - * - * @defaultValue `(req) => req.headers.get('cf-turnstile-response')` - */ - getToken?: (req: Request) => Promise | string | null - - /** - * Override the Turnstile siteverify URL. Useful for tests; otherwise leave - * unset to hit Cloudflare's production endpoint. - * - * @defaultValue `'https://challenges.cloudflare.com/turnstile/v0/siteverify'` - */ - siteverifyUrl?: string -} - -/** - * Shape contributed at `ctx.turnstile` after a successful verification. - */ -export interface TurnstileState { - /** ISO 8601 timestamp when the challenge was solved. */ - challengeTs: string - /** Hostname of the page the widget was rendered on. */ - hostname: string - /** The action the widget was bound to. */ - action: string - /** Custom data the client attached to the widget. */ - cdata: string | null -} - -interface SiteverifyResponse { - success: boolean - challenge_ts?: string - hostname?: string - action?: string - cdata?: string - 'error-codes'?: string[] -} - -/** - * Cloudflare Turnstile bot-check gate. - * - * @example - * ```ts - * import { withTurnstile } from '@supabase/server/gates/cloudflare' - * - * export default { - * fetch: withTurnstile( - * { - * secretKey: process.env.TURNSTILE_SECRET_KEY!, - * expectedAction: 'login', - * }, - * async (req, ctx) => - * Response.json({ ok: true, action: ctx.turnstile.action }), - * ), - * } - * ``` - */ -export const withTurnstile: Gate< - 'turnstile', - WithTurnstileConfig, - Record, - TurnstileState -> = defineGate< - 'turnstile', - WithTurnstileConfig, - Record, - TurnstileState ->({ - key: 'turnstile', - run: (config) => { - const url = config.siteverifyUrl ?? SITEVERIFY_URL - const getToken = config.getToken ?? defaultGetToken - - return async (req) => { - const token = await getToken(req) - if (!token) { - return Response.json( - { error: 'turnstile_token_missing' }, - { status: 401 }, - ) - } - - const params = new URLSearchParams() - params.set('secret', config.secretKey) - params.set('response', token) - const remoteip = req.headers.get('cf-connecting-ip') - if (remoteip) params.set('remoteip', remoteip) - - const verifyResponse = await fetch(url, { - method: 'POST', - body: params, - }) - - if (!verifyResponse.ok) { - return Response.json( - { - error: 'turnstile_verification_unavailable', - status: verifyResponse.status, - }, - { status: 503 }, - ) - } - - const result = (await verifyResponse.json()) as SiteverifyResponse - - if (!result.success) { - return Response.json( - { - error: 'turnstile_verification_failed', - codes: result['error-codes'] ?? [], - }, - { status: 401 }, - ) - } - - if ( - config.expectedAction !== undefined && - result.action !== config.expectedAction - ) { - return Response.json( - { - error: 'turnstile_action_mismatch', - expected: config.expectedAction, - actual: result.action ?? null, - }, - { status: 401 }, - ) - } - - return { - turnstile: { - challengeTs: result.challenge_ts ?? '', - hostname: result.hostname ?? '', - action: result.action ?? '', - cdata: result.cdata ?? null, - }, - } - } - }, -}) - -function defaultGetToken(req: Request): string | null { - return req.headers.get('cf-turnstile-response') -} diff --git a/src/gates/flag/README.md b/src/gates/feature-flag/README.md similarity index 55% rename from src/gates/flag/README.md rename to src/gates/feature-flag/README.md index 9771a8e..3dc9871 100644 --- a/src/gates/flag/README.md +++ b/src/gates/feature-flag/README.md @@ -1,36 +1,38 @@ -# `@supabase/server/gates/flag` +# `@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 { withFlag } from '@supabase/server/gates/flag' +import { withFeatureFlag } from '@supabase/server/gates/feature-flag' export default { - fetch: withFlag( + fetch: withFeatureFlag( { name: 'beta-checkout', evaluate: (req) => req.headers.get('x-beta') === '1', }, - async (_req, ctx) => Response.json({ feature: ctx.flag.name }), + async (_req, ctx) => Response.json({ feature: ctx.featureFlag.name }), ), } ``` ## Config -| Field | Type | Description | -| -------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | -| `name` | `string` | Recorded in `ctx.flag.name` and the default rejection body. | -| `evaluate` | `(req) => boolean \| FlagVerdict \| 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: }`. | +| 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 -withFlag({ +withFeatureFlag({ name: 'pricing-experiment', evaluate: async (req) => { const variant = await ld.variation('pricing-experiment', userKey, 'control') @@ -42,8 +44,8 @@ withFlag({ Then the handler reads: ```ts -ctx.flag.variant // 'a' | 'b' | 'control' | null -ctx.flag.payload // anything you returned +ctx.featureFlag.variant // 'a' | 'b' | 'control' | null +ctx.featureFlag.payload // anything you returned ``` ## Why 404 by default @@ -52,15 +54,15 @@ Soft reveal. A `403 Forbidden` tells the caller "this exists, but you can't see ## Composing with auth-aware flags -Place `withFlag` _after_ `withSupabase` to target by user identity: +Place `withFeatureFlag` _after_ `withSupabase` to target by user identity: ```ts import { withSupabase } from '@supabase/server' -import { withFlag } from '@supabase/server/gates/flag' +import { withFeatureFlag } from '@supabase/server/gates/feature-flag' withSupabase( - { allow: 'user' }, - withFlag( + { auth: 'user' }, + withFeatureFlag( { name: 'beta-checkout', evaluate: async (req) => { @@ -80,8 +82,9 @@ The current `evaluate` signature only sees the request — for user-aware flags, ## Single namespace caveat -The gate occupies `ctx.flag` — only one `withFlag` 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. +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/flag/with-flag.test.ts b/src/gates/feature-flag/with-feature-flag.test.ts similarity index 78% rename from src/gates/flag/with-flag.test.ts rename to src/gates/feature-flag/with-feature-flag.test.ts index 8b2a087..96d4145 100644 --- a/src/gates/flag/with-flag.test.ts +++ b/src/gates/feature-flag/with-feature-flag.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it, vi } from 'vitest' -import { withFlag } from './with-flag.js' +import { withFeatureFlag } from './with-feature-flag.js' const innerOk = async () => Response.json({ ok: true }) -describe('withFlag', () => { +describe('withFeatureFlag', () => { it('admits when evaluate returns true and contributes the flag state', async () => { const inner = vi.fn(async (_req: Request, ctx) => { - expect(ctx.flag).toEqual({ + expect(ctx.featureFlag).toEqual({ name: 'beta', enabled: true, variant: null, @@ -16,7 +16,10 @@ describe('withFlag', () => { return Response.json({ ok: true }) }) - const handler = withFlag({ name: 'beta', evaluate: () => true }, inner) + const handler = withFeatureFlag( + { name: 'beta', evaluate: () => true }, + inner, + ) const res = await handler(new Request('http://localhost/')) expect(res.status).toBe(200) @@ -24,7 +27,10 @@ describe('withFlag', () => { }) it('rejects with 404 by default when evaluate returns false', async () => { - const handler = withFlag({ name: 'beta', evaluate: () => false }, innerOk) + const handler = withFeatureFlag( + { name: 'beta', evaluate: () => false }, + innerOk, + ) const res = await handler(new Request('http://localhost/')) expect(res.status).toBe(404) @@ -35,7 +41,7 @@ describe('withFlag', () => { }) it('honors a custom rejectStatus and rejectBody', async () => { - const handler = withFlag( + const handler = withFeatureFlag( { name: 'beta', evaluate: () => false, @@ -52,12 +58,12 @@ describe('withFlag', () => { it('captures variant + payload when evaluate returns a verdict object', async () => { const inner = vi.fn(async (_req: Request, ctx) => { - expect(ctx.flag.variant).toBe('green') - expect(ctx.flag.payload).toEqual({ rollout: 0.25 }) + expect(ctx.featureFlag.variant).toBe('green') + expect(ctx.featureFlag.payload).toEqual({ rollout: 0.25 }) return Response.json({ ok: true }) }) - const handler = withFlag( + const handler = withFeatureFlag( { name: 'beta', evaluate: () => ({ @@ -76,7 +82,7 @@ describe('withFlag', () => { 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 = withFlag({ name: 'beta', evaluate }, innerOk) + const handler = withFeatureFlag({ name: 'beta', evaluate }, innerOk) const off = await handler(new Request('http://localhost/')) expect(off.status).toBe(404) @@ -90,7 +96,7 @@ describe('withFlag', () => { }) it('supports async evaluators', async () => { - const handler = withFlag( + const handler = withFeatureFlag( { name: 'beta', evaluate: async () => { @@ -98,7 +104,7 @@ describe('withFlag', () => { return { enabled: true, variant: 'a' } }, }, - async (_req, ctx) => Response.json({ variant: ctx.flag.variant }), + async (_req, ctx) => Response.json({ variant: ctx.featureFlag.variant }), ) const res = await handler(new Request('http://localhost/')) 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/gates/flag/index.ts b/src/gates/flag/index.ts deleted file mode 100644 index 06edb64..0000000 --- a/src/gates/flag/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Feature-flag gate. - * - * @packageDocumentation - */ - -export { withFlag } from './with-flag.js' -export type { FlagState, FlagVerdict, WithFlagConfig } from './with-flag.js' diff --git a/src/gates/flag/with-flag.ts b/src/gates/flag/with-flag.ts deleted file mode 100644 index 80b80e9..0000000 --- a/src/gates/flag/with-flag.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** - * Feature-flag gate. - * - * Evaluates a flag for the inbound request and either admits with the - * verdict at `ctx.flag` or short-circuits with a configurable response. - * Provider-agnostic — pass any `evaluate` function (PostHog, LaunchDarkly, - * Statsig, a header check, a database lookup). - */ - -import { defineGate, type Gate } from '../../core/gates/index.js' - -export interface WithFlagConfig { - /** Human-readable name for the flag, recorded in `ctx.flag.name`. */ - name: string - - /** - * Evaluate the flag for the inbound request. Return `true` to admit, - * `false` to reject with a default 404. Return an object to record - * additional metadata (variant, payload) and admit; return - * `{ enabled: false, ... }` to reject with custom data. - */ - evaluate: ( - req: Request, - ) => Promise | boolean | FlagVerdict - - /** - * 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 -} - -/** - * Verdict shape that an `evaluate` function may return for richer state. - */ -export interface FlagVerdict { - enabled: boolean - /** A/B test variant if applicable. */ - variant?: string | null - /** Provider-specific payload (rollout %, targeting rules, etc.). */ - payload?: unknown -} - -/** Shape contributed at `ctx.flag` after a successful evaluation. */ -export interface FlagState { - name: string - enabled: true - variant: string | null - payload: unknown -} - -/** - * Feature-flag gate. - * - * @example - * ```ts - * import { withFlag } from '@supabase/server/gates/flag' - * - * export default { - * fetch: withFlag( - * { - * name: 'beta-checkout', - * evaluate: (req) => req.headers.get('x-beta') === '1', - * }, - * async (_req, ctx) => Response.json({ feature: ctx.flag.name }), - * ), - * } - * ``` - * - * Pluggable providers — use whatever you like in `evaluate`: - * - * ```ts - * withFlag({ - * name: 'beta-checkout', - * evaluate: async (req) => { - * const userId = req.headers.get('x-user-id') ?? 'anon' - * return await posthog.isFeatureEnabled('beta-checkout', userId) - * }, - * }) - * ``` - */ -export const withFlag: Gate< - 'flag', - WithFlagConfig, - Record, - FlagState -> = defineGate<'flag', WithFlagConfig, Record, FlagState>({ - key: 'flag', - run: (config) => async (req) => { - const result = await config.evaluate(req) - const verdict: FlagVerdict = - typeof result === 'boolean' ? { enabled: result } : result - - if (!verdict.enabled) { - return Response.json( - config.rejectBody ?? { error: 'feature_disabled', flag: config.name }, - { status: config.rejectStatus ?? 404 }, - ) - } - - return { - flag: { - name: config.name, - enabled: true, - variant: verdict.variant ?? null, - payload: verdict.payload ?? null, - }, - } - }, -}) diff --git a/src/gates/rate-limit/README.md b/src/gates/rate-limit/README.md deleted file mode 100644 index 2960ba6..0000000 --- a/src/gates/rate-limit/README.md +++ /dev/null @@ -1,118 +0,0 @@ -# `@supabase/server/gates/rate-limit` - -Fixed-window rate-limit gate backed by Supabase Postgres. Counts hits per key within a window via an atomic SQL function; rejects with `429 Too Many Requests` once the limit is exceeded. - -```ts -import { withSupabase } from '@supabase/server' -import { withRateLimit } from '@supabase/server/gates/rate-limit' - -export default { - fetch: withSupabase( - { allow: 'always' }, - withRateLimit( - { - limit: 60, - windowMs: 60_000, - key: (req) => req.headers.get('cf-connecting-ip') ?? 'anon', - }, - async (req, ctx) => Response.json({ remaining: ctx.rateLimit.remaining }), - ), - ), -} -``` - -## One-time migration - -Copy this into `supabase/migrations/_supabase_server_rate_limit.sql` and run `supabase db push`: - -```sql -create table if not exists public._supabase_server_rate_limits ( - key text primary key, - count int not null, - reset_at bigint not null -); - -create or replace function public._supabase_server_rate_limit_hit( - p_key text, - p_window_ms bigint -) -returns json -language plpgsql -as $$ -declare - now_ms bigint := (extract(epoch from clock_timestamp()) * 1000)::bigint; - result_count int; - result_reset bigint; -begin - insert into public._supabase_server_rate_limits (key, count, reset_at) - values (p_key, 1, now_ms + p_window_ms) - on conflict (key) do update - set - count = case - when public._supabase_server_rate_limits.reset_at <= now_ms then 1 - else public._supabase_server_rate_limits.count + 1 - end, - reset_at = case - when public._supabase_server_rate_limits.reset_at <= now_ms - then now_ms + p_window_ms - else public._supabase_server_rate_limits.reset_at - end - returning count, reset_at into result_count, result_reset; - - return json_build_object('count', result_count, 'reset_at', result_reset); -end; -$$; - --- Service role only; never exposed via RLS. -alter table public._supabase_server_rate_limits enable row level security; -``` - -The gate calls `ctx.supabaseAdmin.rpc('_supabase_server_rate_limit_hit', { p_key, p_window_ms })`. Override the function name via `rpc:` in the config if you'd rather pick your own. - -## Config - -| Field | Type | Description | -| ---------- | --------------------------------------------- | ----------------------------------------------------- | -| `limit` | `number` | Maximum hits per `windowMs` per key. | -| `windowMs` | `number` | Window length in milliseconds. | -| `key` | `(req: Request) => string \| Promise` | Bucketing key. Per-IP, per-user, per-tenant, etc. | -| `rpc` | `string?` | RPC name. Default: `_supabase_server_rate_limit_hit`. | - -## Contribution - -```ts -ctx.rateLimit = { - limit: number // configured limit - remaining: number // hits remaining in current window - reset: number // ms epoch when the window resets -} -``` - -## Errors - -- **429 Too Many Requests** with `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining: 0`, `X-RateLimit-Reset` headers. Body: `{ error: 'rate_limit_exceeded', retryAfter: }`. -- If the RPC isn't installed, the gate throws with a hint pointing at this README's migration block. - -## Composing with `withSupabase` - -```ts -import { withSupabase } from '@supabase/server' - -withSupabase( - { allow: 'user' }, - withRateLimit( - { - limit: 30, - windowMs: 60_000, - key: (req) => req.headers.get('cf-connecting-ip') ?? 'anon', - }, - async (_req, ctx) => Response.json({ user: ctx.userClaims!.id }), - ), -) -``` - -The inner handler's `ctx` includes both `withSupabase` keys and `ctx.rateLimit`. - -## See also - -- [Gate composition primitives](../../core/gates/README.md) diff --git a/src/gates/rate-limit/index.ts b/src/gates/rate-limit/index.ts deleted file mode 100644 index 3496355..0000000 --- a/src/gates/rate-limit/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Rate-limit gate. - * - * @packageDocumentation - */ - -export { withRateLimit } from './with-rate-limit.js' -export type { - RateLimitState, - SupabaseRpcClient, - WithRateLimitConfig, -} from './with-rate-limit.js' diff --git a/src/gates/rate-limit/with-rate-limit.test.ts b/src/gates/rate-limit/with-rate-limit.test.ts deleted file mode 100644 index 79ae3a0..0000000 --- a/src/gates/rate-limit/with-rate-limit.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' - -import { withRateLimit, type SupabaseRpcClient } from './with-rate-limit.js' - -const innerOk = async () => Response.json({ ok: true }) - -beforeAll(() => { - vi.useFakeTimers() - vi.setSystemTime(new Date(1_700_000_000_000)) -}) - -afterEach(() => { - vi.setSystemTime(new Date(1_700_000_000_000)) -}) - -/** - * In-memory fake of the Supabase RPC client that mimics the SQL function - * the gate expects. - */ -function makeFakeAdmin(): SupabaseRpcClient & { - rpc: ReturnType -} { - const buckets = new Map() - const rpc = vi.fn( - async ( - _fn: string, - args: { p_key: string; p_window_ms: number }, - ): Promise<{ data: { count: number; reset_at: number }; error: null }> => { - const now = Date.now() - const existing = buckets.get(args.p_key) - let next: { count: number; reset_at: number } - if (!existing || existing.reset_at <= now) { - next = { count: 1, reset_at: now + args.p_window_ms } - } else { - next = { count: existing.count + 1, reset_at: existing.reset_at } - } - buckets.set(args.p_key, next) - return { data: { ...next }, error: null } - }, - ) - return { rpc } as SupabaseRpcClient & { rpc: typeof rpc } -} - -describe('withRateLimit', () => { - it('admits requests under the limit and contributes ctx.rateLimit', async () => { - const supabaseAdmin = makeFakeAdmin() - const handler = withRateLimit( - { limit: 3, windowMs: 60_000, key: () => 'k' }, - async (_req, ctx) => - Response.json({ remaining: ctx.rateLimit.remaining }), - ) - - const r1 = await handler(new Request('http://localhost/'), { - supabaseAdmin, - }) - const r2 = await handler(new Request('http://localhost/'), { - supabaseAdmin, - }) - const r3 = await handler(new Request('http://localhost/'), { - supabaseAdmin, - }) - - expect(r1.status).toBe(200) - expect(await r1.json()).toEqual({ remaining: 2 }) - expect(await r2.json()).toEqual({ remaining: 1 }) - expect(await r3.json()).toEqual({ remaining: 0 }) - expect(supabaseAdmin.rpc).toHaveBeenCalledTimes(3) - }) - - it('rejects with 429 + Retry-After once the limit is exceeded', async () => { - const supabaseAdmin = makeFakeAdmin() - const handler = withRateLimit( - { limit: 1, windowMs: 60_000, key: () => 'k' }, - innerOk, - ) - - const ok = await handler(new Request('http://localhost/'), { - supabaseAdmin, - }) - expect(ok.status).toBe(200) - - const blocked = await handler(new Request('http://localhost/'), { - supabaseAdmin, - }) - expect(blocked.status).toBe(429) - expect(blocked.headers.get('Retry-After')).toBe('60') - expect(blocked.headers.get('X-RateLimit-Limit')).toBe('1') - expect(blocked.headers.get('X-RateLimit-Remaining')).toBe('0') - expect(blocked.headers.get('X-RateLimit-Reset')).toBe( - String(Math.floor((1_700_000_000_000 + 60_000) / 1000)), - ) - const body = await blocked.json() - expect(body).toMatchObject({ error: 'rate_limit_exceeded', retryAfter: 60 }) - }) - - it('isolates buckets by key', async () => { - const supabaseAdmin = makeFakeAdmin() - const handler = withRateLimit( - { - limit: 1, - windowMs: 60_000, - key: (req) => new URL(req.url).searchParams.get('user') ?? 'anon', - }, - innerOk, - ) - - expect( - ( - await handler(new Request('http://localhost/?user=a'), { - supabaseAdmin, - }) - ).status, - ).toBe(200) - expect( - ( - await handler(new Request('http://localhost/?user=b'), { - supabaseAdmin, - }) - ).status, - ).toBe(200) - expect( - ( - await handler(new Request('http://localhost/?user=a'), { - supabaseAdmin, - }) - ).status, - ).toBe(429) - expect( - ( - await handler(new Request('http://localhost/?user=b'), { - supabaseAdmin, - }) - ).status, - ).toBe(429) - }) - - it('honors a custom rpc name', async () => { - const supabaseAdmin = makeFakeAdmin() - const handler = withRateLimit( - { - limit: 1, - windowMs: 60_000, - key: () => 'k', - rpc: 'my_custom_rate_limit', - }, - innerOk, - ) - - await handler(new Request('http://localhost/'), { supabaseAdmin }) - expect(supabaseAdmin.rpc).toHaveBeenCalledWith('my_custom_rate_limit', { - p_key: 'k', - p_window_ms: 60_000, - }) - }) - - it('throws a helpful error when the rpc is missing', async () => { - const supabaseAdmin = { - rpc: vi.fn(async () => ({ - data: null, - error: { - code: '42883', - message: 'function _supabase_server_rate_limit_hit does not exist', - }, - })), - } satisfies SupabaseRpcClient - const handler = withRateLimit( - { limit: 1, windowMs: 60_000, key: () => 'k' }, - innerOk, - ) - - await expect( - handler(new Request('http://localhost/'), { supabaseAdmin }), - ).rejects.toThrow(/RPC .* not found/) - }) -}) diff --git a/src/gates/rate-limit/with-rate-limit.ts b/src/gates/rate-limit/with-rate-limit.ts deleted file mode 100644 index 3fec188..0000000 --- a/src/gates/rate-limit/with-rate-limit.ts +++ /dev/null @@ -1,167 +0,0 @@ -/** - * Fixed-window rate-limit gate. - * - * Counts hits per key within a rolling window via a Supabase Postgres RPC; - * rejects with 429 when the count exceeds `limit`. - * - * The user owns the schema. Run the migration in this gate's README to - * install the table + atomic-increment function. The gate then calls - * `ctx.supabaseAdmin.rpc(, { p_key, p_window_ms })` and expects - * back `{ count, reset_at }` (ms epoch). The admin client comes from - * `withSupabase` upstream — this gate must be wrapped by it (or any wrapper - * that provides `supabaseAdmin`). - */ - -import { defineGate, type Gate } from '../../core/gates/index.js' - -const DEFAULT_RPC = '_supabase_server_rate_limit_hit' - -/** - * Structural subset of the Supabase admin client surface used by this gate. - * Any client whose `rpc` resolves to Supabase-shaped `{ data, error }` - * works — typed as `PromiseLike` so `supabase-js`'s `PostgrestFilterBuilder` - * (a thenable, not a strict `Promise`) satisfies it. - */ -export interface SupabaseRpcClient { - rpc( - fn: string, - args?: Record, - ): PromiseLike<{ - data: T | null - error: { message: string; code?: string } | null - }> -} - -export interface WithRateLimitConfig { - /** Maximum hits per `windowMs` per key. */ - limit: number - - /** Window length in milliseconds. */ - windowMs: number - - /** - * Extracts the bucketing key from the request. Common choices: - * - `req => req.headers.get('cf-connecting-ip') ?? 'anon'` for per-IP limits - * - `(req) => req.headers.get('authorization') ?? 'anon'` for per-bearer limits - */ - key: (req: Request) => string | Promise - - /** - * Name of the SQL function the user registered. The function must accept - * `p_key text` and `p_window_ms bigint` and return - * `{ count: int, reset_at: bigint }` (ms epoch). - * - * @defaultValue `'_supabase_server_rate_limit_hit'` - */ - rpc?: string -} - -/** Shape contributed at `ctx.rateLimit` after a successful hit. */ -export interface RateLimitState { - /** The configured limit for this window. */ - limit: number - /** Hits remaining in the current window. */ - remaining: number - /** Absolute ms timestamp when the current window resets. */ - reset: number -} - -interface RpcResult { - count: number - reset_at: number -} - -/** - * Fixed-window rate-limit gate. Must be wrapped by `withSupabase` (or any - * wrapper that provides `supabaseAdmin`) — the gate calls into it. - * - * @example - * ```ts - * import { withSupabase } from '@supabase/server' - * import { withRateLimit } from '@supabase/server/gates/rate-limit' - * - * export default { - * fetch: withSupabase( - * { allow: 'always' }, - * withRateLimit( - * { - * limit: 60, - * windowMs: 60_000, - * key: (req) => req.headers.get('cf-connecting-ip') ?? 'anon', - * }, - * async (req, ctx) => - * Response.json({ remaining: ctx.rateLimit.remaining }), - * ), - * ), - * } - * ``` - */ -export const withRateLimit: Gate< - 'rateLimit', - WithRateLimitConfig, - { supabaseAdmin: SupabaseRpcClient }, - RateLimitState -> = defineGate< - 'rateLimit', - WithRateLimitConfig, - { supabaseAdmin: SupabaseRpcClient }, - RateLimitState ->({ - key: 'rateLimit', - run: (config) => { - const rpc = config.rpc ?? DEFAULT_RPC - - return async (req, ctx) => { - const key = await config.key(req) - const { data, error } = await ctx.supabaseAdmin.rpc(rpc, { - p_key: key, - p_window_ms: config.windowMs, - }) - - if (error || !data) { - if ( - error?.code === '42883' || - error?.message?.toLowerCase().includes('function') - ) { - throw new Error( - `withRateLimit: RPC '${rpc}' not found. Install the migration ` + - `from this gate's README before calling.`, - ) - } - throw new Error( - `withRateLimit: rpc failed: ${error?.message ?? 'no data returned'}`, - ) - } - - const remaining = Math.max(0, config.limit - data.count) - const resetSec = Math.floor(data.reset_at / 1000) - - if (data.count > config.limit) { - const retryAfter = Math.max( - 1, - Math.ceil((data.reset_at - Date.now()) / 1000), - ) - return Response.json( - { error: 'rate_limit_exceeded', retryAfter }, - { - status: 429, - headers: { - 'Retry-After': String(retryAfter), - 'X-RateLimit-Limit': String(config.limit), - 'X-RateLimit-Remaining': '0', - 'X-RateLimit-Reset': String(resetSec), - }, - }, - ) - } - - return { - rateLimit: { - limit: config.limit, - remaining, - reset: data.reset_at, - }, - } - } - }, -}) diff --git a/src/gates/webhook/README.md b/src/gates/webhook/README.md deleted file mode 100644 index 8e7199c..0000000 --- a/src/gates/webhook/README.md +++ /dev/null @@ -1,146 +0,0 @@ -# `@supabase/server/gates/webhook` - -HMAC signature verification for inbound webhooks. Reads the raw request body, verifies it against a shared secret, checks the replay window, and contributes the parsed event + raw bytes to `ctx.webhook`. - -```ts -import { withWebhook } from '@supabase/server/gates/webhook' - -export default { - fetch: withWebhook( - { - provider: { - kind: 'github', - secret: process.env.GITHUB_WEBHOOK_SECRET!, - }, - }, - async (req, ctx) => { - const event = req.headers.get('x-github-event') - if (event === 'pull_request') { - const pr = ctx.webhook.event as { action: string } - // … - } - return new Response(null, { status: 204 }) - }, - ), -} -``` - -## Built-in providers - -### Stripe - -Verifies the `Stripe-Signature` header (`t=,v1=`), rejects on: - -- missing header (`signature_missing`) -- malformed header (`signature_malformed`) -- timestamp outside `toleranceMs` (`signature_expired`, default 5 minutes) -- HMAC mismatch (`signature_invalid`) - -Supports key rotation: pass `secret: ['whsec_new', 'whsec_old']` and the gate accepts any of them. - -```ts -withWebhook({ - provider: { - kind: 'stripe', - secret: process.env.STRIPE_WEBHOOK_SECRET!, - toleranceMs: 5 * 60 * 1000, // optional, default 5 minutes - }, -}) -``` - -### GitHub - -Verifies the `X-Hub-Signature-256` header (`sha256=`), rejects on: - -- missing header (`signature_missing`) -- missing `sha256=` prefix (`signature_malformed`) -- HMAC mismatch (`signature_invalid`) - -GitHub's signing scheme has no timestamp, so there's no replay window — pin events to the `X-GitHub-Delivery` UUID for idempotency (see [Idempotency](#idempotency)). The event type is delivered out-of-band in the `X-GitHub-Event` header; the gate exposes it via `req.headers`. - -Key rotation works the same as Stripe: pass `secret: ['new', 'old']` to accept either. - -```ts -withWebhook( - { - provider: { - kind: 'github', - secret: process.env.GITHUB_WEBHOOK_SECRET!, - }, - }, - async (req, ctx) => { - switch (req.headers.get('x-github-event')) { - case 'pull_request': { - const pr = ctx.webhook.event as { action: string } - // … - break - } - case 'push': { - // … - break - } - } - return new Response(null, { status: 204 }) - }, -) -``` - -### Custom - -For any other provider (Svix/Resend, Slack, Shopify, in-house) supply a `verify` function. The gate calls it with the raw body already consumed. Slack, for instance, signs `v0::` and exposes both pieces in headers: - -```ts -withWebhook({ - provider: { - kind: 'custom', - async verify(req, rawBody) { - const ts = req.headers.get('x-slack-request-timestamp') ?? '' - const sig = req.headers.get('x-slack-signature') ?? '' - if (Math.abs(Date.now() / 1000 - Number(ts)) > 5 * 60) { - return { ok: false, error: 'signature_expired' } - } - const expected = - 'v0=' + - (await hmacHex( - process.env.SLACK_SIGNING_SECRET!, - `v0:${ts}:${rawBody}`, - )) - if (!timingSafeEqual(sig, expected)) { - return { ok: false, error: 'signature_invalid' } - } - return { - ok: true, - event: JSON.parse(rawBody), - deliveryId: req.headers.get('x-slack-request-id') ?? '', - timestamp: Number(ts) * 1000, - } - }, - }, -}) -``` - -## Contribution - -```ts -ctx.webhook = { - event: unknown // parsed JSON body - rawBody: string // raw bytes the signature was computed over - deliveryId: string // provider-supplied id (Stripe: event.id; GitHub: x-github-delivery) - timestamp: number // ms epoch -} -``` - -`rawBody` is preserved so downstream handlers can re-verify, forward to other systems, or pass to libraries that expect raw bytes. - -## Body consumption - -The gate reads the request body via `req.text()` once. Downstream handlers that call `req.json()` would fail because the body is already consumed — read from `ctx.webhook.event` (parsed) or `ctx.webhook.rawBody` (raw) instead. - -## Idempotency - -The gate doesn't dedupe. Webhooks are typically delivered at-least-once; persist `deliveryId` to a `webhook_events(provider, delivery_id)` table with a unique index and skip duplicates in your handler. - -## See also - -- [Gate composition primitives](../../core/gates/README.md) -- [Stripe webhook signing docs](https://docs.stripe.com/webhooks#verify-manually) diff --git a/src/gates/webhook/index.ts b/src/gates/webhook/index.ts deleted file mode 100644 index a5d138c..0000000 --- a/src/gates/webhook/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Webhook signature verification gate. - * - * @packageDocumentation - */ - -export { withWebhook } from './with-webhook.js' -export type { - WebhookProvider, - WebhookState, - WebhookVerifyResult, - WithWebhookConfig, -} from './with-webhook.js' diff --git a/src/gates/webhook/with-webhook.test.ts b/src/gates/webhook/with-webhook.test.ts deleted file mode 100644 index f7a6806..0000000 --- a/src/gates/webhook/with-webhook.test.ts +++ /dev/null @@ -1,308 +0,0 @@ -import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' - -import { withWebhook } from './with-webhook.js' - -const SECRET = 'whsec_test' - -beforeAll(() => { - vi.useFakeTimers() - vi.setSystemTime(new Date(1_700_000_000_000)) -}) - -afterEach(() => { - vi.setSystemTime(new Date(1_700_000_000_000)) -}) - -async function hmacHex(secret: string, payload: string): Promise { - const enc = new TextEncoder() - const key = await crypto.subtle.importKey( - 'raw', - enc.encode(secret), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['sign'], - ) - const sig = await crypto.subtle.sign('HMAC', key, enc.encode(payload)) - return Array.from(new Uint8Array(sig)) - .map((b) => b.toString(16).padStart(2, '0')) - .join('') -} - -const innerOk = async () => Response.json({ ok: true }) - -describe('withWebhook (stripe)', () => { - it('admits a valid Stripe signature and contributes parsed event', async () => { - const body = JSON.stringify({ - id: 'evt_123', - type: 'payment_intent.succeeded', - created: 1_700_000_000, - }) - const t = 1_700_000_000 - const v1 = await hmacHex(SECRET, `${t}.${body}`) - - const inner = vi.fn(async (_req: Request, ctx) => { - expect(ctx.webhook.deliveryId).toBe('evt_123') - expect((ctx.webhook.event as { type: string }).type).toBe( - 'payment_intent.succeeded', - ) - expect(ctx.webhook.timestamp).toBe(1_700_000_000_000) - expect(ctx.webhook.rawBody).toBe(body) - return Response.json({ ok: true }) - }) - - const handler = withWebhook( - { provider: { kind: 'stripe', secret: SECRET } }, - inner, - ) - - const res = await handler( - new Request('http://localhost/', { - method: 'POST', - headers: { 'stripe-signature': `t=${t},v1=${v1}` }, - body, - }), - ) - - expect(res.status).toBe(200) - expect(inner).toHaveBeenCalledOnce() - }) - - it('rejects when the signature header is missing', async () => { - const handler = withWebhook( - { provider: { kind: 'stripe', secret: SECRET } }, - innerOk, - ) - - const res = await handler( - new Request('http://localhost/', { method: 'POST', body: '{}' }), - ) - - expect(res.status).toBe(401) - expect((await res.json()).error).toBe('signature_missing') - }) - - it('rejects on a bad signature', async () => { - const body = '{"id":"evt_1"}' - const t = 1_700_000_000 - - const handler = withWebhook( - { provider: { kind: 'stripe', secret: SECRET } }, - innerOk, - ) - - const res = await handler( - new Request('http://localhost/', { - method: 'POST', - headers: { 'stripe-signature': `t=${t},v1=deadbeef` }, - body, - }), - ) - - expect(res.status).toBe(401) - expect((await res.json()).error).toBe('signature_invalid') - }) - - it('rejects when the timestamp is outside the tolerance window', async () => { - const body = '{"id":"evt_1"}' - const t = 1_700_000_000 - 600 // 10 min ago, default tolerance is 5 min - const v1 = await hmacHex(SECRET, `${t}.${body}`) - - const handler = withWebhook( - { provider: { kind: 'stripe', secret: SECRET } }, - innerOk, - ) - - const res = await handler( - new Request('http://localhost/', { - method: 'POST', - headers: { 'stripe-signature': `t=${t},v1=${v1}` }, - body, - }), - ) - - expect(res.status).toBe(401) - expect((await res.json()).error).toBe('signature_expired') - }) - - it('accepts any of multiple secrets (rotation)', async () => { - const body = '{"id":"evt_rot"}' - const t = 1_700_000_000 - const oldSecret = 'whsec_old' - const v1 = await hmacHex(oldSecret, `${t}.${body}`) - - const handler = withWebhook( - { provider: { kind: 'stripe', secret: ['whsec_new', oldSecret] } }, - innerOk, - ) - - const res = await handler( - new Request('http://localhost/', { - method: 'POST', - headers: { 'stripe-signature': `t=${t},v1=${v1}` }, - body, - }), - ) - - expect(res.status).toBe(200) - }) -}) - -describe('withWebhook (github)', () => { - const GH_SECRET = 'ghsec_test' - - it('admits a valid GitHub signature and contributes parsed event', async () => { - const body = JSON.stringify({ action: 'opened', number: 7 }) - const sig = 'sha256=' + (await hmacHex(GH_SECRET, body)) - - const inner = vi.fn(async (_req: Request, ctx) => { - expect(ctx.webhook.deliveryId).toBe( - '72d3162e-cc78-11e3-81ab-4c9367dc0958', - ) - expect((ctx.webhook.event as { action: string }).action).toBe('opened') - expect(ctx.webhook.rawBody).toBe(body) - expect(ctx.webhook.timestamp).toBe(1_700_000_000_000) - return Response.json({ ok: true }) - }) - - const handler = withWebhook( - { provider: { kind: 'github', secret: GH_SECRET } }, - inner, - ) - - const res = await handler( - new Request('http://localhost/', { - method: 'POST', - headers: { - 'x-hub-signature-256': sig, - 'x-github-delivery': '72d3162e-cc78-11e3-81ab-4c9367dc0958', - 'x-github-event': 'pull_request', - }, - body, - }), - ) - - expect(res.status).toBe(200) - expect(inner).toHaveBeenCalledOnce() - }) - - it('rejects when the signature header is missing', async () => { - const handler = withWebhook( - { provider: { kind: 'github', secret: GH_SECRET } }, - innerOk, - ) - - const res = await handler( - new Request('http://localhost/', { method: 'POST', body: '{}' }), - ) - - expect(res.status).toBe(401) - expect((await res.json()).error).toBe('signature_missing') - }) - - it('rejects when the signature header is missing the sha256= prefix', async () => { - const body = '{}' - const v = await hmacHex(GH_SECRET, body) - - const handler = withWebhook( - { provider: { kind: 'github', secret: GH_SECRET } }, - innerOk, - ) - - const res = await handler( - new Request('http://localhost/', { - method: 'POST', - headers: { 'x-hub-signature-256': v }, // no sha256= prefix - body, - }), - ) - - expect(res.status).toBe(401) - expect((await res.json()).error).toBe('signature_malformed') - }) - - it('rejects on a bad signature', async () => { - const handler = withWebhook( - { provider: { kind: 'github', secret: GH_SECRET } }, - innerOk, - ) - - const res = await handler( - new Request('http://localhost/', { - method: 'POST', - headers: { 'x-hub-signature-256': 'sha256=' + 'de'.repeat(32) }, - body: '{"action":"opened"}', - }), - ) - - expect(res.status).toBe(401) - expect((await res.json()).error).toBe('signature_invalid') - }) - - it('accepts any of multiple secrets (rotation)', async () => { - const body = '{"action":"opened"}' - const oldSecret = 'ghsec_old' - const sig = 'sha256=' + (await hmacHex(oldSecret, body)) - - const handler = withWebhook( - { provider: { kind: 'github', secret: ['ghsec_new', oldSecret] } }, - innerOk, - ) - - const res = await handler( - new Request('http://localhost/', { - method: 'POST', - headers: { 'x-hub-signature-256': sig }, - body, - }), - ) - - expect(res.status).toBe(200) - }) -}) - -describe('withWebhook (custom)', () => { - it('passes when the custom verifier returns ok', async () => { - const verify = vi.fn(async (_req: Request, body: string) => ({ - ok: true as const, - event: JSON.parse(body), - deliveryId: 'd-1', - timestamp: 1_700_000_000_000, - })) - - const inner = vi.fn(async (_req: Request, ctx) => { - expect(ctx.webhook.deliveryId).toBe('d-1') - return Response.json({ ok: true }) - }) - - const handler = withWebhook({ provider: { kind: 'custom', verify } }, inner) - - const res = await handler( - new Request('http://localhost/', { - method: 'POST', - body: '{"hi":1}', - }), - ) - - expect(res.status).toBe(200) - expect(verify).toHaveBeenCalledOnce() - }) - - it('rejects when the custom verifier returns failure', async () => { - const handler = withWebhook( - { - provider: { - kind: 'custom', - verify: () => ({ ok: false, status: 403, error: 'forbidden' }), - }, - }, - innerOk, - ) - - const res = await handler( - new Request('http://localhost/', { method: 'POST', body: '{}' }), - ) - - expect(res.status).toBe(403) - expect((await res.json()).error).toBe('forbidden') - }) -}) diff --git a/src/gates/webhook/with-webhook.ts b/src/gates/webhook/with-webhook.ts deleted file mode 100644 index 6b09a6d..0000000 --- a/src/gates/webhook/with-webhook.ts +++ /dev/null @@ -1,259 +0,0 @@ -/** - * Webhook signature verification gate. - * - * Verifies the HMAC signature on an inbound webhook against a shared secret, - * checks the replay window where the provider supplies one, and contributes - * the parsed event + raw body to `ctx.webhook`. Stripe and GitHub are - * built-in; supply a custom `verify` function to plug in others - * (Svix/Resend, Slack, Shopify, in-house). - */ - -import { defineGate, type Gate } from '../../core/gates/index.js' - -const FIVE_MIN_MS = 5 * 60 * 1000 - -export type WebhookProvider = - | { kind: 'stripe'; secret: string | string[]; toleranceMs?: number } - | { kind: 'github'; secret: string | string[] } - | { - kind: 'custom' - /** - * Verify the inbound request and return a `WebhookSuccess` to admit it - * or a `WebhookFailure` to reject. The gate calls this with the raw - * body string already consumed; emit your own response shape if needed. - */ - verify: ( - req: Request, - rawBody: string, - ) => Promise | WebhookVerifyResult - } - -export type WebhookVerifyResult = - | { ok: true; event: unknown; deliveryId: string; timestamp: number } - | { ok: false; status?: number; error?: string } - -export interface WithWebhookConfig { - provider: WebhookProvider -} - -/** Shape contributed at `ctx.webhook` after a successful verification. */ -export interface WebhookState { - /** The parsed JSON event body. */ - event: unknown - /** The raw body bytes (as string) the signature was computed over. */ - rawBody: string - /** Provider-specific delivery id (for idempotency / dedupe). */ - deliveryId: string - /** Provider-supplied event timestamp (ms epoch). */ - timestamp: number -} - -/** - * Webhook signature verification gate. - * - * @example - * ```ts - * import { withWebhook } from '@supabase/server/gates/webhook' - * - * export default { - * fetch: withWebhook( - * { - * provider: { - * kind: 'github', - * secret: process.env.GITHUB_WEBHOOK_SECRET!, - * }, - * }, - * async (req, ctx) => { - * // ctx.webhook.event is the parsed GitHub event payload - * // ctx.webhook.deliveryId is the X-GitHub-Delivery uuid - * // req.headers.get('x-github-event') tells you which event fired - * return new Response(null, { status: 204 }) - * }, - * ), - * } - * ``` - */ -export const withWebhook: Gate< - 'webhook', - WithWebhookConfig, - Record, - WebhookState -> = defineGate< - 'webhook', - WithWebhookConfig, - Record, - WebhookState ->({ - key: 'webhook', - run: (config) => async (req) => { - const rawBody = await req.text() - const { provider } = config - let result: WebhookVerifyResult - switch (provider.kind) { - case 'stripe': - result = await verifyStripe(req, rawBody, provider) - break - case 'github': - result = await verifyGithub(req, rawBody, provider) - break - case 'custom': - result = await provider.verify(req, rawBody) - break - } - - if (!result.ok) { - return Response.json( - { error: result.error ?? 'invalid_signature' }, - { status: result.status ?? 401 }, - ) - } - - return { - webhook: { - event: result.event, - rawBody, - deliveryId: result.deliveryId, - timestamp: result.timestamp, - }, - } - }, -}) - -async function verifyStripe( - req: Request, - rawBody: string, - provider: { kind: 'stripe'; secret: string | string[]; toleranceMs?: number }, -): Promise { - const header = req.headers.get('stripe-signature') - if (!header) return { ok: false, error: 'signature_missing' } - - const parsed = parseStripeHeader(header) - if (!parsed) return { ok: false, error: 'signature_malformed' } - - const tolerance = provider.toleranceMs ?? FIVE_MIN_MS - const ageMs = Math.abs(Date.now() - parsed.t * 1000) - if (ageMs > tolerance) { - return { ok: false, error: 'signature_expired' } - } - - const signedPayload = `${parsed.t}.${rawBody}` - const secrets = Array.isArray(provider.secret) - ? provider.secret - : [provider.secret] - - let matched = false - for (const secret of secrets) { - const expected = await hmacSha256Hex(secret, signedPayload) - for (const v1 of parsed.v1) { - if (timingSafeEqualHex(expected, v1)) { - matched = true - break - } - } - if (matched) break - } - if (!matched) return { ok: false, error: 'signature_invalid' } - - let event: { id?: string; created?: number } & Record - try { - event = JSON.parse(rawBody) as typeof event - } catch { - return { ok: false, error: 'body_not_json' } - } - - return { - ok: true, - event, - deliveryId: typeof event.id === 'string' ? event.id : '', - timestamp: - typeof event.created === 'number' - ? event.created * 1000 - : parsed.t * 1000, - } -} - -async function verifyGithub( - req: Request, - rawBody: string, - provider: { kind: 'github'; secret: string | string[] }, -): Promise { - const header = req.headers.get('x-hub-signature-256') - if (!header) return { ok: false, error: 'signature_missing' } - - const eq = header.indexOf('=') - if (eq < 0 || header.slice(0, eq) !== 'sha256') { - return { ok: false, error: 'signature_malformed' } - } - const provided = header.slice(eq + 1) - - const secrets = Array.isArray(provider.secret) - ? provider.secret - : [provider.secret] - - let matched = false - for (const secret of secrets) { - const expected = await hmacSha256Hex(secret, rawBody) - if (timingSafeEqualHex(expected, provided)) { - matched = true - break - } - } - if (!matched) return { ok: false, error: 'signature_invalid' } - - let event: unknown - try { - event = JSON.parse(rawBody) - } catch { - return { ok: false, error: 'body_not_json' } - } - - return { - ok: true, - event, - deliveryId: req.headers.get('x-github-delivery') ?? '', - timestamp: Date.now(), - } -} - -function parseStripeHeader(header: string): { t: number; v1: string[] } | null { - const parts = header.split(',') - let t: number | null = null - const v1: string[] = [] - for (const part of parts) { - const eq = part.indexOf('=') - if (eq < 0) continue - const k = part.slice(0, eq).trim() - const v = part.slice(eq + 1).trim() - if (k === 't') t = Number(v) - else if (k === 'v1') v1.push(v) - } - if (t === null || Number.isNaN(t) || v1.length === 0) return null - return { t, v1 } -} - -async function hmacSha256Hex(secret: string, payload: string): Promise { - const enc = new TextEncoder() - const key = await crypto.subtle.importKey( - 'raw', - enc.encode(secret), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['sign'], - ) - const sig = await crypto.subtle.sign('HMAC', key, enc.encode(payload)) - const bytes = new Uint8Array(sig) - let hex = '' - for (let i = 0; i < bytes.length; i++) { - hex += bytes[i]!.toString(16).padStart(2, '0') - } - return hex -} - -function timingSafeEqualHex(a: string, b: string): boolean { - if (a.length !== b.length) return false - let mismatch = 0 - for (let i = 0; i < a.length; i++) { - mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i) - } - return mismatch === 0 -} diff --git a/src/gates/x402/README.md b/src/gates/x402/README.md deleted file mode 100644 index 0c0940c..0000000 --- a/src/gates/x402/README.md +++ /dev/null @@ -1,112 +0,0 @@ -# withPayment - -> **Experimental:** Stripe's machine-payment crypto deposit mode is a preview API. Both Stripe's surface and this gate may change. - -Stripe-facilitated [x402](https://www.x402.org) paywall gate. Charge per-call in USDC for any fetch handler — Stripe issues the deposit address, settles on-chain, and the gate admits the request once the `PaymentIntent` has succeeded. - -Persistence (deposit-address → PaymentIntent-id mapping) lives in Supabase Postgres via two RPCs the user installs once. Stripe explicitly assumes the server holds this mapping; there's no `paymentIntents.retrieveByDepositAddress` to fall back on. - -```ts -import Stripe from 'stripe' -import { withSupabase } from '@supabase/server' -import { withPayment } from '@supabase/server/gates/x402' - -const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { - apiVersion: '2026-03-04.preview' as never, -}) - -export default { - fetch: withSupabase( - { allow: 'always' }, - withPayment({ stripe, amountCents: 1 }, async (req, ctx) => - Response.json({ ok: true, paid: ctx.payment.intentId }), - ), - ), -} -``` - -## One-time migration - -Copy this into `supabase/migrations/_supabase_server_x402.sql` and run `supabase db push`: - -```sql -create table if not exists public._supabase_server_x402_intents ( - deposit_address text primary key, - payment_intent_id text not null, - created_at timestamptz not null default now() -); - -create or replace function public._supabase_server_x402_register( - p_deposit_address text, - p_payment_intent_id text -) -returns void -language sql -as $$ - insert into public._supabase_server_x402_intents - (deposit_address, payment_intent_id) - values (p_deposit_address, p_payment_intent_id) - on conflict (deposit_address) do nothing; -$$; - -create or replace function public._supabase_server_x402_lookup( - p_deposit_address text -) -returns text -language sql -as $$ - select payment_intent_id - from public._supabase_server_x402_intents - where deposit_address = p_deposit_address; -$$; - --- Service role only. -alter table public._supabase_server_x402_intents enable row level security; -``` - -Override the function names via `registerRpc` / `lookupRpc` in the config if you'd rather pick your own. - -## How it works - -1. **First request — no `X-PAYMENT` header.** `withPayment` creates a Stripe `PaymentIntent` in crypto-deposit mode, records the deposit address → PI id via `registerRpc`, and short-circuits with a `402 Payment Required` carrying an [x402 v1](https://www.x402.org) `accepts` body that advertises the address. -2. **Client pays.** An x402-aware client (or agent) sends USDC to the advertised address on the requested network. -3. **Retry with `X-PAYMENT` header.** The header is a base64-encoded JSON envelope of the form `{ payload: { authorization: { to: } } }`. `withPayment` decodes it, looks up the matching `PaymentIntent` via `lookupRpc`, and: - - if `status === "succeeded"`, contributes `{ intentId }` to `ctx.payment` and runs the handler, - - if not yet settled, replies `402` with `{ error: "payment_not_settled", status }`, - - if the address is unknown or the header is malformed, falls back to issuing a fresh `402`. - -## Config - -| Field | Type | Description | -| ------------- | ------------ | ------------------------------------------------------ | -| `stripe` | `StripeLike` | Stripe client (or any structurally compatible object). | -| `amountCents` | `number` | Price per call in USD cents. Stripe converts to USDC. | -| `network` | `Network?` | `'base' \| 'tempo' \| 'solana'`. Default `'base'`. | -| `registerRpc` | `string?` | Default: `_supabase_server_x402_register`. | -| `lookupRpc` | `string?` | Default: `_supabase_server_x402_lookup`. | - -`StripeLike` is structurally typed — this package does not depend on the `stripe` SDK at runtime or types-level. Pass any object exposing `paymentIntents.create` and `paymentIntents.retrieve`. - -## Composing with `withSupabase` - -```ts -import { withSupabase } from '@supabase/server' - -withSupabase( - { allow: 'user' }, - withPayment({ stripe, amountCents: 5 }, async (req, ctx) => { - // ctx.supabase is the user-scoped client (from withSupabase) - // ctx.payment.intentId is the settled PaymentIntent id - const { data } = await ctx.supabase.from('premium_reports').select() - return Response.json({ data, paid: ctx.payment.intentId }) - }), -) -``` - -For fully anonymous machine-to-machine paywalls, drop `withSupabase`. - -## See also - -- [Gate composition primitives](../../core/gates/README.md) -- [x402 specification](https://www.x402.org) -- [Stripe machine payments docs](https://docs.stripe.com/payments/machine/x402) diff --git a/src/gates/x402/index.ts b/src/gates/x402/index.ts deleted file mode 100644 index 3aee494..0000000 --- a/src/gates/x402/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Stripe-facilitated x402 paywall gate. - * - * @packageDocumentation - */ - -export { withPayment } from './with-payment.js' -export type { - Network, - PaymentIntent, - PaymentIntentCreateParams, - PaymentState, - StripeLike, - SupabaseRpcClient, - WithPaymentConfig, -} from './with-payment.js' diff --git a/src/gates/x402/with-payment.test.ts b/src/gates/x402/with-payment.test.ts deleted file mode 100644 index 75e6f81..0000000 --- a/src/gates/x402/with-payment.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -import { - withPayment, - type PaymentIntent, - type PaymentState, - type StripeLike, - type SupabaseRpcClient, -} from './with-payment.js' - -type Ctx = { - payment: PaymentState -} - -const innerOk = async () => Response.json({ ok: true }) - -const DEPOSIT_ADDRESS = '0xDEPOSITADDRESS' - -const makePI = (status: string, id = 'pi_test_123'): PaymentIntent => ({ - id, - status, - next_action: { - crypto_display_details: { - deposit_addresses: { base: { address: DEPOSIT_ADDRESS } }, - }, - }, -}) - -const makeStripeMock = (initialStatus = 'requires_action') => { - const create = vi.fn().mockResolvedValue(makePI(initialStatus)) - const retrieve = vi.fn().mockResolvedValue(makePI(initialStatus)) - const stripe: StripeLike = { paymentIntents: { create, retrieve } } - return { stripe, create, retrieve } -} - -/** In-memory fake of the deposit-address → PI-id table the gate calls into. */ -function makeFakeAdmin(): SupabaseRpcClient & { - rpc: ReturnType -} { - const map = new Map() - const rpc = vi.fn( - async ( - fn: string, - args: Record, - ): Promise<{ data: unknown; error: null }> => { - if (fn === '_supabase_server_x402_register') { - const addr = args.p_deposit_address as string - const pi = args.p_payment_intent_id as string - map.set(addr, pi) - return { data: null, error: null } - } - if (fn === '_supabase_server_x402_lookup') { - const addr = args.p_deposit_address as string - return { data: map.get(addr) ?? null, error: null } - } - throw new Error(`unexpected rpc: ${fn}`) - }, - ) - return { rpc } as SupabaseRpcClient & { rpc: typeof rpc } -} - -const encodePayment = (to: string) => - btoa(JSON.stringify({ payload: { authorization: { to } } })) - -describe('withPayment', () => { - it('returns 402 with deposit address when X-PAYMENT is missing', async () => { - const { stripe, create } = makeStripeMock() - const supabaseAdmin = makeFakeAdmin() - const handler = withPayment({ stripe, amountCents: 1 }, innerOk) - - const res = await handler(new Request('http://localhost/api/foo'), { - supabaseAdmin, - }) - - expect(res.status).toBe(402) - const body = await res.json() - expect(body).toEqual({ - x402Version: 1, - accepts: [ - { - scheme: 'exact', - network: 'base', - maxAmountRequired: '1', - asset: 'USDC', - payTo: DEPOSIT_ADDRESS, - resource: '/api/foo', - extra: { stripePaymentIntent: 'pi_test_123' }, - }, - ], - }) - expect(create).toHaveBeenCalledOnce() - expect(supabaseAdmin.rpc).toHaveBeenCalledWith( - '_supabase_server_x402_register', - { - p_deposit_address: DEPOSIT_ADDRESS, - p_payment_intent_id: 'pi_test_123', - }, - ) - }) - - it('runs handler when X-PAYMENT references a succeeded PaymentIntent', async () => { - const { stripe, retrieve } = makeStripeMock() - const supabaseAdmin = makeFakeAdmin() - const inner = vi.fn(async (_req: Request, ctx: Ctx) => { - expect(ctx.payment).toEqual({ intentId: 'pi_test_123' }) - return Response.json({ ok: true }) - }) - const handler = withPayment({ stripe, amountCents: 1 }, inner) - - // Seed the store via a first request. - await handler(new Request('http://localhost/api/foo'), { supabaseAdmin }) - - // Stripe reports the PI as settled on the retry. - retrieve.mockResolvedValueOnce(makePI('succeeded')) - - const res = await handler( - new Request('http://localhost/api/foo', { - headers: { 'x-payment': encodePayment(DEPOSIT_ADDRESS) }, - }), - { supabaseAdmin }, - ) - - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ ok: true }) - expect(inner).toHaveBeenCalledOnce() - expect(retrieve).toHaveBeenCalledWith('pi_test_123') - }) - - it('returns 402 when the PaymentIntent has not settled yet', async () => { - const { stripe } = makeStripeMock('requires_action') - const supabaseAdmin = makeFakeAdmin() - const inner = vi.fn(innerOk) - const handler = withPayment({ stripe, amountCents: 1 }, inner) - - await handler(new Request('http://localhost/api/foo'), { supabaseAdmin }) - - const res = await handler( - new Request('http://localhost/api/foo', { - headers: { 'x-payment': encodePayment(DEPOSIT_ADDRESS) }, - }), - { supabaseAdmin }, - ) - - expect(res.status).toBe(402) - const body = await res.json() - expect(body).toMatchObject({ - x402Version: 1, - error: 'payment_not_settled', - status: 'requires_action', - }) - expect(inner).not.toHaveBeenCalled() - }) - - it('issues a fresh 402 when X-PAYMENT references an unknown deposit address', async () => { - const { stripe, create, retrieve } = makeStripeMock() - const supabaseAdmin = makeFakeAdmin() - const inner = vi.fn(innerOk) - const handler = withPayment({ stripe, amountCents: 1 }, inner) - - const res = await handler( - new Request('http://localhost/api/foo', { - headers: { 'x-payment': encodePayment('0xUNKNOWN') }, - }), - { supabaseAdmin }, - ) - - expect(res.status).toBe(402) - const body = await res.json() - expect(body.accepts?.[0]?.payTo).toBe(DEPOSIT_ADDRESS) - expect(create).toHaveBeenCalledOnce() - expect(retrieve).not.toHaveBeenCalled() - expect(inner).not.toHaveBeenCalled() - }) - - it('issues a fresh 402 when X-PAYMENT is malformed', async () => { - const { stripe, create } = makeStripeMock() - const supabaseAdmin = makeFakeAdmin() - const handler = withPayment({ stripe, amountCents: 1 }, innerOk) - - const res = await handler( - new Request('http://localhost/api/foo', { - headers: { 'x-payment': 'not-base64-json' }, - }), - { supabaseAdmin }, - ) - - expect(res.status).toBe(402) - expect(create).toHaveBeenCalledOnce() - }) - - it('honors a custom network', async () => { - const { stripe } = makeStripeMock() - stripe.paymentIntents.create = vi.fn().mockResolvedValue({ - id: 'pi_custom', - status: 'requires_action', - next_action: { - crypto_display_details: { - deposit_addresses: { solana: { address: 'SOLADDRESS' } }, - }, - }, - }) - const supabaseAdmin = makeFakeAdmin() - - const handler = withPayment( - { stripe, amountCents: 5, network: 'solana' }, - innerOk, - ) - - const res = await handler(new Request('http://localhost/api/foo'), { - supabaseAdmin, - }) - - expect(res.status).toBe(402) - const body = await res.json() - expect(body.accepts[0].network).toBe('solana') - expect(body.accepts[0].payTo).toBe('SOLADDRESS') - expect(supabaseAdmin.rpc).toHaveBeenCalledWith( - '_supabase_server_x402_register', - { p_deposit_address: 'SOLADDRESS', p_payment_intent_id: 'pi_custom' }, - ) - }) - - it('throws a helpful error when the lookup rpc is missing', async () => { - const { stripe } = makeStripeMock() - const supabaseAdmin = { - rpc: vi.fn(async () => ({ - data: null, - error: { - code: '42883', - message: 'function _supabase_server_x402_lookup does not exist', - }, - })), - } satisfies SupabaseRpcClient - const handler = withPayment({ stripe, amountCents: 1 }, innerOk) - - await expect( - handler( - new Request('http://localhost/api/foo', { - headers: { 'x-payment': encodePayment(DEPOSIT_ADDRESS) }, - }), - { supabaseAdmin }, - ), - ).rejects.toThrow(/lookup RPC .* not found/) - }) -}) diff --git a/src/gates/x402/with-payment.ts b/src/gates/x402/with-payment.ts deleted file mode 100644 index 1910f11..0000000 --- a/src/gates/x402/with-payment.ts +++ /dev/null @@ -1,301 +0,0 @@ -/** - * Stripe-facilitated x402 paywall gate. - * - * Issues an HTTP 402 with a Stripe-generated USDC deposit address on - * unauthenticated requests, and lets the handler run once the corresponding - * PaymentIntent has settled on-chain. - * - * Persistence (deposit-address → PaymentIntent-id mapping) lives in Supabase - * Postgres via two RPCs the user installs once. See this gate's README for - * the migration. - * - * @see https://docs.stripe.com/payments/machine/x402 - * @see https://www.x402.org - */ - -import { defineGate, type Gate } from '../../core/gates/index.js' - -const DEFAULT_REGISTER_RPC = '_supabase_server_x402_register' -const DEFAULT_LOOKUP_RPC = '_supabase_server_x402_lookup' - -/** Networks supported by Stripe's machine-payment crypto deposit mode. */ -export type Network = 'base' | 'tempo' | 'solana' - -/** - * Subset of the `Stripe` client surface used by `withPayment`. Structurally - * typed so callers pass their own `Stripe` instance without this package - * depending on the `stripe` SDK at the type level. - */ -export interface StripeLike { - paymentIntents: { - create(params: PaymentIntentCreateParams): Promise - retrieve(id: string): Promise - } -} - -/** - * Structural subset of the Supabase admin client surface used by this gate. - * Typed as `PromiseLike` so `supabase-js`'s `PostgrestFilterBuilder` (a - * thenable, not a strict `Promise`) satisfies it. - */ -export interface SupabaseRpcClient { - rpc( - fn: string, - args?: Record, - ): PromiseLike<{ - data: T | null - error: { message: string; code?: string } | null - }> -} - -export interface PaymentIntent { - id: string - status: string - next_action?: { - crypto_display_details?: { - deposit_addresses?: Partial> - } - } | null -} - -export interface PaymentIntentCreateParams { - amount: number - currency: string - payment_method_types: ['crypto'] - payment_method_data: { type: 'crypto' } - payment_method_options: { - crypto: { - mode: 'deposit' - deposit_options: { networks: Network[] } - } - } - confirm: true -} - -export interface WithPaymentConfig { - /** A `Stripe` instance configured with a secret key and the x402 preview API version. */ - stripe: StripeLike - - /** Price per call, denominated in USD cents. Stripe converts to USDC at settlement. */ - amountCents: number - - /** @defaultValue `"base"` */ - network?: Network - - /** - * RPC that registers a deposit address against a PaymentIntent id. Called - * with `{ p_deposit_address: text, p_payment_intent_id: text }`. - * - * @defaultValue `'_supabase_server_x402_register'` - */ - registerRpc?: string - - /** - * RPC that looks up a PaymentIntent id by deposit address. Called with - * `{ p_deposit_address: text }` and must return the PaymentIntent id - * (or `null` if unknown). - * - * @defaultValue `'_supabase_server_x402_lookup'` - */ - lookupRpc?: string -} - -/** - * Shape contributed at `ctx.payment` once the gate has admitted a paid request. - */ -export interface PaymentState { - /** The id of the settled Stripe `PaymentIntent` that paid for this call. */ - intentId: string -} - -/** - * x402 paywall gate. Must be wrapped by `withSupabase` (or any wrapper that - * provides `supabaseAdmin`) — the gate calls into it for the deposit-address - * → PaymentIntent-id mapping. - * - * @example - * ```ts - * import Stripe from 'stripe' - * import { withSupabase } from '@supabase/server' - * import { withPayment } from '@supabase/server/gates/x402' - * - * const stripe = new Stripe(env.STRIPE_SECRET_KEY, { - * apiVersion: '2026-03-04.preview' as never, - * }) - * - * export default { - * fetch: withSupabase( - * { allow: 'always' }, - * withPayment( - * { stripe, amountCents: 1 }, - * async (req, ctx) => - * Response.json({ ok: true, paid: ctx.payment.intentId }), - * ), - * ), - * } - * ``` - */ -export const withPayment: Gate< - 'payment', - WithPaymentConfig, - { supabaseAdmin: SupabaseRpcClient }, - PaymentState -> = defineGate< - 'payment', - WithPaymentConfig, - { supabaseAdmin: SupabaseRpcClient }, - PaymentState ->({ - key: 'payment', - run: (config) => { - const network = config.network ?? 'base' - const registerRpc = config.registerRpc ?? DEFAULT_REGISTER_RPC - const lookupRpc = config.lookupRpc ?? DEFAULT_LOOKUP_RPC - - return async (req, ctx) => { - const header = req.headers.get('x-payment') - if (header) { - const toAddress = decodePaymentHeader(header) - if (toAddress) { - const paymentIntentId = await lookupPaymentIntent( - ctx.supabaseAdmin, - lookupRpc, - toAddress, - ) - if (paymentIntentId) { - const pi = - await config.stripe.paymentIntents.retrieve(paymentIntentId) - if (pi.status === 'succeeded') { - return { payment: { intentId: paymentIntentId } } - } - return Response.json( - { - x402Version: 1, - error: 'payment_not_settled', - status: pi.status, - }, - { status: 402 }, - ) - } - } - } - - return issuePaymentRequired( - req, - ctx.supabaseAdmin, - config, - network, - registerRpc, - ) - } - }, -}) - -function decodePaymentHeader(header: string): string | null { - try { - const decoded = JSON.parse(atob(header)) as { - payload?: { authorization?: { to?: unknown } } - } - const to = decoded.payload?.authorization?.to - return typeof to === 'string' ? to : null - } catch { - return null - } -} - -async function lookupPaymentIntent( - client: SupabaseRpcClient, - rpc: string, - depositAddress: string, -): Promise { - const { data, error } = await client.rpc(rpc, { - p_deposit_address: depositAddress, - }) - if (error) { - if ( - error.code === '42883' || - error.message.toLowerCase().includes('function') - ) { - throw new Error( - `withPayment: lookup RPC '${rpc}' not found. Install the migration ` + - `from this gate's README before calling.`, - ) - } - throw new Error(`withPayment: lookup rpc failed: ${error.message}`) - } - return typeof data === 'string' && data.length > 0 ? data : null -} - -async function registerPaymentIntent( - client: SupabaseRpcClient, - rpc: string, - depositAddress: string, - paymentIntentId: string, -): Promise { - const { error } = await client.rpc(rpc, { - p_deposit_address: depositAddress, - p_payment_intent_id: paymentIntentId, - }) - if (error) { - if ( - error.code === '42883' || - error.message.toLowerCase().includes('function') - ) { - throw new Error( - `withPayment: register RPC '${rpc}' not found. Install the migration ` + - `from this gate's README before calling.`, - ) - } - throw new Error(`withPayment: register rpc failed: ${error.message}`) - } -} - -async function issuePaymentRequired( - req: Request, - client: SupabaseRpcClient, - config: WithPaymentConfig, - network: Network, - registerRpc: string, -): Promise { - const pi = await config.stripe.paymentIntents.create({ - amount: config.amountCents, - currency: 'usd', - payment_method_types: ['crypto'], - payment_method_data: { type: 'crypto' }, - payment_method_options: { - crypto: { - mode: 'deposit', - deposit_options: { networks: [network] }, - }, - }, - confirm: true, - }) - - const address = - pi.next_action?.crypto_display_details?.deposit_addresses?.[network] - ?.address - if (!address) { - throw new Error( - `Stripe PaymentIntent ${pi.id} did not return a deposit address for ${network}`, - ) - } - await registerPaymentIntent(client, registerRpc, address, pi.id) - - return Response.json( - { - x402Version: 1, - accepts: [ - { - scheme: 'exact', - network, - maxAmountRequired: String(config.amountCents), - asset: 'USDC', - payTo: address, - resource: new URL(req.url).pathname, - extra: { stripePaymentIntent: pi.id }, - }, - ], - }, - { status: 402 }, - ) -} diff --git a/tsdown.config.ts b/tsdown.config.ts index e8cc5a2..cbd9c04 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -7,11 +7,7 @@ export default defineConfig({ 'src/core/gates/index.ts', 'src/adapters/hono/index.ts', 'src/adapters/h3/index.ts', - 'src/gates/cloudflare/index.ts', - 'src/gates/flag/index.ts', - 'src/gates/rate-limit/index.ts', - 'src/gates/webhook/index.ts', - 'src/gates/x402/index.ts', + 'src/gates/feature-flag/index.ts', ], format: ['esm', 'cjs'], dts: true,