Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 5 additions & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
45 changes: 44 additions & 1 deletion apps/api/src/app.test.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -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')),
Expand All @@ -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');
});
});
20 changes: 18 additions & 2 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
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) => {
Expand All @@ -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;
}
111 changes: 111 additions & 0 deletions apps/api/src/auth/session.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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<string, string> }).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 });
});
});
73 changes: 73 additions & 0 deletions apps/api/src/auth/session.ts
Original file line number Diff line number Diff line change
@@ -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),
};
}
1 change: 1 addition & 0 deletions apps/api/src/config/env-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
10 changes: 10 additions & 0 deletions apps/api/src/config/env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions apps/api/src/server.ts
Original file line number Diff line number Diff line change
@@ -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`);
},
Expand All @@ -18,6 +24,7 @@ const server = app.listen(env.PORT, () => {

function shutdown() {
server.close(() => {
sessionStore.close();
void pool.end().then(() => process.exit(0));
});
}
Expand Down
Loading