From a4bf2f117ec6adb3ea5032e86546a075a8bfb1cf Mon Sep 17 00:00:00 2001 From: bogdan tarasenko Date: Wed, 20 May 2026 13:26:05 +0200 Subject: [PATCH] feat: add Express 5 adapter Adds @supabase/server/adapters/express targeting Express 5, mirroring the Hono/H3 adapter contract. Public surface: withSupabase() middleware, requireAuth() guard, withSupabaseRoute() per-route wrapper, and the WithSupabaseExpressConfig type. Resolved context lives on res.locals.supabaseContext via Express.Locals declaration merging. Auth errors flow through a configurable onError option (default: next(error), Express-idiomatic). The Express req to Fetch Request bridge preserves headers including multi-value Set-Cookie and Cookie, composes absolute URLs under trust proxy, and forwards bodies for non-GET/HEAD methods. No new runtime deps; express ^5.0.0 is an optional peer. --- README.md | 43 +- docs/adapters/express.md | 299 +++++++++ jsr.json | 14 +- package.json | 13 +- pnpm-lock.yaml | 598 ++++++++++++++++++ src/adapters/README.md | 13 +- src/adapters/express/index.ts | 14 + src/adapters/express/middleware.test.ts | 370 +++++++++++ src/adapters/express/middleware.ts | 132 ++++ src/adapters/express/require-auth.test.ts | 194 ++++++ src/adapters/express/require-auth.ts | 93 +++ src/adapters/express/to-fetch-request.test.ts | 168 +++++ src/adapters/express/to-fetch-request.ts | 64 ++ .../express/with-supabase-route.test.ts | 200 ++++++ src/adapters/express/with-supabase-route.ts | 78 +++ tsdown.config.ts | 3 +- 16 files changed, 2265 insertions(+), 31 deletions(-) create mode 100644 docs/adapters/express.md create mode 100644 src/adapters/express/index.ts create mode 100644 src/adapters/express/middleware.test.ts create mode 100644 src/adapters/express/middleware.ts create mode 100644 src/adapters/express/require-auth.test.ts create mode 100644 src/adapters/express/require-auth.ts create mode 100644 src/adapters/express/to-fetch-request.test.ts create mode 100644 src/adapters/express/to-fetch-request.ts create mode 100644 src/adapters/express/with-supabase-route.test.ts create mode 100644 src/adapters/express/with-supabase-route.ts diff --git a/README.md b/README.md index ddd27e8..6b417f8 100644 --- a/README.md +++ b/README.md @@ -266,11 +266,12 @@ Adapters wrap `withSupabase` for a specific framework's middleware contract. The > **Adapters are a community-driven initiative.** They're developed, maintained, and evolved by contributors — including responding to upstream framework changes. See [`src/adapters/README.md`](src/adapters/README.md) for the contribution requirements (tests, types, docs, build wiring) if you'd like to add or help maintain one. -| Framework | Import | Framework version | Docs | -| --------- | ---------------------------------- | ----------------- | -------------------------------------------------- | -| Hono | `@supabase/server/adapters/hono` | `^4.0.0` | [docs/adapters/hono.md](docs/adapters/hono.md) | -| H3 / Nuxt | `@supabase/server/adapters/h3` | `^2.0.0` | [docs/adapters/h3.md](docs/adapters/h3.md) | -| Elysia | `@supabase/server/adapters/elysia` | `^1.4.0` | [docs/adapters/elysia.md](docs/adapters/elysia.md) | +| Framework | Import | Framework version | Docs | +| --------- | ----------------------------------- | ----------------- | ---------------------------------------------------- | +| Hono | `@supabase/server/adapters/hono` | `^4.0.0` | [docs/adapters/hono.md](docs/adapters/hono.md) | +| H3 / Nuxt | `@supabase/server/adapters/h3` | `^2.0.0` | [docs/adapters/h3.md](docs/adapters/h3.md) | +| Elysia | `@supabase/server/adapters/elysia` | `^1.4.0` | [docs/adapters/elysia.md](docs/adapters/elysia.md) | +| Express | `@supabase/server/adapters/express` | `^5.0.0` | [docs/adapters/express.md](docs/adapters/express.md) | See the per-adapter docs above for setup, per-route auth, CORS, error handling, and other patterns. @@ -316,6 +317,20 @@ app.listen(3000) The adapter does not handle CORS — use `@elysiajs/cors` for that. +### Express + +```ts +import express from 'express' +import { withSupabase } from '@supabase/server/adapters/express' + +const app = express() +app.use(withSupabase({ auth: 'user' })) + +app.listen(3000) +``` + +See [docs/adapters/express.md](docs/adapters/express.md) for per-route auth (`requireAuth`, `withSupabaseRoute`), custom error handling, CORS, and more. + ## Primitives For when you need more control than `withSupabase` provides — multiple routes with different auth, custom response headers, or building your own wrapper. @@ -450,7 +465,7 @@ For other environments, pass overrides via the `env` config option or `resolveEn | **Deno / Bun** | Works out of the box via `export default { fetch }`. | | **Node.js** | Use a [framework adapter](#framework-adapters) or [core primitives](#primitives) with your framework of choice. | -Using a framework? See [Framework Adapters](#framework-adapters) for Hono, H3 / Nuxt, and Elysia, or [`docs/ssr-frameworks.md`](docs/ssr-frameworks.md) for Next.js / SvelteKit / Remix (compose with [`@supabase/ssr`](https://github.com/supabase/ssr)). +Using a framework? See [Framework Adapters](#framework-adapters) for Hono, H3 / Nuxt, Elysia, and Express, or [`docs/ssr-frameworks.md`](docs/ssr-frameworks.md) for Next.js / SvelteKit / Remix (compose with [`@supabase/ssr`](https://github.com/supabase/ssr)). ### Does this replace `@supabase/ssr`? @@ -458,13 +473,14 @@ 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/adapters/elysia` | `withSupabase` (Elysia plugin) | +| 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/adapters/elysia` | `withSupabase` (Elysia plugin) | +| `@supabase/server/adapters/express` | `withSupabase`, `requireAuth`, `withSupabaseRoute` (Express 5 middleware/route helpers) | ## Documentation @@ -476,6 +492,7 @@ No. `@supabase/ssr` handles cookie-based session management for frameworks like | 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 this with Elysia? | [`docs/adapters/elysia.md`](docs/adapters/elysia.md) | +| How do I use this with Express? | [`docs/adapters/express.md`](docs/adapters/express.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) | diff --git a/docs/adapters/express.md b/docs/adapters/express.md new file mode 100644 index 0000000..87a1f1c --- /dev/null +++ b/docs/adapters/express.md @@ -0,0 +1,299 @@ +# Express Adapter + +## Setup + +Install Express as a peer dependency: + +```bash +pnpm add express +pnpm add -D @types/express +``` + +The adapter exports an Express 5 `RequestHandler` instead of a fetch handler. The resolved `SupabaseContext` is stored on `res.locals.supabaseContext` (typed via declaration merging) so every downstream handler can read it. + +> **Express 5 only.** The adapter relies on Express 5's native async-error handling — an `async` middleware that returns a rejected promise propagates to your error pipeline without `express-async-errors` or any other wrapper. + +## Basic app with auth + +```ts +import express from 'express' +import { withSupabase } from '@supabase/server/adapters/express' + +const app = express() + +// Apply auth to all routes +app.use(withSupabase({ auth: 'user' })) + +app.get('/todos', async (_req, res) => { + const { supabase } = res.locals.supabaseContext + const { data } = await supabase.from('todos').select() + res.json(data) +}) + +app.get('/profile', async (_req, res) => { + const { supabase, userClaims } = res.locals.supabaseContext + const { data } = await supabase + .from('profiles') + .select() + .eq('id', userClaims!.id) + res.json(data) +}) + +app.listen(3000) +``` + +The context is stored in `res.locals.supabaseContext` and contains the same `SupabaseContext` fields as the main `withSupabase` wrapper: `supabase`, `supabaseAdmin`, `userClaims`, `jwtClaims`, `authMode`, and `authKeyName`. + +## Per-route auth + +Two composition patterns are supported. + +### Mount `withSupabase()` once, then guard with `requireAuth()` + +When most routes share a baseline auth set, mount the middleware once and use `requireAuth()` per route to narrow the allowed modes. This is the recommended ergonomic pattern. + +```ts +import express from 'express' +import { requireAuth, withSupabase } from '@supabase/server/adapters/express' + +const app = express() + +// App-wide: accept either a user JWT or a secret key +app.use(withSupabase({ auth: ['user', 'secret'] })) + +// User-only +app.get('/me', requireAuth('user'), async (_req, res) => { + const { userClaims } = res.locals.supabaseContext + res.json(userClaims) +}) + +// Service-only +app.post('/admin/sync', requireAuth('secret'), async (_req, res) => { + const { supabaseAdmin } = res.locals.supabaseContext + const { data } = await supabaseAdmin + .from('audit_log') + .insert({ action: 'sync' }) + res.json(data) +}) + +// Service-only, named key — only the "cron" secret key may call this +app.post('/admin/refresh', requireAuth('secret:cron'), async (_req, res) => { + const { supabaseAdmin } = res.locals.supabaseContext + await supabaseAdmin.rpc('refresh_popular') + res.json({ ok: true }) +}) + +app.listen(3000) +``` + +`requireAuth(modes?)` reads `res.locals.supabaseContext` set by an upstream `withSupabase()`. If the context is missing or the established `authMode` / `authKeyName` does not match, it forwards an `AuthError` via `next(err)` so your error middleware can render the 401. The `publishable:*` and `secret:*` wildcards accept any named key for that base mode. + +### Wrap a single route with `withSupabaseRoute()` + +When you do NOT want a global middleware — for example, a single authenticated route in an otherwise public app — wrap the route directly. The handler receives the resolved context as a fourth argument, so you don't need to read `res.locals`. + +```ts +import express from 'express' +import { withSupabaseRoute } from '@supabase/server/adapters/express' + +const app = express() + +// Public, no auth +app.get('/health', (_req, res) => { + res.json({ status: 'ok' }) +}) + +// Just this route is authenticated +app.get( + '/todos', + withSupabaseRoute({ auth: 'user' }, async (_req, res, _next, ctx) => { + const { data } = await ctx.supabase.from('todos').select() + res.json(data) + }), +) + +// Different mode on a different route — no global middleware needed +app.post( + '/admin/sync', + withSupabaseRoute({ auth: 'secret' }, async (_req, res, _next, ctx) => { + const { data } = await ctx.supabaseAdmin + .from('audit_log') + .insert({ action: 'sync' }) + res.json(data) + }), +) + +app.listen(3000) +``` + +`withSupabaseRoute()` also populates `res.locals.supabaseContext`, so chaining additional `requireAuth()` guards on the same route still works. + +## Skip behavior + +If a previous middleware already set `res.locals.supabaseContext`, subsequent `withSupabase` calls skip auth and call `next()` immediately. This lets a route-level middleware override an app-wide default: + +```ts +import express from 'express' +import { withSupabase } from '@supabase/server/adapters/express' + +const app = express() + +// App-wide default: user auth +app.use(withSupabase({ auth: 'user' })) + +// This route needs secret auth instead. +// The route-level middleware runs first, sets the context, +// and the app-wide middleware sees res.locals.supabaseContext +// is already populated and skips. +app.post('/webhook', withSupabase({ auth: 'secret' }), async (_req, res) => { + const { supabaseAdmin } = res.locals.supabaseContext + await supabaseAdmin.from('webhook_log').insert({}) + res.json({ ok: true }) +}) + +app.listen(3000) +``` + +`withSupabaseRoute()` is the terminal entry point for its own route and does NOT short-circuit on a pre-existing context — use it when you want the route's auth config to always run. + +## Error handling + +By default, an `AuthError` from `createSupabaseContext` is forwarded via `next(error)` so your existing Express error middleware handles it — the Express-idiomatic flow: + +```ts +import express, { type ErrorRequestHandler } from 'express' +import { AuthError } from '@supabase/server' +import { withSupabase } from '@supabase/server/adapters/express' + +const app = express() + +app.use(withSupabase({ auth: 'user' })) + +app.get('/todos', async (_req, res) => { + const { supabase } = res.locals.supabaseContext + const { data } = await supabase.from('todos').select() + res.json(data) +}) + +const errorHandler: ErrorRequestHandler = (err, _req, res, next) => { + if (err instanceof AuthError) { + res.status(err.status).json({ code: err.code, message: err.message }) + return + } + next(err) +} + +app.use(errorHandler) + +app.listen(3000) +``` + +The `AuthError` retains its `.status`, `.code`, and `.message` so you can map directly to an HTTP response without re-parsing. + +### Inline `onError` handler + +Pass an `onError` callback to handle auth failures next to the middleware itself. When provided, the adapter calls it instead of `next(error)` — your handler owns response/next semantics: + +```ts +app.use( + withSupabase({ + auth: 'user', + onError: (error, _req, res) => { + res + .status(error.status) + .json({ code: error.code, message: error.message }) + }, + }), +) +``` + +If your `onError` throws or returns a rejected promise, the thrown error is forwarded via `next(err)` so Express's error pipeline still triggers. The same `onError` option is also available on `withSupabaseRoute()`. + +## CORS + +The Express adapter does not handle CORS — the `cors` option is excluded from its config type. Use the [`cors`](https://www.npmjs.com/package/cors) npm package: + +```ts +import cors from 'cors' +import express from 'express' +import { withSupabase } from '@supabase/server/adapters/express' + +const app = express() + +app.use(cors()) +app.use(withSupabase({ auth: 'user' })) + +app.get('/todos', async (_req, res) => { + const { supabase } = res.locals.supabaseContext + const { data } = await supabase.from('todos').select() + res.json(data) +}) + +app.listen(3000) +``` + +## Request body forwarding + +The adapter forwards request bodies for non-`GET`/`HEAD` methods so `createSupabaseContext` can read whatever it needs from the request. It works in two scenarios: + +- **No body parser registered** — the raw `IncomingMessage` stream is forwarded directly. +- **A body parser (e.g., `express.json()`) ran** — the parsed `req.body` is re-serialized. + +You do not need to register `express.json()` just to make auth work; mount it only if your route handlers consume `req.body`. + +## Environment overrides + +Pass `env` to override auto-detected environment variables, same as the main wrapper: + +```ts +app.use( + withSupabase({ + auth: 'user', + env: { url: 'http://localhost:54321' }, + }), +) +``` + +## Supabase client options + +Forward options to the underlying `createClient()` calls: + +```ts +app.use( + withSupabase({ + auth: 'user', + supabaseOptions: { db: { schema: 'api' } }, + }), +) +``` + +## Trust proxy + +When deploying Express behind a reverse proxy (Vercel, Fly, Heroku, an nginx ingress, etc.), enable `trust proxy` so the adapter composes the correct absolute URL from `X-Forwarded-Proto` and the forwarded host. The adapter reads `req.protocol`, which already honors this setting: + +```ts +app.set('trust proxy', true) +app.use(withSupabase({ auth: 'user' })) +``` + +## TypeScript + +`res.locals.supabaseContext` is typed via Express's declaration-merged `Express.Locals` namespace, so it's available on every `Response` after the middleware has run: + +```ts +import type { Request, Response } from 'express' + +function handler(_req: Request, res: Response) { + const { supabase, authMode } = res.locals.supabaseContext // fully typed + // ... +} +``` + +If you need to extend the adapter's config (e.g., wrap it in your own factory), import the `WithSupabaseExpressConfig` and `ExpressAuthErrorHandler` types: + +```ts +import type { + ExpressAuthErrorHandler, + WithSupabaseExpressConfig, +} from '@supabase/server/adapters/express' +``` diff --git a/jsr.json b/jsr.json index 77dbe1e..6ea902b 100644 --- a/jsr.json +++ b/jsr.json @@ -6,17 +6,11 @@ "./core": "./src/core/index.ts", "./adapters/hono": "./src/adapters/hono/index.ts", "./adapters/h3": "./src/adapters/h3/index.ts", - "./adapters/elysia": "./src/adapters/elysia/index.ts" + "./adapters/elysia": "./src/adapters/elysia/index.ts", + "./adapters/express": "./src/adapters/express/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 8b44e34..f389cbd 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,11 @@ "import": "./dist/adapters/elysia/index.mjs", "require": "./dist/adapters/elysia/index.cjs" }, + "./adapters/express": { + "types": "./dist/adapters/express/index.d.mts", + "import": "./dist/adapters/express/index.mjs", + "require": "./dist/adapters/express/index.cjs" + }, "./package.json": "./package.json" }, "main": "./dist/index.cjs", @@ -65,7 +70,7 @@ "format": "prettier --write .", "lint": "eslint src", "lint:fix": "eslint src --fix", - "prepare": "simple-git-hooks", + "prepare": "tsdown && (simple-git-hooks 2>/dev/null || true)", "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc --noEmit" @@ -76,11 +81,15 @@ }, "peerDependencies": { "@supabase/supabase-js": "^2.0.0", + "express": "^5.0.0", "h3": "^2.0.0", "hono": "^4.0.0", "elysia": "^1.4.0" }, "peerDependenciesMeta": { + "express": { + "optional": true + }, "h3": { "optional": true }, @@ -95,8 +104,10 @@ "@commitlint/cli": "^20.4.2", "@commitlint/config-conventional": "^20.4.2", "@supabase/supabase-js": "^2.105.4", + "@types/express": "^5.0.0", "eslint": "^10.0.2", "elysia": "^1.4.0", + "express": "^5.0.0", "h3": "2.0.1-rc.20", "hono": "^4.12.5", "prettier": "3.8.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2a23a1..91f0f25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,12 +21,18 @@ importers: '@supabase/supabase-js': specifier: ^2.105.4 version: 2.105.4 + '@types/express': + specifier: ^5.0.0 + version: 5.0.6 elysia: specifier: ^1.4.0 version: 1.4.28(@sinclair/typebox@0.34.49)(exact-mirror@1.0.0)(file-type@22.0.1)(openapi-types@12.1.3)(typescript@5.9.3) eslint: specifier: ^10.0.2 version: 10.0.2(jiti@2.6.1) + express: + specifier: ^5.0.0 + version: 5.2.1 h3: specifier: 2.0.1-rc.20 version: 2.0.1-rc.20 @@ -681,9 +687,15 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -693,9 +705,18 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/express-serve-static-core@5.1.1': + resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} + + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/jsesc@2.5.1': resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} @@ -705,6 +726,18 @@ packages: '@types/node@25.3.0': resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==} + '@types/qs@6.15.1': + resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -796,6 +829,10 @@ packages: '@vitest/utils@4.0.18': resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -845,14 +882,30 @@ packages: birpc@4.0.0: resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + brace-expansion@5.0.5: resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} engines: {node: 18 || 20 || >=22} + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -875,6 +928,14 @@ packages: compare-func@2.0.0: resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + conventional-changelog-angular@8.1.0: resolution: {integrity: sha512-GGf2Nipn1RUCAktxuVauVr1e3r8QrLP/B0lEUsFktmGqc3ddbQkhoJZHJctVU829U1c6mTSWftrVOCHaL85Q3w==} engines: {node: '>=18'} @@ -888,6 +949,14 @@ packages: engines: {node: '>=18'} hasBin: true + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} @@ -932,6 +1001,10 @@ packages: defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dot-prop@5.3.0: resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} engines: {node: '>=8'} @@ -945,6 +1018,13 @@ packages: oxc-resolver: optional: true + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + elysia@1.4.28: resolution: {integrity: sha512-Vrx8sBnvq8squS/3yNBzR1jBXI+SgmnmvwawPjNuEHndUe5l1jV2Gp6JJ4ulDkEB8On6bWmmuyPpA+bq4t+WYg==} peerDependencies: @@ -967,6 +1047,10 @@ packages: resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} engines: {node: '>=14'} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -978,9 +1062,21 @@ packages: error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + esbuild@0.27.3: resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} @@ -990,6 +1086,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -1039,6 +1138,10 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + exact-mirror@1.0.0: resolution: {integrity: sha512-tB6QSwlyUDZh22vS4ytBjmTvpMJ7eNNqSUtH4w7TpQsE7//V+MsdWUhO0B1UptzStDFHQBCxfJPtDDiVaFfRyQ==} peerDependencies: @@ -1051,6 +1154,10 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + fast-decode-uri-component@1.0.1: resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} @@ -1083,6 +1190,10 @@ packages: resolution: {integrity: sha512-ww5Mhre0EE+jmBvOXTmXAbEMuZE7uX4a3+oRCQFNj8w++g3ev913N6tXQz0XTXbueQ5TWQfm6BdaViEHHn8bhA==} engines: {node: '>=22'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -1094,15 +1205,34 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + get-tsconfig@4.13.6: resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} @@ -1120,6 +1250,10 @@ packages: resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} engines: {node: '>=18'} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + h3@2.0.1-rc.20: resolution: {integrity: sha512-28ljodXuUp0fZovdiSRq4G9OgrxCztrJe5VdYzXAB7ueRvI7pIUqLU14Xi3XqdYJ/khXjfpUOOD2EQa6CmBgsg==} engines: {node: '>=20.11.1'} @@ -1130,6 +1264,14 @@ packages: crossws: optional: true + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + hono@4.12.14: resolution: {integrity: sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==} engines: {node: '>=16.9.0'} @@ -1137,10 +1279,18 @@ packages: hookable@6.0.1: resolution: {integrity: sha512-uKGyY8BuzN/a5gvzvA+3FVWo0+wUjgtfSdnmjtrOVwQCZPHpHDH2WRO3VZSOeluYrHoDCiXFffZXs8Dj1ULWtw==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + iceberg-js@0.8.1: resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==} engines: {node: '>=20.0.0'} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -1167,10 +1317,17 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ini@4.1.1: resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} @@ -1194,6 +1351,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1276,9 +1436,17 @@ packages: resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} hasBin: true + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + mdurl@2.0.0: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + memoirist@0.4.0: resolution: {integrity: sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg==} @@ -1290,6 +1458,18 @@ packages: resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} engines: {node: '>=18'} + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -1312,9 +1492,24 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + openapi-types@12.1.3: resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} @@ -1338,6 +1533,10 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1346,6 +1545,9 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1376,6 +1578,10 @@ packages: peerDependencies: prettier: ^3.0.0 + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + punycode.js@2.3.1: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} @@ -1384,9 +1590,21 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.15.1: + resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} + engines: {node: '>=0.6'} + quansync@1.0.0: resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -1438,11 +1656,29 @@ packages: rou3@0.8.1: resolution: {integrity: sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA==} + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + semver@7.7.4: resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1451,6 +1687,22 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -1474,6 +1726,10 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -1507,6 +1763,10 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + token-types@6.1.2: resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} engines: {node: '>=14.16'} @@ -1553,6 +1813,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + typedoc@0.28.19: resolution: {integrity: sha512-wKh+lhdmMFivMlc6vRRcMGXeGEHGU2g8a2CkPTJjJlwRf1iXbimWIPcFolCqe4E0d/FRtGszpIrsp3WLpDB8Pw==} engines: {node: '>= 18', pnpm: '>= 10'} @@ -1585,6 +1849,10 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + unrun@0.2.27: resolution: {integrity: sha512-Mmur1UJpIbfxasLOhPRvox/QS4xBiDii71hMP7smfRthGcwFL2OAmYRgduLANOAU4LUkvVamuP+02U+c90jlrw==} engines: {node: '>=20.19.0'} @@ -1598,6 +1866,10 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vite@7.3.2: resolution: {integrity: sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1690,6 +1962,9 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -2214,21 +2489,45 @@ snapshots: tslib: 2.8.1 optional: true + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 25.3.0 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/connect@3.4.38': + dependencies: + '@types/node': 25.3.0 + '@types/deep-eql@4.0.2': {} '@types/esrecurse@4.3.1': {} '@types/estree@1.0.8': {} + '@types/express-serve-static-core@5.1.1': + dependencies: + '@types/node': 25.3.0 + '@types/qs': 6.15.1 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.1 + '@types/serve-static': 2.2.0 + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 + '@types/http-errors@2.0.5': {} + '@types/jsesc@2.5.1': {} '@types/json-schema@7.0.15': {} @@ -2237,6 +2536,19 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/qs@6.15.1': {} + + '@types/range-parser@1.2.7': {} + + '@types/send@1.2.1': + dependencies: + '@types/node': 25.3.0 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 25.3.0 + '@types/unist@3.0.3': {} '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': @@ -2369,6 +2681,11 @@ snapshots: '@vitest/pretty-format': 4.0.18 tinyrainbow: 3.0.3 + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -2413,12 +2730,38 @@ snapshots: birpc@4.0.0: {} + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.1 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + brace-expansion@5.0.5: dependencies: balanced-match: 4.0.4 + bytes@3.1.2: {} + cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + callsites@3.1.0: {} chai@6.2.2: {} @@ -2440,6 +2783,10 @@ snapshots: array-ify: 1.0.0 dot-prop: 5.3.0 + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + conventional-changelog-angular@8.1.0: dependencies: compare-func: 2.0.0 @@ -2452,6 +2799,10 @@ snapshots: dependencies: meow: 13.2.0 + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + cookie@1.1.1: {} cosmiconfig-typescript-loader@6.2.0(@types/node@25.3.0)(cosmiconfig@9.0.0(typescript@5.9.3))(typescript@5.9.3): @@ -2486,12 +2837,22 @@ snapshots: defu@6.1.7: {} + depd@2.0.0: {} + dot-prop@5.3.0: dependencies: is-obj: 2.0.0 dts-resolver@2.1.3: {} + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + elysia@1.4.28(@sinclair/typebox@0.34.49)(exact-mirror@1.0.0)(file-type@22.0.1)(openapi-types@12.1.3)(typescript@5.9.3): dependencies: '@sinclair/typebox': 0.34.49 @@ -2508,6 +2869,8 @@ snapshots: empathic@2.0.0: {} + encodeurl@2.0.0: {} + entities@4.5.0: {} env-paths@2.2.1: {} @@ -2516,8 +2879,16 @@ snapshots: dependencies: is-arrayish: 0.2.1 + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + esbuild@0.27.3: optionalDependencies: '@esbuild/aix-ppc64': 0.27.3 @@ -2549,6 +2920,8 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} eslint-scope@9.1.1: @@ -2621,10 +2994,45 @@ snapshots: esutils@2.0.3: {} + etag@1.8.1: {} + exact-mirror@1.0.0: {} expect-type@1.3.0: {} + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.1 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + fast-decode-uri-component@1.0.1: {} fast-deep-equal@3.1.3: {} @@ -2652,6 +3060,17 @@ snapshots: transitivePeerDependencies: - supports-color + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -2664,11 +3083,35 @@ snapshots: flatted@3.4.2: {} + forwarded@0.2.0: {} + + fresh@2.0.0: {} + fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + get-caller-file@2.0.5: {} + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + get-tsconfig@4.13.6: dependencies: resolve-pkg-maps: 1.0.0 @@ -2687,17 +3130,37 @@ snapshots: dependencies: ini: 4.1.1 + gopd@1.2.0: {} + h3@2.0.1-rc.20: dependencies: rou3: 0.8.1 srvx: 0.11.15 + has-symbols@1.1.0: {} + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + hono@4.12.14: {} hookable@6.0.1: {} + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + iceberg-js@0.8.1: {} + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -2715,8 +3178,12 @@ snapshots: imurmurhash@0.1.4: {} + inherits@2.0.4: {} + ini@4.1.1: {} + ipaddr.js@1.9.1: {} + is-arrayish@0.2.1: {} is-extglob@2.1.1: {} @@ -2731,6 +3198,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-promise@4.0.0: {} + isexe@2.0.0: {} jiti@2.6.1: {} @@ -2801,14 +3270,26 @@ snapshots: punycode.js: 2.3.1 uc.micro: 2.1.0 + math-intrinsics@1.1.0: {} + mdurl@2.0.0: {} + media-typer@1.1.0: {} + memoirist@0.4.0: {} meow@12.1.1: {} meow@13.2.0: {} + merge-descriptors@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + minimatch@10.2.5: dependencies: brace-expansion: 5.0.5 @@ -2823,8 +3304,20 @@ snapshots: natural-compare@1.4.0: {} + negotiator@1.0.0: {} + + object-inspect@1.13.4: {} + obug@2.1.1: {} + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + openapi-types@12.1.3: {} optionator@0.9.4: @@ -2855,10 +3348,14 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parseurl@1.3.3: {} + path-exists@4.0.0: {} path-key@3.1.1: {} + path-to-regexp@8.4.2: {} + pathe@2.0.3: {} picocolors@1.1.1: {} @@ -2886,12 +3383,30 @@ snapshots: tinyexec: 0.3.2 tslib: 2.8.1 + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + punycode.js@2.3.1: {} punycode@2.3.1: {} + qs@6.15.1: + dependencies: + side-channel: 1.1.0 + quansync@1.0.0: {} + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -2971,14 +3486,81 @@ snapshots: rou3@0.8.1: {} + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + safer-buffer@2.1.2: {} + semver@7.7.4: {} + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 shebang-regex@3.0.0: {} + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} simple-git-hooks@2.13.1: {} @@ -2991,6 +3573,8 @@ snapshots: stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@3.10.0: {} string-width@4.2.3: @@ -3020,6 +3604,8 @@ snapshots: tinyrainbow@3.0.3: {} + toidentifier@1.0.1: {} + token-types@6.1.2: dependencies: '@borewit/text-codec': 0.2.2 @@ -3065,6 +3651,12 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + typedoc@0.28.19(typescript@5.9.3): dependencies: '@gerrit0/mini-shiki': 3.23.0 @@ -3098,6 +3690,8 @@ snapshots: undici-types@7.18.2: {} + unpipe@1.0.0: {} + unrun@0.2.27: dependencies: rolldown: 1.0.0-rc.3 @@ -3106,6 +3700,8 @@ snapshots: dependencies: punycode: 2.3.1 + vary@1.1.2: {} + vite@7.3.2(@types/node@25.3.0)(jiti@2.6.1)(yaml@2.8.3): dependencies: esbuild: 0.27.3 @@ -3174,6 +3770,8 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrappy@1.0.2: {} + y18n@5.0.8: {} yaml@2.8.3: {} diff --git a/src/adapters/README.md b/src/adapters/README.md index 4130c43..cc1fdc3 100644 --- a/src/adapters/README.md +++ b/src/adapters/README.md @@ -4,17 +4,18 @@ You're in the adapter source folder. Framework adapters wrap `withSupabase` and ## Available adapters -| Framework | Import | Framework version | Docs | -| --------- | ---------------------------------- | ----------------- | -------------------------------------------------------- | -| Hono | `@supabase/server/adapters/hono` | `^4.0.0` | [docs/adapters/hono.md](../../docs/adapters/hono.md) | -| H3 / Nuxt | `@supabase/server/adapters/h3` | `^2.0.0` | [docs/adapters/h3.md](../../docs/adapters/h3.md) | -| Elysia | `@supabase/server/adapters/elysia` | `^1.4.0` | [docs/adapters/elysia.md](../../docs/adapters/elysia.md) | +| Framework | Import | Framework version | Docs | +| --------- | ----------------------------------- | ----------------- | ---------------------------------------------------------- | +| Hono | `@supabase/server/adapters/hono` | `^4.0.0` | [docs/adapters/hono.md](../../docs/adapters/hono.md) | +| H3 / Nuxt | `@supabase/server/adapters/h3` | `^2.0.0` | [docs/adapters/h3.md](../../docs/adapters/h3.md) | +| Elysia | `@supabase/server/adapters/elysia` | `^1.4.0` | [docs/adapters/elysia.md](../../docs/adapters/elysia.md) | +| Express | `@supabase/server/adapters/express` | `^5.0.0` | [docs/adapters/express.md](../../docs/adapters/express.md) | The framework version reflects what the adapter is tested against. It must match the corresponding entry in [`package.json#peerDependencies`](../../package.json) — if you bump the peer-dep range, update this table too. ## Community-maintained -**Every adapter listed above is community-maintained.** Hono, H3, and Elysia all originated as community contributions. Adapters live in this repo and ship with the core package, so users get them with a single `npm install @supabase/server` — no separate package per framework. +**Every adapter listed above is community-maintained.** Hono, H3, Elysia, and Express all originated as community contributions. Adapters live in this repo and ship with the core package, so users get them with a single `npm install @supabase/server` — no separate package per framework. The Supabase team reviews PRs, runs security and regression triage, and ships releases. The original contributor of an adapter is the de-facto domain expert and is expected to be the first responder on framework-version bumps and bug reports for that adapter. diff --git a/src/adapters/express/index.ts b/src/adapters/express/index.ts new file mode 100644 index 0000000..c9ade33 --- /dev/null +++ b/src/adapters/express/index.ts @@ -0,0 +1,14 @@ +/** + * Express framework adapter for `@supabase/server`. + * + * @packageDocumentation + */ + +export { withSupabase } from './middleware.js' +export type { + ExpressAuthErrorHandler, + WithSupabaseExpressConfig, +} from './middleware.js' +export { requireAuth } from './require-auth.js' +export { withSupabaseRoute } from './with-supabase-route.js' +export type { SupabaseRouteHandler } from './with-supabase-route.js' diff --git a/src/adapters/express/middleware.test.ts b/src/adapters/express/middleware.test.ts new file mode 100644 index 0000000..fe6fcb0 --- /dev/null +++ b/src/adapters/express/middleware.test.ts @@ -0,0 +1,370 @@ +import type { AddressInfo } from 'node:net' + +import express, { type Express, type ErrorRequestHandler } from 'express' +import { exportJWK, generateKeyPair, SignJWT } from 'jose' +import { beforeAll, describe, expect, it } from 'vitest' + +import type { JsonWebKeySet, SupabaseEnv } from '../../types.js' +import { withSupabase } from './middleware.js' + +const reportAuthError: ErrorRequestHandler = (err, _req, res, next) => { + const e = err as { status?: number; code?: string; message: string } + if (!e || typeof e.status !== 'number') { + next(err) + return + } + res.status(e.status).json({ error: e.message, code: e.code }) +} + +const reportGenericError: ErrorRequestHandler = (err, _req, res, next) => { + const e = err as { message?: string } + if (!e) { + next(err) + return + } + res.status(500).json({ error: e.message ?? 'unknown' }) +} + +function makeEnv(overrides?: Partial): Partial { + return { + url: 'https://test.supabase.co', + publishableKeys: { default: 'sb_publishable_xyz' }, + secretKeys: { default: 'sb_secret_xyz' }, + jwks: null, + ...overrides, + } +} + +interface RunResult { + status: number + body: string +} + +async function withApp( + configure: (app: Express) => void, + request: (port: number) => Promise, +): Promise { + const app = express() + configure(app) + + const server = app.listen(0) + await new Promise((resolve) => server.once('listening', resolve)) + const port = (server.address() as AddressInfo).port + + try { + return await request(port) + } finally { + await new Promise((resolve) => server.close(() => resolve())) + } +} + +async function fetchJson(port: number, init?: RequestInit): Promise { + const res = await fetch(`http://127.0.0.1:${port}/`, init) + return { status: res.status, body: await res.text() } +} + +describe('express supabase middleware', () => { + describe('none mode', () => { + it('sets supabase context on successful auth', async () => { + const result = await withApp( + (app) => { + app.use(withSupabase({ auth: 'none', env: makeEnv() })) + app.get('/', (_req, res) => { + const ctx = res.locals.supabaseContext + res.json({ + authMode: ctx.authMode, + hasSupabase: !!ctx.supabase, + hasAdmin: !!ctx.supabaseAdmin, + }) + }) + }, + (port) => fetchJson(port), + ) + + expect(result.status).toBe(200) + const body = JSON.parse(result.body) as { + authMode: string + hasSupabase: boolean + hasAdmin: boolean + } + expect(body.authMode).toBe('none') + expect(body.hasSupabase).toBe(true) + expect(body.hasAdmin).toBe(true) + }) + }) + + describe('publishable mode', () => { + it('succeeds with valid publishable apikey header', async () => { + const result = await withApp( + (app) => { + app.use(withSupabase({ auth: 'publishable', env: makeEnv() })) + app.get('/', (_req, res) => { + const ctx = res.locals.supabaseContext + res.json({ authMode: ctx.authMode }) + }) + }, + (port) => + fetchJson(port, { headers: { apikey: 'sb_publishable_xyz' } }), + ) + + expect(result.status).toBe(200) + expect(JSON.parse(result.body)).toEqual({ authMode: 'publishable' }) + }) + + it('forwards AuthError to next() when apikey is missing', async () => { + const result = await withApp( + (app) => { + app.use(withSupabase({ auth: 'publishable', env: makeEnv() })) + app.get('/', (_req, res) => res.json({ ok: true })) + app.use(reportAuthError) + }, + (port) => fetchJson(port), + ) + + expect(result.status).toBe(401) + const body = JSON.parse(result.body) as { error: string; code: string } + expect(body.code).toBe('INVALID_CREDENTIALS') + }) + }) + + describe('secret mode', () => { + it('succeeds with valid secret apikey header', async () => { + const result = await withApp( + (app) => { + app.use(withSupabase({ auth: 'secret', env: makeEnv() })) + app.get('/', (_req, res) => { + const ctx = res.locals.supabaseContext + res.json({ authMode: ctx.authMode }) + }) + }, + (port) => fetchJson(port, { headers: { apikey: 'sb_secret_xyz' } }), + ) + + expect(result.status).toBe(200) + expect(JSON.parse(result.body)).toEqual({ authMode: 'secret' }) + }) + }) + + describe('user mode', () => { + let jwks: JsonWebKeySet + let validToken: string + + beforeAll(async () => { + const { privateKey, publicKey } = await generateKeyPair('RS256') + const publicJwk = await exportJWK(publicKey) + publicJwk.alg = 'RS256' + publicJwk.use = 'sig' + jwks = { keys: [publicJwk] } + + validToken = await new SignJWT({ + sub: 'user-123', + role: 'authenticated', + email: 'test@example.com', + }) + .setProtectedHeader({ alg: 'RS256' }) + .setIssuedAt() + .setExpirationTime('1h') + .sign(privateKey) + }) + + it('succeeds with a valid JWT', async () => { + const result = await withApp( + (app) => { + app.use(withSupabase({ auth: 'user', env: makeEnv({ jwks }) })) + app.get('/', (_req, res) => { + const ctx = res.locals.supabaseContext + res.json({ + authMode: ctx.authMode, + userId: ctx.userClaims?.id, + email: ctx.userClaims?.email, + }) + }) + }, + (port) => + fetchJson(port, { + headers: { Authorization: `Bearer ${validToken}` }, + }), + ) + + expect(result.status).toBe(200) + expect(JSON.parse(result.body)).toEqual({ + authMode: 'user', + userId: 'user-123', + email: 'test@example.com', + }) + }) + + it('forwards AuthError to next() on invalid JWT', async () => { + const result = await withApp( + (app) => { + app.use(withSupabase({ auth: 'user', env: makeEnv({ jwks }) })) + app.get('/', (_req, res) => res.json({ ok: true })) + app.use(reportAuthError) + }, + (port) => + fetchJson(port, { + headers: { Authorization: 'Bearer not.a.real.jwt' }, + }), + ) + + expect(result.status).toBe(401) + const body = JSON.parse(result.body) as { error: string; code: string } + expect(body.code).toBe('INVALID_CREDENTIALS') + }) + }) + + describe('array auth form', () => { + it('accepts a request that matches one of the listed modes', async () => { + const result = await withApp( + (app) => { + app.use( + withSupabase({ auth: ['user', 'publishable'], env: makeEnv() }), + ) + app.get('/', (_req, res) => { + const ctx = res.locals.supabaseContext + res.json({ authMode: ctx.authMode }) + }) + }, + (port) => + fetchJson(port, { headers: { apikey: 'sb_publishable_xyz' } }), + ) + + expect(result.status).toBe(200) + expect(JSON.parse(result.body)).toEqual({ authMode: 'publishable' }) + }) + }) + + describe('missing credentials', () => { + it('surfaces 401 via the error pipeline', async () => { + const result = await withApp( + (app) => { + app.use(withSupabase({ auth: 'user', env: makeEnv() })) + app.get('/', (_req, res) => res.json({ ok: true })) + app.use(reportAuthError) + }, + (port) => fetchJson(port), + ) + + expect(result.status).toBe(401) + const body = JSON.parse(result.body) as { error: string; code: string } + expect(body.code).toBe('INVALID_CREDENTIALS') + }) + }) + + describe('onError option', () => { + it('defaults to next(error) when onError is omitted', async () => { + const result = await withApp( + (app) => { + app.use(withSupabase({ auth: 'publishable', env: makeEnv() })) + app.get('/', (_req, res) => res.json({ ok: true })) + app.use(reportAuthError) + }, + (port) => fetchJson(port), + ) + + expect(result.status).toBe(401) + const body = JSON.parse(result.body) as { error: string; code: string } + expect(body.code).toBe('INVALID_CREDENTIALS') + }) + + it('invokes a custom onError that responds directly', async () => { + const result = await withApp( + (app) => { + app.use( + withSupabase({ + auth: 'publishable', + env: makeEnv(), + onError: (error, _req, res) => { + res.status(error.status).json({ + status: error.status, + code: error.code, + message: error.message, + }) + }, + }), + ) + app.get('/', (_req, res) => res.json({ ok: true })) + }, + (port) => fetchJson(port), + ) + + expect(result.status).toBe(401) + const body = JSON.parse(result.body) as { + status: number + code: string + message: string + } + expect(body.status).toBe(401) + expect(body.code).toBe('INVALID_CREDENTIALS') + expect(typeof body.message).toBe('string') + }) + + it('forwards a thrown error from onError via next(err)', async () => { + const result = await withApp( + (app) => { + app.use( + withSupabase({ + auth: 'publishable', + env: makeEnv(), + onError: () => { + throw new Error('handler boom') + }, + }), + ) + app.get('/', (_req, res) => res.json({ ok: true })) + app.use(reportGenericError) + }, + (port) => fetchJson(port), + ) + + expect(result.status).toBe(500) + const body = JSON.parse(result.body) as { error: string } + expect(body.error).toBe('handler boom') + }) + + it('forwards a rejected promise from async onError via next(err)', async () => { + const result = await withApp( + (app) => { + app.use( + withSupabase({ + auth: 'publishable', + env: makeEnv(), + onError: async () => { + await Promise.resolve() + throw new Error('async handler boom') + }, + }), + ) + app.get('/', (_req, res) => res.json({ ok: true })) + app.use(reportGenericError) + }, + (port) => fetchJson(port), + ) + + expect(result.status).toBe(500) + const body = JSON.parse(result.body) as { error: string } + expect(body.error).toBe('async handler boom') + }) + }) + + describe('short-circuit', () => { + it('skips auth when res.locals.supabaseContext is already set', async () => { + const result = await withApp( + (app) => { + app.use(withSupabase({ auth: 'none', env: makeEnv() })) + // Second middleware would require 'secret' — but should skip. + app.use(withSupabase({ auth: 'secret', env: makeEnv() })) + app.get('/', (_req, res) => { + const ctx = res.locals.supabaseContext + res.json({ authMode: ctx.authMode }) + }) + }, + // No apikey header — would fail 'secret' if it ran. + (port) => fetchJson(port), + ) + + expect(result.status).toBe(200) + expect(JSON.parse(result.body)).toEqual({ authMode: 'none' }) + }) + }) +}) diff --git a/src/adapters/express/middleware.ts b/src/adapters/express/middleware.ts new file mode 100644 index 0000000..33d13a6 --- /dev/null +++ b/src/adapters/express/middleware.ts @@ -0,0 +1,132 @@ +import type { NextFunction, Request, RequestHandler, Response } from 'express' + +import { createSupabaseContext } from '../../create-supabase-context.js' +import type { AuthError } from '../../errors.js' +import type { SupabaseContext, WithSupabaseConfig } from '../../types.js' +import { toFetchRequest } from './to-fetch-request.js' + +/** + * Handler invoked when {@link withSupabase} fails to authenticate a request. + * + * Receives the standard Express tuple plus the {@link AuthError} produced by + * `createSupabaseContext`. The handler owns response/next semantics: when + * provided, the adapter will NOT call `next()` itself. If the handler throws + * or returns a rejected promise, the thrown error is forwarded via `next(err)` + * so Express's error pipeline still triggers. + */ +export type ExpressAuthErrorHandler = ( + error: AuthError, + req: Request, + res: Response, + next: NextFunction, +) => void | Promise + +/** + * Configuration for the Express adapter's {@link withSupabase} middleware. + * + * Mirrors {@link WithSupabaseConfig} but omits `cors` — Express applications + * should use the `cors` npm package directly — and adds an Express-specific + * {@link ExpressAuthErrorHandler | onError} hook. + */ +export interface WithSupabaseExpressConfig extends Omit< + WithSupabaseConfig, + 'cors' +> { + /** + * Custom handler for authentication failures. + * + * When omitted (default), the adapter calls `next(error)` so the + * application's error middleware can handle the {@link AuthError} — the + * Express-idiomatic flow. + * + * When provided, the adapter invokes the handler instead and does NOT call + * `next()`. The handler owns response/next semantics (e.g., `res.status(401).json(...)`). + * + * If the handler throws or rejects, the thrown error is forwarded via + * `next(err)` so Express's error pipeline still triggers. + */ + onError?: ExpressAuthErrorHandler +} + +/** + * Express 5 middleware that creates a {@link SupabaseContext} and stores it on + * `res.locals.supabaseContext`. + * + * Skips if a previous middleware already set the context, enabling route-level + * overrides. On authentication failure the configured + * {@link WithSupabaseExpressConfig.onError | onError} handler runs; if none is + * configured, the `AuthError` is forwarded via `next(error)` so the + * application's error middleware can handle it. + * + * @param config - Auth modes, optional environment overrides, and an optional `onError` handler. CORS is excluded — use the `cors` npm package. + * @returns An Express {@link RequestHandler}. + * + * @example App-wide auth + * ```ts + * import express from 'express' + * import { withSupabase } from '@supabase/server/adapters/express' + * + * const app = express() + * app.use(withSupabase({ auth: 'user' })) + * + * app.get('/profile', async (_req, res) => { + * const { supabase } = res.locals.supabaseContext + * const { data } = await supabase.rpc('get_profile') + * res.json(data) + * }) + * ``` + * + * @example Custom error handler + * ```ts + * app.use( + * withSupabase({ + * auth: 'user', + * onError: (error, _req, res) => { + * res.status(error.status).json({ code: error.code, message: error.message }) + * }, + * }), + * ) + * ``` + */ +export function withSupabase( + config?: WithSupabaseExpressConfig, +): RequestHandler { + const onError = config?.onError + return async (req, res, next) => { + if (res.locals.supabaseContext) { + next() + return + } + + const request = toFetchRequest(req) + const { data: ctx, error } = await createSupabaseContext(request, config) + if (error) { + if (onError) { + try { + await onError(error, req, res, next) + } catch (handlerError) { + next(handlerError) + } + return + } + next(error) + return + } + + res.locals.supabaseContext = ctx + next() + } +} + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Express { + interface Locals { + /** + * Supabase context populated by {@link withSupabase}. Available on every + * downstream handler once the middleware has run successfully. + */ + supabaseContext: SupabaseContext + } + } +} diff --git a/src/adapters/express/require-auth.test.ts b/src/adapters/express/require-auth.test.ts new file mode 100644 index 0000000..deede7d --- /dev/null +++ b/src/adapters/express/require-auth.test.ts @@ -0,0 +1,194 @@ +import type { AddressInfo } from 'node:net' + +import express, { type Express, type ErrorRequestHandler } from 'express' +import { describe, expect, it } from 'vitest' + +import type { SupabaseEnv } from '../../types.js' +import { withSupabase } from './middleware.js' +import { requireAuth } from './require-auth.js' + +const reportAuthError: ErrorRequestHandler = (err, _req, res, next) => { + const e = err as { status?: number; code?: string; message: string } + if (!e || typeof e.status !== 'number') { + next(err) + return + } + res.status(e.status).json({ error: e.message, code: e.code }) +} + +function makeEnv(overrides?: Partial): Partial { + return { + url: 'https://test.supabase.co', + publishableKeys: { + default: 'sb_publishable_xyz', + web: 'sb_publishable_web', + }, + secretKeys: { default: 'sb_secret_xyz' }, + jwks: null, + ...overrides, + } +} + +interface RunResult { + status: number + body: string +} + +async function withApp( + configure: (app: Express) => void, + request: (port: number) => Promise, +): Promise { + const app = express() + configure(app) + + const server = app.listen(0) + await new Promise((resolve) => server.once('listening', resolve)) + const port = (server.address() as AddressInfo).port + + try { + return await request(port) + } finally { + await new Promise((resolve) => server.close(() => resolve())) + } +} + +async function fetchJson(port: number, init?: RequestInit): Promise { + const res = await fetch(`http://127.0.0.1:${port}/`, init) + return { status: res.status, body: await res.text() } +} + +describe('requireAuth', () => { + it('passes through when context is set and no modes are specified', async () => { + const result = await withApp( + (app) => { + app.use(withSupabase({ auth: 'none', env: makeEnv() })) + app.get('/', requireAuth(), (_req, res) => { + res.json({ ok: true, authMode: res.locals.supabaseContext.authMode }) + }) + }, + (port) => fetchJson(port), + ) + + expect(result.status).toBe(200) + expect(JSON.parse(result.body)).toEqual({ ok: true, authMode: 'none' }) + }) + + it('passes through when the established mode matches the single allowed mode', async () => { + const result = await withApp( + (app) => { + app.use(withSupabase({ auth: 'publishable', env: makeEnv() })) + app.get('/', requireAuth('publishable'), (_req, res) => { + res.json({ authMode: res.locals.supabaseContext.authMode }) + }) + }, + (port) => fetchJson(port, { headers: { apikey: 'sb_publishable_xyz' } }), + ) + + expect(result.status).toBe(200) + expect(JSON.parse(result.body)).toEqual({ authMode: 'publishable' }) + }) + + it('passes through when the established mode matches one of an array of modes', async () => { + const result = await withApp( + (app) => { + app.use(withSupabase({ auth: ['user', 'publishable'], env: makeEnv() })) + app.get('/', requireAuth(['user', 'publishable']), (_req, res) => { + res.json({ authMode: res.locals.supabaseContext.authMode }) + }) + }, + (port) => fetchJson(port, { headers: { apikey: 'sb_publishable_xyz' } }), + ) + + expect(result.status).toBe(200) + expect(JSON.parse(result.body)).toEqual({ authMode: 'publishable' }) + }) + + it('passes through with publishable: when the established keyName matches', async () => { + const result = await withApp( + (app) => { + app.use(withSupabase({ auth: 'publishable:web', env: makeEnv() })) + app.get('/', requireAuth('publishable:web'), (_req, res) => { + res.json({ + authMode: res.locals.supabaseContext.authMode, + authKeyName: res.locals.supabaseContext.authKeyName, + }) + }) + }, + (port) => fetchJson(port, { headers: { apikey: 'sb_publishable_web' } }), + ) + + expect(result.status).toBe(200) + expect(JSON.parse(result.body)).toEqual({ + authMode: 'publishable', + authKeyName: 'web', + }) + }) + + it('passes through with the publishable:* wildcard', async () => { + const result = await withApp( + (app) => { + app.use(withSupabase({ auth: 'publishable:*', env: makeEnv() })) + app.get('/', requireAuth('publishable:*'), (_req, res) => { + res.json({ + authMode: res.locals.supabaseContext.authMode, + authKeyName: res.locals.supabaseContext.authKeyName, + }) + }) + }, + (port) => fetchJson(port, { headers: { apikey: 'sb_publishable_web' } }), + ) + + expect(result.status).toBe(200) + expect(JSON.parse(result.body)).toEqual({ + authMode: 'publishable', + authKeyName: 'web', + }) + }) + + it('fails with 401 when the context is missing (withSupabase was not mounted)', async () => { + const result = await withApp( + (app) => { + app.get('/', requireAuth(), (_req, res) => res.json({ ok: true })) + app.use(reportAuthError) + }, + (port) => fetchJson(port), + ) + + expect(result.status).toBe(401) + const body = JSON.parse(result.body) as { error: string; code: string } + expect(body.code).toBe('INVALID_CREDENTIALS') + }) + + it('fails with 401 when the established mode does not match any of the allowed modes', async () => { + const result = await withApp( + (app) => { + app.use(withSupabase({ auth: 'none', env: makeEnv() })) + app.get('/', requireAuth('user'), (_req, res) => res.json({ ok: true })) + app.use(reportAuthError) + }, + (port) => fetchJson(port), + ) + + expect(result.status).toBe(401) + const body = JSON.parse(result.body) as { error: string; code: string } + expect(body.code).toBe('INVALID_CREDENTIALS') + }) + + it('fails with 401 when the established keyName does not match the requested one', async () => { + const result = await withApp( + (app) => { + app.use(withSupabase({ auth: 'publishable:*', env: makeEnv() })) + // Authenticated with the "default" key, but the route requires "web". + app.get('/', requireAuth('publishable:web'), (_req, res) => + res.json({ ok: true }), + ) + app.use(reportAuthError) + }, + (port) => fetchJson(port, { headers: { apikey: 'sb_publishable_xyz' } }), + ) + + expect(result.status).toBe(401) + const body = JSON.parse(result.body) as { error: string; code: string } + expect(body.code).toBe('INVALID_CREDENTIALS') + }) +}) diff --git a/src/adapters/express/require-auth.ts b/src/adapters/express/require-auth.ts new file mode 100644 index 0000000..5abbbbb --- /dev/null +++ b/src/adapters/express/require-auth.ts @@ -0,0 +1,93 @@ +import type { RequestHandler } from 'express' + +import { AuthError, InvalidCredentialsError } from '../../errors.js' +import type { AuthMode, AuthModeWithKey, SupabaseContext } from '../../types.js' + +interface ParsedMode { + base: AuthMode + keyName: string | null +} + +function parseMode(mode: AuthModeWithKey): ParsedMode { + if ( + mode === 'none' || + mode === 'publishable' || + mode === 'secret' || + mode === 'user' + ) { + return { base: mode, keyName: null } + } + const colonIndex = mode.indexOf(':') + const base = mode.slice(0, colonIndex) as AuthMode + const keyName = mode.slice(colonIndex + 1) + return { base, keyName: keyName || null } +} + +function matchesMode(ctx: SupabaseContext, allowed: AuthModeWithKey): boolean { + const { base, keyName } = parseMode(allowed) + if (ctx.authMode !== base) return false + if (keyName === null || keyName === '*') return true + return ctx.authKeyName === keyName +} + +/** + * Per-route guard that ensures the request has been authenticated by an + * upstream {@link withSupabase} middleware. + * + * Forwards an {@link AuthError} via `next(err)` when: + * - `res.locals.supabaseContext` is absent (mount `withSupabase()` first), or + * - `modes` is provided and the established `authMode` (and `authKeyName`, for + * the `publishable:` / `secret:` forms) does not match any of + * the allowed entries. + * + * The `publishable:*` and `secret:*` wildcards accept any named key for that + * base mode. A bare `'publishable'` or `'secret'` accepts any key as well — the + * named-key constraint was already enforced upstream by `withSupabase()`. + * + * @param modes - Optional allowed auth mode(s). When omitted, any established + * context passes through. + * + * @example + * ```ts + * app.use(withSupabase({ auth: ['user', 'secret'] })) + * app.get('/me', requireAuth('user'), (_req, res) => { + * const { userClaims } = res.locals.supabaseContext + * res.json(userClaims) + * }) + * ``` + */ +export function requireAuth( + modes?: AuthModeWithKey | AuthModeWithKey[], +): RequestHandler { + const allowed = + modes === undefined ? null : Array.isArray(modes) ? modes : [modes] + + return (_req, res, next) => { + const ctx = res.locals.supabaseContext + if (!ctx) { + next( + new AuthError( + 'Supabase context is missing — mount withSupabase() before requireAuth().', + InvalidCredentialsError, + 401, + ), + ) + return + } + if (allowed === null) { + next() + return + } + if (allowed.some((mode) => matchesMode(ctx, mode))) { + next() + return + } + next( + new AuthError( + `Auth mode "${ctx.authMode}" is not allowed for this route.`, + InvalidCredentialsError, + 401, + ), + ) + } +} diff --git a/src/adapters/express/to-fetch-request.test.ts b/src/adapters/express/to-fetch-request.test.ts new file mode 100644 index 0000000..c753f21 --- /dev/null +++ b/src/adapters/express/to-fetch-request.test.ts @@ -0,0 +1,168 @@ +import type { AddressInfo } from 'node:net' + +import express, { type Express } from 'express' +import { describe, expect, it } from 'vitest' + +import { toFetchRequest } from './to-fetch-request.js' + +interface Capture { + url: string + method: string + headers: Headers + bodyText: string +} + +async function withApp( + configure: (app: Express) => void, + run: (port: number) => Promise, +): Promise { + const app = express() + configure(app) + + let capture: Capture | undefined + let captureError: unknown + + app.use(async (req, res) => { + try { + const fetchReq = toFetchRequest(req) + const bodyText = + fetchReq.method === 'GET' || fetchReq.method === 'HEAD' + ? '' + : await fetchReq.text() + capture = { + url: fetchReq.url, + method: fetchReq.method, + headers: fetchReq.headers, + bodyText, + } + res.status(200).end() + } catch (err) { + captureError = err + res.status(500).end() + } + }) + + const server = app.listen(0) + await new Promise((resolve) => server.once('listening', resolve)) + const port = (server.address() as AddressInfo).port + + try { + await run(port) + } finally { + await new Promise((resolve) => server.close(() => resolve())) + } + + if (captureError) throw captureError + if (!capture) throw new Error('Request was not captured') + return capture +} + +describe('toFetchRequest', () => { + it('preserves headers case-insensitively', async () => { + const cap = await withApp( + () => undefined, + async (port) => { + await fetch(`http://127.0.0.1:${port}/`, { + headers: { + 'X-Custom-Header': 'value', + 'X-Another-Header': 'foo', + }, + }) + }, + ) + expect(cap.headers.get('x-custom-header')).toBe('value') + expect(cap.headers.get('X-CUSTOM-HEADER')).toBe('value') + expect(cap.headers.get('x-another-header')).toBe('foo') + }) + + it('forwards Authorization and apikey headers', async () => { + const cap = await withApp( + () => undefined, + async (port) => { + await fetch(`http://127.0.0.1:${port}/`, { + headers: { + Authorization: 'Bearer token123', + apikey: 'sb_publishable_xyz', + }, + }) + }, + ) + expect(cap.headers.get('authorization')).toBe('Bearer token123') + expect(cap.headers.get('apikey')).toBe('sb_publishable_xyz') + }) + + it('preserves multi-value Cookie header', async () => { + const cap = await withApp( + () => undefined, + async (port) => { + await fetch(`http://127.0.0.1:${port}/`, { + headers: { + Cookie: 'session=abc; theme=dark; lang=en', + }, + }) + }, + ) + const cookie = cap.headers.get('cookie') ?? '' + expect(cookie).toContain('session=abc') + expect(cookie).toContain('theme=dark') + expect(cookie).toContain('lang=en') + }) + + it('composes absolute URL behind X-Forwarded-Proto when trust proxy is enabled', async () => { + const cap = await withApp( + (app) => app.set('trust proxy', true), + async (port) => { + await fetch(`http://127.0.0.1:${port}/path/here?q=1`, { + headers: { 'X-Forwarded-Proto': 'https' }, + }) + }, + ) + expect(cap.url.startsWith('https://')).toBe(true) + expect(cap.url).toContain('/path/here?q=1') + }) + + it('uses http scheme when trust proxy is disabled', async () => { + const cap = await withApp( + () => undefined, + async (port) => { + await fetch(`http://127.0.0.1:${port}/`, { + headers: { 'X-Forwarded-Proto': 'https' }, + }) + }, + ) + expect(cap.url.startsWith('http://')).toBe(true) + expect(cap.url.startsWith('https://')).toBe(false) + }) + + it('forwards POST body when no parser middleware is registered', async () => { + const cap = await withApp( + () => undefined, + async (port) => { + await fetch(`http://127.0.0.1:${port}/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hello: 'world' }), + }) + }, + ) + expect(cap.method).toBe('POST') + expect(cap.bodyText).toBe('{"hello":"world"}') + }) + + it('re-serializes a parsed JSON body when express.json() is registered', async () => { + const cap = await withApp( + (app) => { + app.use(express.json()) + }, + async (port) => { + await fetch(`http://127.0.0.1:${port}/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ a: 1, b: 'two' }), + }) + }, + ) + expect(cap.method).toBe('POST') + expect(JSON.parse(cap.bodyText)).toEqual({ a: 1, b: 'two' }) + }) +}) diff --git a/src/adapters/express/to-fetch-request.ts b/src/adapters/express/to-fetch-request.ts new file mode 100644 index 0000000..14854bf --- /dev/null +++ b/src/adapters/express/to-fetch-request.ts @@ -0,0 +1,64 @@ +import { Readable } from 'node:stream' + +import type { Request as ExpressRequest } from 'express' + +type RequestInitWithDuplex = RequestInit & { duplex?: 'half' } + +/** + * Translate an Express {@link ExpressRequest} into a Fetch {@link Request}. + * + * Internal helper consumed by the Express adapter middleware. Not exported + * from the public adapter barrel. + * + * - URL is built from `req.protocol` + `req.get('host')` + `req.originalUrl` + * (falls back to `req.url`). `req.protocol` already respects `trust proxy` + * and `X-Forwarded-Proto` when the app opts in. + * - All headers are copied from `req.rawHeaders` so repeated values + * (e.g., multiple `Cookie` headers) are preserved. + * - For non-`GET`/`HEAD` methods, the body is forwarded. If a parser + * middleware populated `req.body`, the parsed value is re-serialized; + * otherwise the raw {@link Readable} stream is streamed through. + */ +export function toFetchRequest(req: ExpressRequest): Request { + const host = req.get('host') ?? 'localhost' + const protocol = req.protocol || 'http' + const path = req.originalUrl || req.url || '/' + const url = `${protocol}://${host}${path}` + + const headers = new Headers() + const raw = req.rawHeaders + for (let i = 0; i < raw.length; i += 2) { + const name = raw[i] + const value = raw[i + 1] + if (name !== undefined && value !== undefined) { + headers.append(name, value) + } + } + + const method = (req.method ?? 'GET').toUpperCase() + + if (method === 'GET' || method === 'HEAD') { + return new Request(url, { method, headers }) + } + + const init: RequestInitWithDuplex = { method, headers } + const parsed: unknown = (req as ExpressRequest & { body?: unknown }).body + + if (parsed === undefined || parsed === null) { + init.body = Readable.toWeb(req) as ReadableStream + init.duplex = 'half' + } else if (typeof parsed === 'string') { + init.body = parsed + } else if (parsed instanceof Uint8Array) { + init.body = parsed as BodyInit + } else if (parsed instanceof ArrayBuffer) { + init.body = parsed + } else { + init.body = JSON.stringify(parsed) + if (!headers.has('content-type')) { + headers.set('content-type', 'application/json') + } + } + + return new Request(url, init) +} diff --git a/src/adapters/express/with-supabase-route.test.ts b/src/adapters/express/with-supabase-route.test.ts new file mode 100644 index 0000000..0d88605 --- /dev/null +++ b/src/adapters/express/with-supabase-route.test.ts @@ -0,0 +1,200 @@ +import type { AddressInfo } from 'node:net' + +import express, { type Express, type ErrorRequestHandler } from 'express' +import { describe, expect, it } from 'vitest' + +import type { SupabaseEnv } from '../../types.js' +import { withSupabaseRoute } from './with-supabase-route.js' + +const reportAuthError: ErrorRequestHandler = (err, _req, res, next) => { + const e = err as { status?: number; code?: string; message: string } + if (!e || typeof e.status !== 'number') { + next(err) + return + } + res.status(e.status).json({ error: e.message, code: e.code }) +} + +const reportGenericError: ErrorRequestHandler = (err, _req, res, next) => { + const e = err as { message?: string } + if (!e) { + next(err) + return + } + res.status(500).json({ error: e.message ?? 'unknown' }) +} + +function makeEnv(overrides?: Partial): Partial { + return { + url: 'https://test.supabase.co', + publishableKeys: { default: 'sb_publishable_xyz' }, + secretKeys: { default: 'sb_secret_xyz' }, + jwks: null, + ...overrides, + } +} + +interface RunResult { + status: number + body: string +} + +async function withApp( + configure: (app: Express) => void, + request: (port: number) => Promise, +): Promise { + const app = express() + configure(app) + + const server = app.listen(0) + await new Promise((resolve) => server.once('listening', resolve)) + const port = (server.address() as AddressInfo).port + + try { + return await request(port) + } finally { + await new Promise((resolve) => server.close(() => resolve())) + } +} + +async function fetchJson(port: number, init?: RequestInit): Promise { + const res = await fetch(`http://127.0.0.1:${port}/`, init) + return { status: res.status, body: await res.text() } +} + +describe('withSupabaseRoute', () => { + it('invokes the handler with the resolved context on success', async () => { + const result = await withApp( + (app) => { + app.get( + '/', + withSupabaseRoute( + { auth: 'publishable', env: makeEnv() }, + (_req, res, _next, ctx) => { + res.json({ + authMode: ctx.authMode, + hasSupabase: !!ctx.supabase, + fromLocals: res.locals.supabaseContext.authMode, + }) + }, + ), + ) + }, + (port) => fetchJson(port, { headers: { apikey: 'sb_publishable_xyz' } }), + ) + + expect(result.status).toBe(200) + expect(JSON.parse(result.body)).toEqual({ + authMode: 'publishable', + hasSupabase: true, + fromLocals: 'publishable', + }) + }) + + it('skips the handler and forwards AuthError via next() on auth failure', async () => { + let handlerCalls = 0 + const result = await withApp( + (app) => { + app.get( + '/', + withSupabaseRoute( + { auth: 'publishable', env: makeEnv() }, + (_req, res) => { + handlerCalls += 1 + res.json({ ok: true }) + }, + ), + ) + app.use(reportAuthError) + }, + (port) => fetchJson(port), + ) + + expect(result.status).toBe(401) + const body = JSON.parse(result.body) as { error: string; code: string } + expect(body.code).toBe('INVALID_CREDENTIALS') + expect(handlerCalls).toBe(0) + }) + + it('invokes a configured onError on auth failure instead of next(error)', async () => { + let handlerCalls = 0 + const result = await withApp( + (app) => { + app.get( + '/', + withSupabaseRoute( + { + auth: 'publishable', + env: makeEnv(), + onError: (error, _req, res) => { + res.status(error.status).json({ + status: error.status, + code: error.code, + }) + }, + }, + (_req, res) => { + handlerCalls += 1 + res.json({ ok: true }) + }, + ), + ) + }, + (port) => fetchJson(port), + ) + + expect(result.status).toBe(401) + const body = JSON.parse(result.body) as { status: number; code: string } + expect(body.status).toBe(401) + expect(body.code).toBe('INVALID_CREDENTIALS') + expect(handlerCalls).toBe(0) + }) + + it('forwards an async-thrown error from the handler via Express 5 native handling', async () => { + const result = await withApp( + (app) => { + app.get( + '/', + withSupabaseRoute({ auth: 'none', env: makeEnv() }, async () => { + await Promise.resolve() + throw new Error('handler boom') + }), + ) + app.use(reportGenericError) + }, + (port) => fetchJson(port), + ) + + expect(result.status).toBe(500) + const body = JSON.parse(result.body) as { error: string } + expect(body.error).toBe('handler boom') + }) + + it('forwards a thrown error from a custom onError via next(err)', async () => { + const result = await withApp( + (app) => { + app.get( + '/', + withSupabaseRoute( + { + auth: 'publishable', + env: makeEnv(), + onError: () => { + throw new Error('handler boom') + }, + }, + (_req, res) => { + res.json({ ok: true }) + }, + ), + ) + app.use(reportGenericError) + }, + (port) => fetchJson(port), + ) + + expect(result.status).toBe(500) + const body = JSON.parse(result.body) as { error: string } + expect(body.error).toBe('handler boom') + }) +}) diff --git a/src/adapters/express/with-supabase-route.ts b/src/adapters/express/with-supabase-route.ts new file mode 100644 index 0000000..8ea7aad --- /dev/null +++ b/src/adapters/express/with-supabase-route.ts @@ -0,0 +1,78 @@ +import type { NextFunction, Request, RequestHandler, Response } from 'express' + +import { createSupabaseContext } from '../../create-supabase-context.js' +import type { SupabaseContext } from '../../types.js' +import type { WithSupabaseExpressConfig } from './middleware.js' +import { toFetchRequest } from './to-fetch-request.js' + +/** + * Route handler invoked by {@link withSupabaseRoute} after authentication + * succeeds. Receives the resolved {@link SupabaseContext} as a fourth argument + * so the user does not need to read `res.locals`. + */ +export type SupabaseRouteHandler = ( + req: Request, + res: Response, + next: NextFunction, + ctx: SupabaseContext, +) => void | Promise + +/** + * Per-route wrapper that establishes a {@link SupabaseContext} for a single + * Express route handler — an alternative to mounting {@link withSupabase} as + * application-wide middleware. + * + * On success, `handler` is invoked with the standard Express tuple plus the + * resolved context. On authentication failure, the configured + * {@link WithSupabaseExpressConfig.onError | onError} handler runs; if none is + * configured, the `AuthError` is forwarded via `next(error)` and `handler` is + * NOT invoked. + * + * Async errors thrown by `handler` propagate via Express 5's native async + * error handling. + * + * @param config - Auth modes, optional environment overrides, and an optional `onError` handler. CORS is excluded — use the `cors` npm package. + * @param handler - Route handler invoked after auth succeeds. + * @returns An Express {@link RequestHandler} suitable for `app.get('/path', ...)`. + * + * @example + * ```ts + * import express from 'express' + * import { withSupabaseRoute } from '@supabase/server/adapters/express' + * + * const app = express() + * + * app.get( + * '/profile', + * withSupabaseRoute({ auth: 'user' }, async (_req, res, _next, ctx) => { + * const { data } = await ctx.supabase.rpc('get_profile') + * res.json(data) + * }), + * ) + * ``` + */ +export function withSupabaseRoute( + config: WithSupabaseExpressConfig | undefined, + handler: SupabaseRouteHandler, +): RequestHandler { + const onError = config?.onError + return async (req, res, next) => { + const request = toFetchRequest(req) + const { data: ctx, error } = await createSupabaseContext(request, config) + if (error) { + if (onError) { + try { + await onError(error, req, res, next) + } catch (handlerError) { + next(handlerError) + } + return + } + next(error) + return + } + + res.locals.supabaseContext = ctx + await handler(req, res, next, ctx) + } +} diff --git a/tsdown.config.ts b/tsdown.config.ts index 029b6fe..cd32e2a 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -7,8 +7,9 @@ export default defineConfig({ 'src/adapters/hono/index.ts', 'src/adapters/h3/index.ts', 'src/adapters/elysia/index.ts', + 'src/adapters/express/index.ts', ], format: ['esm', 'cjs'], dts: true, - external: ['@supabase/supabase-js', 'hono', 'h3', 'elysia'], + external: ['@supabase/supabase-js', 'hono', 'h3', 'elysia', 'express'], })