From 4fd7b988ac9210cc9789dbf67d0d9f522315c95d Mon Sep 17 00:00:00 2001 From: rd-code-23 Date: Mon, 17 Aug 2026 17:18:57 -0700 Subject: [PATCH] feat(auth): configure PostgreSQL sessions --- apps/api/.env.example | 1 + apps/api/package.json | 6 +- apps/api/src/app.test.ts | 45 +++++++++++- apps/api/src/app.ts | 20 +++++- apps/api/src/auth/session.test.ts | 111 ++++++++++++++++++++++++++++++ apps/api/src/auth/session.ts | 73 ++++++++++++++++++++ apps/api/src/config/env-schema.ts | 1 + apps/api/src/config/env.test.ts | 10 +++ apps/api/src/server.ts | 7 ++ pnpm-lock.yaml | 104 ++++++++++++++++++++++++++++ 10 files changed, 374 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/auth/session.test.ts create mode 100644 apps/api/src/auth/session.ts diff --git a/apps/api/.env.example b/apps/api/.env.example index 11d81f3..b5eb214 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -1,5 +1,6 @@ # Safe local configuration template; replace placeholders in a private .env file. PORT=3000 +TRUST_PROXY_HOPS=0 DATABASE_URL=postgresql://goforlift:goforlift_dev@localhost:5432/goforlift WEB_ORIGIN=http://localhost:5173 API_ORIGIN=http://localhost:3000 diff --git a/apps/api/package.json b/apps/api/package.json index 90ab39a..e3ee7ed 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -16,21 +16,25 @@ }, "dependencies": { "@goforlift/contracts": "workspace:*", + "connect-pg-simple": "^10.0.0", "cors": "^2.8.5", "dotenv": "^17.2.1", "drizzle-orm": "^0.44.4", "express": "^5.1.0", + "express-session": "^1.19.0", "pg": "^8.16.3", "zod": "^4.0.17" }, "devDependencies": { + "@types/connect-pg-simple": "^7.0.3", "@types/cors": "^2.8.19", "@types/express": "^5.0.3", + "@types/express-session": "^1.19.0", "@types/node": "^24.3.0", "@types/pg": "^8.15.5", + "@types/supertest": "^6.0.3", "drizzle-kit": "^0.31.4", "supertest": "^7.1.4", - "@types/supertest": "^6.0.3", "tsx": "^4.20.5", "vitest": "^3.2.4" } diff --git a/apps/api/src/app.test.ts b/apps/api/src/app.test.ts index 057f200..6c53962 100644 --- a/apps/api/src/app.test.ts +++ b/apps/api/src/app.test.ts @@ -1,13 +1,18 @@ +/** Verifies health responses, credentialed CORS, and safe middleware errors. */ import request from 'supertest'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { createApp } from './app.js'; +afterEach(() => vi.restoreAllMocks()); + describe('GET /health', () => { it('reports a connected database', async () => { const response = await request( createApp({ webOrigin: 'http://localhost:5173', + trustProxyHops: 0, + sessionMiddleware: (_request, _response, next) => next(), checkDatabaseConnection: vi.fn().mockResolvedValue(undefined), }), ).get('/health'); @@ -20,6 +25,8 @@ describe('GET /health', () => { const response = await request( createApp({ webOrigin: 'http://localhost:5173', + trustProxyHops: 0, + sessionMiddleware: (_request, _response, next) => next(), checkDatabaseConnection: vi .fn() .mockRejectedValue(new Error('connection failed')), @@ -32,4 +39,40 @@ describe('GET /health', () => { database: 'unavailable', }); }); + + it('allows credentialed requests only from the configured web origin', async () => { + const response = await request( + createApp({ + webOrigin: 'http://localhost:5173', + trustProxyHops: 0, + sessionMiddleware: (_request, _response, next) => next(), + checkDatabaseConnection: vi.fn().mockResolvedValue(undefined), + }), + ) + .get('/health') + .set('Origin', 'http://localhost:5173'); + + expect(response.headers['access-control-allow-origin']).toBe( + 'http://localhost:5173', + ); + expect(response.headers['access-control-allow-credentials']).toBe('true'); + }); + + it('returns a safe response when session middleware fails', async () => { + const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {}); + const response = await request( + createApp({ + webOrigin: 'http://localhost:5173', + trustProxyHops: 0, + sessionMiddleware: (_request, _response, next) => + next(new Error('database connection details')), + checkDatabaseConnection: vi.fn().mockResolvedValue(undefined), + }), + ).get('/health'); + + expect(response.status).toBe(500); + expect(response.body).toEqual({ error: 'internal_server_error' }); + expect(response.text).not.toContain('database connection details'); + expect(errorLog).toHaveBeenCalledWith('API request failed'); + }); }); diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index a42a8ca..32bc306 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,20 +1,29 @@ +/** Composes the Express API and its application-wide middleware. */ import { healthResponseSchema } from '@goforlift/contracts'; import cors from 'cors'; -import express from 'express'; +import express, { type RequestHandler } from 'express'; type AppDependencies = { checkDatabaseConnection: () => Promise; + sessionMiddleware: RequestHandler; + trustProxyHops: number; webOrigin: string; }; export function createApp({ checkDatabaseConnection, + sessionMiddleware, + trustProxyHops, webOrigin, }: AppDependencies) { const app = express(); app.disable('x-powered-by'); - app.use(cors({ origin: webOrigin })); + if (trustProxyHops > 0) { + app.set('trust proxy', trustProxyHops); + } + app.use(cors({ origin: webOrigin, credentials: true })); + app.use(sessionMiddleware); app.use(express.json()); app.get('/health', async (_request, response) => { @@ -35,5 +44,12 @@ export function createApp({ } }); + app.use(((error, _request, response, next) => { + void error; + void next; + console.error('API request failed'); + response.status(500).json({ error: 'internal_server_error' }); + }) satisfies express.ErrorRequestHandler); + return app; } diff --git a/apps/api/src/auth/session.test.ts b/apps/api/src/auth/session.test.ts new file mode 100644 index 0000000..58b482b --- /dev/null +++ b/apps/api/src/auth/session.test.ts @@ -0,0 +1,111 @@ +/** Verifies opaque cookies and valid, missing, unknown, and expired sessions. */ +import express from 'express'; +import session from 'express-session'; +import request from 'supertest'; +import { describe, expect, it } from 'vitest'; + +import { createSessionMiddleware } from './session.js'; + +const sessionConfiguration = { + SESSION_SECRET: 'test-session-secret-at-least-32-characters', + SESSION_DURATION_SECONDS: 604800, + SESSION_COOKIE: { + name: 'goforlift.sid', + httpOnly: true, + secure: false, + sameSite: 'lax' as const, + path: '/', + maxAgeMs: 604800000, + }, +}; + +function createSessionTestApp(store: session.MemoryStore) { + const app = express(); + app.use(createSessionMiddleware(store, sessionConfiguration)); + + app.post('/session', (request, response) => { + request.session.userId = 'user-123'; + response.sendStatus(204); + }); + + app.get('/session', (request, response) => { + response.json({ userId: request.session.userId ?? null }); + }); + + return app; +} + +function clearStore(store: session.MemoryStore) { + return new Promise((resolve, reject) => { + store.clear((error) => + error + ? reject( + error instanceof Error + ? error + : new Error('Failed to clear test session store'), + ) + : resolve(), + ); + }); +} + +function inspectStoredSessions(store: session.MemoryStore) { + return (store as unknown as { sessions: Record }).sessions; +} + +describe('session middleware', () => { + it('does not create a session for an anonymous request', async () => { + const response = await request( + createSessionTestApp(new session.MemoryStore()), + ).get('/session'); + + expect(response.body).toEqual({ userId: null }); + expect(response.headers['set-cookie']).toBeUndefined(); + }); + + it('resolves a valid session from an opaque cookie', async () => { + const agent = request.agent( + createSessionTestApp(new session.MemoryStore()), + ); + const creationResponse = await agent.post('/session'); + const cookie = creationResponse.headers['set-cookie']?.[0]; + + expect(cookie).toContain('goforlift.sid='); + expect(cookie).toContain('HttpOnly'); + expect(cookie).toContain('SameSite=Lax'); + expect(cookie).not.toContain('user-123'); + + const response = await agent.get('/session'); + expect(response.body).toEqual({ userId: 'user-123' }); + }); + + it('treats an unknown session as anonymous', async () => { + const store = new session.MemoryStore(); + const agent = request.agent(createSessionTestApp(store)); + await agent.post('/session'); + await clearStore(store); + + const response = await agent.get('/session'); + expect(response.body).toEqual({ userId: null }); + }); + + it('treats an expired session as anonymous', async () => { + const store = new session.MemoryStore(); + const agent = request.agent(createSessionTestApp(store)); + await agent.post('/session'); + + const storedSessions = inspectStoredSessions(store); + for (const sessionId of Object.keys(storedSessions)) { + const storedSession = JSON.parse(storedSessions[sessionId] ?? '{}') as { + cookie?: { expires?: string }; + }; + if (storedSession.cookie) { + storedSession.cookie.expires = new Date(0).toISOString(); + } + storedSessions[sessionId] = JSON.stringify(storedSession); + } + + const response = await agent.get('/session'); + expect(response.body).toEqual({ userId: null }); + }); +}); diff --git a/apps/api/src/auth/session.ts b/apps/api/src/auth/session.ts new file mode 100644 index 0000000..1720fee --- /dev/null +++ b/apps/api/src/auth/session.ts @@ -0,0 +1,73 @@ +/** Configures typed, PostgreSQL-backed server-side application sessions. */ +import connectPgSimple from 'connect-pg-simple'; +import session, { type Store } from 'express-session'; +import type { Pool } from 'pg'; + +import type { Env } from '../config/env-schema.js'; + +declare module 'express-session' { + interface SessionData { + userId?: string; + } +} + +const PgSessionStore = connectPgSimple(session); +const DAILY_PRUNE_INTERVAL_SECONDS = 24 * 60 * 60; + +type SessionConfiguration = Pick< + Env, + 'SESSION_COOKIE' | 'SESSION_DURATION_SECONDS' | 'SESSION_SECRET' +>; + +export function createPostgresSessionStore( + pool: Pool, + sessionDurationSeconds: number, +) { + return new PgSessionStore({ + pool, + tableName: 'sessions', + createTableIfMissing: false, + ttl: sessionDurationSeconds, + disableTouch: true, + pruneSessionInterval: DAILY_PRUNE_INTERVAL_SECONDS, + errorLog: () => console.error('PostgreSQL session store operation failed'), + }); +} + +export function createSessionMiddleware( + store: Store, + configuration: SessionConfiguration, +) { + const { SESSION_COOKIE: cookie, SESSION_SECRET: secret } = configuration; + + return session({ + store, + secret, + name: cookie.name, + resave: false, + saveUninitialized: false, + rolling: false, + cookie: { + httpOnly: cookie.httpOnly, + secure: cookie.secure, + sameSite: cookie.sameSite, + path: cookie.path, + maxAge: cookie.maxAgeMs, + }, + }); +} + +export function createPostgresSession( + pool: Pool, + configuration: SessionConfiguration, +) { + const store = createPostgresSessionStore( + pool, + configuration.SESSION_DURATION_SECONDS, + ); + + return { + store, + middleware: createSessionMiddleware(store, configuration), + }; +} diff --git a/apps/api/src/config/env-schema.ts b/apps/api/src/config/env-schema.ts index 7058da2..a48736d 100644 --- a/apps/api/src/config/env-schema.ts +++ b/apps/api/src/config/env-schema.ts @@ -19,6 +19,7 @@ const rawEnvSchema = z .enum(['development', 'test', 'production']) .default('development'), PORT: z.coerce.number().int().positive().default(3000), + TRUST_PROXY_HOPS: z.coerce.number().int().min(0).max(1).default(0), DATABASE_URL: z.url(), WEB_ORIGIN: httpUrl.default('http://localhost:5173'), API_ORIGIN: httpUrl.default('http://localhost:3000'), diff --git a/apps/api/src/config/env.test.ts b/apps/api/src/config/env.test.ts index d221f16..e5e1ac1 100644 --- a/apps/api/src/config/env.test.ts +++ b/apps/api/src/config/env.test.ts @@ -7,6 +7,7 @@ import { parseEnv } from './env-schema.js'; const validEnvironment = { NODE_ENV: 'development', PORT: '3000', + TRUST_PROXY_HOPS: '0', DATABASE_URL: 'postgresql://user:password@localhost:5432/goforlift', WEB_ORIGIN: 'http://localhost:5173', API_ORIGIN: 'http://localhost:3000', @@ -72,6 +73,15 @@ describe('parseEnv', () => { ).toThrow('Must use HTTPS in production'); }); + it('rejects broad trusted-proxy configuration', () => { + expect(() => + parseEnv({ + ...validEnvironment, + TRUST_PROXY_HOPS: '2', + }), + ).toThrow(ZodError); + }); + it('enables secure cookies for valid production configuration', () => { const parsed = parseEnv({ ...validEnvironment, diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 9097e46..761cef5 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -1,12 +1,18 @@ +/** Starts the API with database, session, and graceful-shutdown resources. */ import { sql } from 'drizzle-orm'; import { createApp } from './app.js'; +import { createPostgresSession } from './auth/session.js'; import { env } from './config/env.js'; import { createDatabase } from './db/client.js'; const { db, pool } = createDatabase(env.DATABASE_URL); +const { middleware: sessionMiddleware, store: sessionStore } = + createPostgresSession(pool, env); const app = createApp({ webOrigin: env.WEB_ORIGIN, + trustProxyHops: env.TRUST_PROXY_HOPS, + sessionMiddleware, checkDatabaseConnection: async () => { await db.execute(sql`select 1`); }, @@ -18,6 +24,7 @@ const server = app.listen(env.PORT, () => { function shutdown() { server.close(() => { + sessionStore.close(); void pool.end().then(() => process.exit(0)); }); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8afde5..54da622 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: '@goforlift/contracts': specifier: workspace:* version: link:../../packages/contracts + connect-pg-simple: + specifier: ^10.0.0 + version: 10.0.0 cors: specifier: ^2.8.5 version: 2.8.6 @@ -50,6 +53,9 @@ importers: express: specifier: ^5.1.0 version: 5.2.1(supports-color@7.2.0) + express-session: + specifier: ^1.19.0 + version: 1.19.0(supports-color@7.2.0) pg: specifier: ^8.16.3 version: 8.23.0 @@ -57,12 +63,18 @@ importers: specifier: ^4.0.17 version: 4.4.3 devDependencies: + '@types/connect-pg-simple': + specifier: ^7.0.3 + version: 7.0.3 '@types/cors': specifier: ^2.8.19 version: 2.8.19 '@types/express': specifier: ^5.0.3 version: 5.0.6 + '@types/express-session': + specifier: ^1.19.0 + version: 1.19.0 '@types/node': specifier: ^24.3.0 version: 24.13.3 @@ -2094,6 +2106,9 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/connect-pg-simple@7.0.3': + resolution: {integrity: sha512-NGCy9WBlW2bw+J/QlLnFZ9WjoGs6tMo3LAut6mY4kK+XHzue//lpNVpAvYRpIwM969vBRAM2Re0izUvV6kt+NA==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} @@ -2112,6 +2127,9 @@ packages: '@types/express-serve-static-core@5.1.3': resolution: {integrity: sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==} + '@types/express-session@1.19.0': + resolution: {integrity: sha512-GbypG0bog68UbOq2tSAp7SclvCUm3ha1uDi58OPRGK1NfRvCIu7Gz0M7fTGtpNG1T9a29GpuurQj9zEcT/lMXQ==} + '@types/express@5.0.6': resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} @@ -2481,6 +2499,10 @@ packages: resolution: {integrity: sha512-8fLl9F04EJqjSqH+QjITQfJF8BrOVaYr1jewVgSRAEWePfxT0sku4w2hrGQ60BC/TNLGQ2pgxNlTbWQmMPFvXg==} engines: {node: '>=12'} + connect-pg-simple@10.0.0: + resolution: {integrity: sha512-pBGVazlqiMrackzCr0eKhn4LO5trJXsOX0nQoey9wCOayh80MYtThCbq8eoLsjpiWgiok/h+1/uti9/2/Una8A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=22.0.0} + content-disposition@1.1.0: resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} engines: {node: '>=18'} @@ -2499,6 +2521,9 @@ packages: cookie-es@3.1.1: resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + cookie-signature@1.0.7: + resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -2550,6 +2575,14 @@ packages: resolution: {integrity: sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==} engines: {node: '>=10'} + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -2909,6 +2942,10 @@ packages: peerDependencies: express: '>= 4.11' + express-session@1.19.0: + resolution: {integrity: sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==} + engines: {node: '>= 0.8.0'} + express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} @@ -3504,6 +3541,9 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -3550,6 +3590,10 @@ packages: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + engines: {node: '>= 0.8'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -3779,6 +3823,10 @@ packages: '@types/react-dom': optional: true + random-bytes@1.0.0: + resolution: {integrity: sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==} + engines: {node: '>= 0.8'} + range-parser@1.3.0: resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} engines: {node: '>= 0.6'} @@ -3889,6 +3937,9 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -4170,6 +4221,10 @@ packages: engines: {node: '>=14.17'} hasBin: true + uid-safe@2.1.5: + resolution: {integrity: sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==} + engines: {node: '>= 0.8'} + undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} @@ -6154,6 +6209,12 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/connect-pg-simple@7.0.3': + dependencies: + '@types/express': 5.0.6 + '@types/express-session': 1.19.0 + '@types/pg': 8.21.0 + '@types/connect@3.4.38': dependencies: '@types/node': 24.13.3 @@ -6175,6 +6236,10 @@ snapshots: '@types/range-parser': 1.2.7 '@types/send': 1.2.1 + '@types/express-session@1.19.0': + dependencies: + '@types/express': 5.0.6 + '@types/express@5.0.6': dependencies: '@types/body-parser': 1.19.6 @@ -6590,6 +6655,12 @@ snapshots: pkg-up: 3.1.0 semver: 7.8.5 + connect-pg-simple@10.0.0: + dependencies: + pg: 8.23.0 + transitivePeerDependencies: + - pg-native + content-disposition@1.1.0: {} content-type@1.0.5: {} @@ -6600,6 +6671,8 @@ snapshots: cookie-es@3.1.1: {} + cookie-signature@1.0.7: {} + cookie-signature@1.2.2: {} cookie@0.7.2: {} @@ -6646,6 +6719,12 @@ snapshots: dependencies: mimic-fn: 3.1.0 + debug@2.6.9(supports-color@7.2.0): + dependencies: + ms: 2.0.0 + optionalDependencies: + supports-color: 7.2.0 + debug@4.4.3(supports-color@7.2.0): dependencies: ms: 2.1.3 @@ -6979,6 +7058,19 @@ snapshots: transitivePeerDependencies: - supports-color + express-session@1.19.0(supports-color@7.2.0): + dependencies: + cookie: 0.7.2 + cookie-signature: 1.0.7 + debug: 2.6.9(supports-color@7.2.0) + depd: 2.0.0 + on-headers: 1.1.0 + parseurl: 1.3.3 + safe-buffer: 5.2.1 + uid-safe: 2.1.5 + transitivePeerDependencies: + - supports-color + express@5.2.1(supports-color@7.2.0): dependencies: accepts: 2.0.0 @@ -7498,6 +7590,8 @@ snapshots: minimist@1.2.8: {} + ms@2.0.0: {} + ms@2.1.3: {} nanoid@3.3.18: {} @@ -7529,6 +7623,8 @@ snapshots: dependencies: ee-first: 1.1.1 + on-headers@1.1.0: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -7798,6 +7894,8 @@ snapshots: '@types/react': 19.2.18 '@types/react-dom': 19.2.4(@types/react@19.2.18) + random-bytes@1.0.0: {} + range-parser@1.3.0: {} raw-body@3.0.2: @@ -7927,6 +8025,8 @@ snapshots: dependencies: queue-microtask: 1.2.3 + safe-buffer@5.2.1: {} + safer-buffer@2.1.2: {} saxes@6.0.0: @@ -8238,6 +8338,10 @@ snapshots: typescript@5.9.3: {} + uid-safe@2.1.5: + dependencies: + random-bytes: 1.0.0 + undici-types@7.18.2: {} undici@7.29.0: {}