Skip to content

feat: withSupabase on the @supabase/middleware engine - #88

Merged
mandarini merged 16 commits into
mainfrom
feat/plugins-option
Aug 13, 2026
Merged

feat: withSupabase on the @supabase/middleware engine#88
mandarini merged 16 commits into
mainfrom
feat/plugins-option

Conversation

@mandarini

@mandarini mandarini commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

withSupabase now runs on the @supabase/middleware engine, and the pieces it is built from ship as public middleware. Adds a middleware option so @supabase/middleware entries can compose around the handler after the Supabase context is established — plus four first-party middleware: withSupabaseClient, withSupabaseAdminClient, withClaims, and withPostgres.

Naming: the array holds middleware (per-request behavior), so that's its name.

import { withSupabase } from '@supabase/server'
import { withPostgres } from '@supabase/server/middleware/postgres'

export default {
  fetch: withSupabase(
    { auth: 'user', middleware: [withPostgres()] },
    async (req, ctx) => {
      ctx.supabase      // from @supabase/server — already present when the middleware runs
      ctx.postgres      // from withPostgres — RLS-scoped SQL as the caller
      const posts = await ctx.postgres.query('select id, title from posts limit 10')
      return Response.json({ posts })
    },
  ),
}

The middleware receive ctx.supabase, ctx.jwtClaims, etc. already populated — they run after the Supabase context is created, not before.

Architecture

End user
  → withSupabase({ middleware: [withPostgres(), withRateLimit(cfg)] }, handler)
  → never sees pipeline()

withSupabase internals (this PR)
  → runs on the engine for EVERY request: folds
    [withSupabaseClient, withSupabaseAdminClient, ...config.middleware]
    around the handler — the same public middleware anyone can compose
  → verifyAuth stays outside the chain (ordered multi-mode resolution +
    historical error shapes), its verified identity is seeded via seedContext()
  → forwards the host's 2nd fetch arg (Workers env) so bindings reach getEnv

Middleware author (e.g. withPostgres)
  → defineMiddleware({ key, run })
  → env access via the importable getEnv() — no reserved ctx keys
  → never calls pipeline()

Why verifyAuth isn't itself a chain entry: an engine middleware contributes exactly one ctx key; verifyAuth's single pass produces four (userClaims, jwtClaims, authMode, authKeyName) via ordered multi-mode resolution. Instead, withClaims shares the same verification core (verifyUserJwt) as verifyAuth's user mode — same code, two surfaces.

What changed

  • src/with-supabase.ts — rewritten on the engine: the two client middleware compose around the user's middleware array and handler on every request. Public API, ctx keys, and error shapes are unchanged (the pre-existing test suite passes unmodified as the parity proof). Two named overloads (no-middleware / with-middleware) so TypeScript infers the handler's ctx concretely; MiddlewareCtx<Entries> accumulates the entries' key contributions. Client-construction failures keep their historical JSON responses — phase-guarded so user middleware / handler throws propagate exactly as before
  • src/middleware/client (@supabase/server/middleware/client) — withSupabaseClient<Database>(): contributes ctx.supabase (RLS-scoped). Under withSupabase it mirrors verified credentials (token only for user mode, matched publishable key by name); standalone it attaches the raw bearer — PostgREST verifies on every query
  • src/middleware/admin-client (@supabase/server/middleware/admin-client) — withSupabaseAdminClient<Database>(): contributes ctx.supabaseAdmin, secret key selected from the upstream authKeyName when the request authenticated as secret
  • src/middleware/claims (@supabase/server/middleware/claims) — withClaims: now JWKS-verified only (shared verifyUserJwt core: JWKS resolver caching, HS256 shared-secret path, sb_* passthrough). No token → contributes null; invalid token → 401; missing JWKS → 500. The demo-grade decoder is gone
  • src/middleware/postgres (@supabase/server/middleware/postgres) — withPostgres: contributes ctx.postgres, an RLS-scoped pg client. Every query runs in its own transaction that injects the caller's claims (request.jwt.claims) and drops to their role, PostgREST-style; the role is clamped to authenticated/anon. Table-grants hint on 42501
  • src/core/verify-user-jwt.ts — the extracted shared verification core used by both verifyCredentials and withClaims
  • package.json@supabase/middleware@^0.3.0 from npm (preview build retired) + four middleware subpath exports (also in jsr.json / tsdown)
  • Tests — middleware composition/order/short-circuit/CORS, platform-env forwarding, JWKS verification (RS256 + HS256 + foreign-key rejection), client key selection from upstream auth, client-construction error mapping vs handler-throw propagation, claim injection/role clamp/rollback/42501 for withPostgres

Compatibility

  • No breaking changes for released consumers: every existing export is unchanged; the six SupabaseContext keys keep exact semantics; error JSON shapes are identical. New subpaths are additive; release is a minor
  • The returned fetch handler gained an optional second parameter (the host platform arg) — assignable in both directions
  • Type-level floor is TS ≥ 5.4 (the engine's .d.ts uses NoInfer); consumers with skipLibCheck are unaffected
  • @supabase/middleware@0.3.0 is published on both npm and JSR; the JSR dry-run packaging check passes in CI

Try it

npm i https://pkg.pr.new/@supabase/server@88

🤖 Generated with Claude Code

@pkg-pr-new

pkg-pr-new Bot commented Jul 1, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@supabase/server@88

commit: bf90330

@mandarini
mandarini force-pushed the feat/plugins-option branch from 0e747ac to ad449a9 Compare July 1, 2026 16:55
@mandarini mandarini changed the title feat: add plugins option to withSupabase feat: add middleware option to withSupabase Jul 2, 2026
@mandarini
mandarini force-pushed the feat/plugins-option branch 2 times, most recently from 9e402e1 to 44b05da Compare July 9, 2026 08:14
@mandarini
mandarini force-pushed the feat/plugins-option branch from fa1d063 to ac091c2 Compare July 21, 2026 11:34
mandarini and others added 8 commits August 11, 2026 10:50
…lution

TypeScript doesn't apply excess property checking during overload resolution,
so calls with plugins: [...] were silently matching overload 1 and typing ctx
as SupabaseContext<unknown>. Adding plugins?: never to overload 1's config
makes it definitively fail when plugins is present, falling through to the
correct overload.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The array holds middleware entries (per-request behavior from
defineMiddleware) — the word 'plugins' is reserved for the package-level
concept whose client namespace goes in createClient({ plugins }). One
word per concept: server-side composition is 'middleware', client-side
namespaces are 'plugins', a Plugin is the package that ships both.

PluginsCtx -> MiddlewareCtx; overload trick unchanged
(middleware?: never on overload 1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Graduate withPostgres and withClaims out of plugin-examples into
@supabase/server/middleware/*, so the PRFAQ's built-in middleware ship
from the package instead of example-local code (SDK-1163 item 5).

- withPostgres reads claims from ctx.jwtClaims (already populated by
  withSupabase), so `middleware: [withPostgres()]` works with no separate
  withClaims. Keeps the RLS role-clamp and tx-local request.jwt.claims
  injection. pg is an optional peer dep (Node/Deno only, not Workers).
- withClaims ships for the standalone agnostic pipeline() case
  (demo-only: no signature verification).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Append the caller-role grants hint to permission-denied errors and document
the grants requirement in the withPostgres JSDoc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Port from the @supabase/web-middleware PR-9 preview to
@supabase/middleware at main (0641674), which dropped ctx._runtime:

- withSupabase seeds the middleware chain via seedContext() instead of
  faking a { _runtime } facet (the engine now marks contexts with a
  symbol, so the structural fake no longer works)
- withPostgres defaults its connection string from the importable
  getEnv('SUPABASE_DB_URL') instead of ctx._runtime.getEnv
- tests use vi.stubEnv for the env fallback; withClaims tests call the
  handler as a bare fetch entry

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
JSR's slow-types check requires explicit types on public API symbols;
the inferred defineMiddleware return type failed 'Verify JSR packaging'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mandarini mandarini self-assigned this Aug 11, 2026
mandarini and others added 4 commits August 11, 2026 11:20
Replaces the pkg.pr.new preview build with the released package.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
withClaims graduates from the demo-grade payload decoder to real
verification: the user-mode JWT leg of verifyCredentials moves into a
shared verifyUserJwt core (JWKS resolver caching, HS256 shared-secret
path, sb_* passthrough), used by both. No decode-only mode remains — an
invalid token short-circuits 401, a missing JWKS 500.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
withSupabaseClient contributes ctx.supabase (RLS-scoped, caller's
token) and withSupabaseAdminClient contributes ctx.supabaseAdmin,
wrapping the existing createContextClient / createAdminClient
primitives. Composed under withSupabase they read the seeded
authMode / authKeyName to mirror verified credentials exactly;
standalone they work as plain engine entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
withSupabase now runs on @supabase/middleware for every request: the
two public client middleware fold around the user's middleware array
and handler, seeded with the verified auth identity via seedContext.
The host's second fetch argument (Workers env) is forwarded so
bindings reach getEnv. Public API, ctx keys, and error shapes are
unchanged — client-construction failures keep their historical JSON
responses (phase-guarded so handler throws still propagate), and the
existing test suite passes unmodified as the parity proof.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mandarini
mandarini force-pushed the feat/plugins-option branch from 16b75f8 to 39d89be Compare August 11, 2026 08:21
@mandarini mandarini changed the title feat: add middleware option to withSupabase feat: withSupabase on the @supabase/middleware engine — middleware option + first-party middleware Aug 11, 2026
@mandarini
mandarini marked this pull request as ready for review August 11, 2026 08:22
@mandarini
mandarini requested review from a team as code owners August 11, 2026 08:22
@mandarini mandarini changed the title feat: withSupabase on the @supabase/middleware engine — middleware option + first-party middleware feat: withSupabase on the @supabase/middleware engine Aug 11, 2026
@mandarini
mandarini force-pushed the feat/plugins-option branch 2 times, most recently from a66525e to 39d89be Compare August 11, 2026 14:22
@mandarini

Copy link
Copy Markdown
Collaborator Author

You can also test this out on 1.5.0-beta.0

@mandarini
mandarini merged commit 2b5d4fd into main Aug 13, 2026
9 checks passed
@mandarini
mandarini deleted the feat/plugins-option branch August 13, 2026 09:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants