From 9b48cb6d24c33e1569bae8ba1652bcfac43735c1 Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Tue, 25 Aug 2026 18:12:38 +0300 Subject: [PATCH 1/2] feat: validate middleware prereqs against Supabase context --- package.json | 2 +- src/with-supabase.test.ts | 159 +++++++++++++++++++++++++++++++++++++- src/with-supabase.ts | 35 ++++++--- 3 files changed, 180 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index c0eaff5..4f2e43a 100644 --- a/package.json +++ b/package.json @@ -246,7 +246,7 @@ "vitest": "^4.1.0" }, "dependencies": { - "@supabase/middleware": "^0.3.1", + "@supabase/middleware": "^0.4.0", "jose": "^6.2.0" } } diff --git a/src/with-supabase.test.ts b/src/with-supabase.test.ts index ba976ca..f91915d 100644 --- a/src/with-supabase.test.ts +++ b/src/with-supabase.test.ts @@ -1,9 +1,11 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest' import { defineMiddleware, getEnv } from '@supabase/middleware' -import type { FetchHandler } from '@supabase/middleware' +import type { Entry, FetchHandler } from '@supabase/middleware' import { _resetAllowDeprecationWarned } from './core/utils/deprecation.js' import { EnvError, MissingDefaultSecretKeyError } from './errors.js' +import { withClaims } from './middleware/claims/index.js' +import { withPostgresClient } from './middleware/postgres/index.js' import { withOAuthProtectedResource } from './oauth-protected-resource/with-oauth-protected-resource.js' import { withSupabase } from './with-supabase.js' @@ -297,10 +299,13 @@ describe('withSupabase', () => { b: 'http://localhost/supabase', }) - // Check reverse order must breaks dependency chain + // Reverse order is a compile-time ordering error; at runtime the + // broken dependency chain throws all the same. + // @ts-expect-error — prereq 'a' is not yet on the context const handlerReverse = withSupabase( { auth: 'none', env: baseEnv, middleware: [withSecond(), withFirst()] }, - async (_req, ctx) => Response.json({ a: ctx.a, b: ctx.b }), + async (_req: Request, ctx: { a: string; b: URL }) => + Response.json({ a: ctx.a, b: ctx.b }), ) expect(handlerReverse(new Request('http://localhost'))).rejects.toThrow( @@ -483,3 +488,149 @@ describe('withSupabase', () => { }) }) }) + +describe('type guarantees (tsc-verified)', () => { + const withProvider = defineMiddleware< + 'prov', + void, + Record, + { v: number } + >({ + key: 'prov', + run: () => async () => ({ prov: { v: 1 } }), + }) + + const withNeedsProv = defineMiddleware< + 'dep', + void, + { prov: { v: number } }, + { ok: true } + >({ + key: 'dep', + run: () => async () => ({ dep: { ok: true as const } }), + }) + + it('an entry may declare a prerequisite on a Supabase-provided key', () => { + // withPostgresClient declares In: { jwtClaims }; the SupabaseContext + // seed satisfies it, so composing it here compiles. + const _handler = withSupabase( + { auth: 'none', env: baseEnv, middleware: [withPostgresClient()] }, + async (_req, ctx) => { + expectTypeOf(ctx.postgres).not.toBeAny() + expectTypeOf(ctx.supabase).not.toBeAny() + return Response.json({ ok: true }) + }, + ) + void _handler + }) + + it('an entry keyed on a Supabase-provided key fails to compile', () => { + // withClaims contributes 'jwtClaims', which withSupabase already seeds. + // Gating inside the array is redundant; the collision is a type error. + // (Same property the SDK-1614 gate relies on.) + // @ts-expect-error — Conflict<'jwtClaims'>: key already on the context + const _bad = withSupabase( + { auth: 'none', env: baseEnv, middleware: [withClaims()] }, + async () => Response.json({ ok: true }), + ) + void _bad + }) + + it('sibling ordering: provider before dependent compiles', () => { + const _ok = withSupabase( + { + auth: 'none', + env: baseEnv, + middleware: [withProvider(), withNeedsProv()], + }, + async (_req, ctx) => { + expectTypeOf(ctx.dep).toEqualTypeOf<{ ok: true }>() + expectTypeOf(ctx.prov).toEqualTypeOf<{ v: number }>() + return Response.json({ ok: true }) + }, + ) + void _ok + }) + + it('sibling ordering: dependent before provider fails to compile', () => { + // @ts-expect-error — prereq 'prov' is not yet on the context + const _bad = withSupabase( + { + auth: 'none', + env: baseEnv, + middleware: [withNeedsProv(), withProvider()], + }, + async () => Response.json({ ok: true }), + ) + void _bad + }) + + it('a prerequisite nothing supplies fails to compile', () => { + // @ts-expect-error — prereq 'prov' is not on the context + const _bad = withSupabase( + { auth: 'none', env: baseEnv, middleware: [withNeedsProv()] }, + async () => Response.json({ ok: true }), + ) + void _bad + }) + + it('nested: Base flows in beside a middleware array', () => { + // The `satisfies FetchHandler` anchor is what pushes the upstream + // contribution into `Base`; the validation conditional on the handler + // parameter must not resolve the call before that happens. + const _composed = withOAuthProtectedResource( + withSupabase( + { auth: 'none', env: baseEnv, middleware: [withProvider()] }, + async (_req, ctx) => { + expectTypeOf( + ctx.oauthProtectedResource.resourceMetadataUrl, + ).toEqualTypeOf() + expectTypeOf(ctx.prov).toEqualTypeOf<{ v: number }>() + expectTypeOf(ctx.supabase).not.toBeAny() + return Response.json({ ok: true }) + }, + ), + ) satisfies FetchHandler + void _composed + }) + + it('a widened entry poisons validation for later typed entries', () => { + // A hand-wrapped entry types as Entry. Its + // string key folds an index signature into the accumulated context, so + // every later typed key reports a false conflict. Pinned here so the + // failure mode is documented rather than discovered in consumer code. + const widened = ((h) => h) as Entry + + // @ts-expect-error — false Conflict<'prov'> caused by the widened entry + const _bad = withSupabase( + { auth: 'none', env: baseEnv, middleware: [widened, withProvider()] }, + async () => Response.json({ ok: true }), + ) + void _bad + + // Placed last, a widened entry has nothing after it to poison. + const _last = withSupabase( + { auth: 'none', env: baseEnv, middleware: [withProvider(), widened] }, + async (_req, ctx) => { + expectTypeOf(ctx.prov).toEqualTypeOf<{ v: number }>() + return Response.json({ ok: true }) + }, + ) + void _last + }) + + it('explicit Database defaults Entries; array accepted, unvalidated', () => { + const _handler = withSupabase<{ fixture: true }>( + { auth: 'none', env: baseEnv, middleware: [withProvider()] }, + async (_req, ctx) => { + expectTypeOf(ctx.supabase).not.toBeAny() + // Entries defaulted to readonly AnyEntry[]: no tuple inference, so + // contributions are not accumulated onto ctx. + // @ts-expect-error — 'prov' is not on ctx without tuple inference + void ctx.prov + return Response.json({ ok: true }) + }, + ) + void _handler + }) +}) diff --git a/src/with-supabase.ts b/src/with-supabase.ts index 8272526..6841033 100644 --- a/src/with-supabase.ts +++ b/src/with-supabase.ts @@ -9,7 +9,11 @@ import type { WithSupabaseConfig, } from './types.js' import { isContext, seedContext } from '@supabase/middleware' -import type { BaseContext, Entry } from '@supabase/middleware' +import type { + BaseContext, + Entry, + ValidateEntries, +} from '@supabase/middleware' type AnyEntry = Entry // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -120,12 +124,14 @@ export function withSupabase< * ``` * * **Type note.** `MiddlewareCtx` accumulates the key contributions of - * the middleware array. Middleware that declare `In` prerequisites on - * Supabase-provided keys (`supabase`, `userClaims`, …) satisfy those at runtime - * (the Supabase context is merged before the middleware run) but not at the - * type level — a full implementation would widen the prerequisite-validation - * seed to include `SupabaseContext`. Ordering and collision checks within the - * middleware array work normally via `@supabase/middleware`'s runtime chain. + * the middleware array onto the handler's `ctx`. `ValidateEntries` checks the + * array against the same context the entries see at runtime: the upstream + * `Base` plus {@link SupabaseContext}. An entry may declare `In` prerequisites + * on Supabase-provided keys (`supabase`, `jwtClaims`, and the rest). An entry + * whose key collides with a Supabase-provided key, or with an earlier sibling, + * fails to compile. The failure sentinel occupies the handler parameter, never + * `entries`, so `const Entries` tuple inference stays intact, as in the + * engine's `pipeline`. */ export function withSupabase< Database = unknown, @@ -133,10 +139,17 @@ export function withSupabase< Base extends BaseContext = BaseContext, >( config: WithSupabaseConfig & { middleware: Entries }, - handler: ( - req: Request, - ctx: NoInfer & SupabaseContext & MiddlewareCtx, - ) => Promise, + // Validation sits on the handler parameter (never on `entries`) so it does + // not disrupt `const Entries` tuple inference; the seed is the context the + // entries actually see at runtime. + handler: [ + ValidateEntries>, + ] extends [true] + ? ( + req: Request, + ctx: NoInfer & SupabaseContext & MiddlewareCtx, + ) => Promise + : ValidateEntries>, ): (req: Request, ctx?: Base) => Promise export function withSupabase( From 438595fcd81d24b4d1e3f748fd4b644f190966a5 Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Wed, 26 Aug 2026 12:37:12 +0300 Subject: [PATCH 2/2] chore: pnpm i --- pnpm-lock.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8882fa9..8ce72ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: .: dependencies: '@supabase/middleware': - specifier: ^0.3.1 - version: 0.3.1(typescript@5.9.3) + specifier: ^0.4.0 + version: 0.4.0(typescript@5.9.3) jose: specifier: ^6.2.0 version: 6.2.0 @@ -833,8 +833,8 @@ packages: resolution: {integrity: sha512-ADIkJYH5w7HbnGVAAlCbyKoLF5QdfyezBLfYXpUqhxZOacK6YepOvnP/8p4p+50bhTPWp6VhDxu19KO7e/qU2g==} engines: {node: '>=20.0.0'} - '@supabase/middleware@0.3.1': - resolution: {integrity: sha512-ssU8dSgRkKJwwT8AaAno4HDrXs0iD4ocNKDPeBGmi3KIQNtjXZrW4ae4NAPg5O75xHkheOfrZPeXhH2edwgeRw==} + '@supabase/middleware@0.4.0': + resolution: {integrity: sha512-iGDAzSQVKRzaKkcbap4G9C20NWc6MYXNht2Mgg7dkY1E8H3Y4bsR7UlWiaL1sbMkgVO/qS0GnrEEXPnzzBXd0A==} engines: {node: '>=22'} peerDependencies: typescript: '>=5.4' @@ -3478,7 +3478,7 @@ snapshots: dependencies: tslib: 2.8.1 - '@supabase/middleware@0.3.1(typescript@5.9.3)': + '@supabase/middleware@0.4.0(typescript@5.9.3)': dependencies: std-env: 4.2.0 optionalDependencies: