Skip to content

Commit ed834f1

Browse files
mandariniclaude
andcommitted
feat(middleware): add postgres and claims middleware entrypoints
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>
1 parent bfe8cd2 commit ed834f1

8 files changed

Lines changed: 527 additions & 4 deletions

File tree

jsr.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
"./adapters/hono": "./src/adapters/hono/index.ts",
99
"./adapters/h3": "./src/adapters/h3/index.ts",
1010
"./adapters/elysia": "./src/adapters/elysia/index.ts",
11-
"./adapters/nestjs": "./src/adapters/nestjs/index.ts"
11+
"./adapters/nestjs": "./src/adapters/nestjs/index.ts",
12+
"./middleware/postgres": "./src/middleware/postgres/index.ts",
13+
"./middleware/claims": "./src/middleware/claims/index.ts"
1214
},
1315
"publish": {
1416
"include": [

package.json

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,26 @@
8989
"default": "./dist/adapters/nestjs/index.cjs"
9090
}
9191
},
92+
"./middleware/postgres": {
93+
"import": {
94+
"types": "./dist/middleware/postgres/index.d.mts",
95+
"default": "./dist/middleware/postgres/index.mjs"
96+
},
97+
"require": {
98+
"types": "./dist/middleware/postgres/index.d.cts",
99+
"default": "./dist/middleware/postgres/index.cjs"
100+
}
101+
},
102+
"./middleware/claims": {
103+
"import": {
104+
"types": "./dist/middleware/claims/index.d.mts",
105+
"default": "./dist/middleware/claims/index.mjs"
106+
},
107+
"require": {
108+
"types": "./dist/middleware/claims/index.d.cts",
109+
"default": "./dist/middleware/claims/index.cjs"
110+
}
111+
},
92112
"./package.json": "./package.json"
93113
},
94114
"main": "./dist/index.cjs",
@@ -128,7 +148,8 @@
128148
"@supabase/supabase-js": "^2.0.0",
129149
"elysia": "^1.4.0",
130150
"h3": "^2.0.0",
131-
"hono": "^4.0.0"
151+
"hono": "^4.0.0",
152+
"pg": "^8.0.0"
132153
},
133154
"peerDependenciesMeta": {
134155
"@nestjs/common": {
@@ -142,6 +163,9 @@
142163
},
143164
"elysia": {
144165
"optional": true
166+
},
167+
"pg": {
168+
"optional": true
145169
}
146170
},
147171
"devDependencies": {
@@ -156,6 +180,7 @@
156180
"@supabase/supabase-js": "^2.105.4",
157181
"@swc/core": "^1.15.33",
158182
"@types/node": "^26.0.1",
183+
"@types/pg": "^8.11.0",
159184
"@types/supertest": "^7.2.0",
160185
"elysia": "^1.4.0",
161186
"eslint": "^10.0.2",

pnpm-lock.yaml

Lines changed: 115 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { withClaims } from './index.js'
4+
5+
function base64url(obj: unknown): string {
6+
return Buffer.from(JSON.stringify(obj))
7+
.toString('base64')
8+
.replace(/\+/g, '-')
9+
.replace(/\//g, '_')
10+
.replace(/=+$/, '')
11+
}
12+
13+
function tokenFor(claims: Record<string, unknown>): string {
14+
return `header.${base64url(claims)}.sig`
15+
}
16+
17+
const runtime = { name: 'node' as const, getEnv: () => undefined }
18+
19+
describe('withClaims', () => {
20+
it('decodes the Bearer token payload into ctx.jwtClaims', async () => {
21+
let seen: unknown
22+
const handler = withClaims(async (_req, ctx) => {
23+
seen = ctx.jwtClaims
24+
return Response.json({ ok: true })
25+
})
26+
27+
await handler(
28+
new Request('http://localhost', {
29+
headers: {
30+
Authorization: `Bearer ${tokenFor({ sub: 'u1', role: 'authenticated' })}`,
31+
},
32+
}),
33+
{ _runtime: runtime },
34+
)
35+
36+
expect(seen).toEqual({ sub: 'u1', role: 'authenticated' })
37+
})
38+
39+
it('contributes null when no Authorization header is present', async () => {
40+
let seen: unknown = 'unset'
41+
const handler = withClaims(async (_req, ctx) => {
42+
seen = ctx.jwtClaims
43+
return Response.json({ ok: true })
44+
})
45+
46+
await handler(new Request('http://localhost'), { _runtime: runtime })
47+
48+
expect(seen).toBeNull()
49+
})
50+
51+
it('contributes null for a malformed token', async () => {
52+
let seen: unknown = 'unset'
53+
const handler = withClaims(async (_req, ctx) => {
54+
seen = ctx.jwtClaims
55+
return Response.json({ ok: true })
56+
})
57+
58+
await handler(
59+
new Request('http://localhost', {
60+
headers: { Authorization: 'Bearer not-a-jwt' },
61+
}),
62+
{ _runtime: runtime },
63+
)
64+
65+
expect(seen).toBeNull()
66+
})
67+
})

src/middleware/claims/index.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { defineMiddleware } from '@supabase/web-middleware'
2+
3+
/**
4+
* Loosely-typed JWT claims contributed by {@link withClaims}.
5+
*
6+
* @category Middleware
7+
*/
8+
export interface JwtClaims {
9+
sub?: string
10+
role?: string
11+
[k: string]: unknown
12+
}
13+
14+
/** base64url-decode a JWT payload segment (no signature verification). */
15+
function decodeJwtPayload(token: string): JwtClaims | null {
16+
const part = token.split('.')[1]
17+
if (!part) return null
18+
const b64 = part
19+
.replace(/-/g, '+')
20+
.replace(/_/g, '/')
21+
.padEnd(Math.ceil(part.length / 4) * 4, '=')
22+
try {
23+
return JSON.parse(atob(b64)) as JwtClaims
24+
} catch {
25+
return null
26+
}
27+
}
28+
29+
/**
30+
* Contributes `ctx.jwtClaims` by decoding the caller's Bearer token.
31+
*
32+
* Use this only when composing a standalone `pipeline([...], handler)` that is
33+
* **not** wrapped by `withSupabase` — for example a Supabase-agnostic Edge
34+
* Function that still wants the caller's claims available to a downstream
35+
* middleware such as {@link withPostgres}. Inside `withSupabase`, the context
36+
* already carries `jwtClaims` (JWKS-verified), so `withClaims` is unnecessary.
37+
*
38+
* > **DEMO ONLY — does NOT verify the signature.** It base64url-decodes the
39+
* > payload so the Postgres example is self-contained. `withSupabase` verifies
40+
* > the JWT against the project JWKS before trusting the claims; never trust an
41+
* > unverified token in production.
42+
*
43+
* @category Middleware
44+
*/
45+
export const withClaims = defineMiddleware<
46+
'jwtClaims',
47+
void,
48+
Record<never, never>,
49+
JwtClaims | null
50+
>({
51+
key: 'jwtClaims',
52+
run: () => async (req) => {
53+
const auth = req.headers.get('Authorization')
54+
const token = auth?.replace(/^Bearer\s+/i, '')
55+
return { jwtClaims: token ? decodeJwtPayload(token) : null }
56+
},
57+
})

0 commit comments

Comments
 (0)