From 4b022218e655bdf9a521f87e6acb8b3cf34d1306 Mon Sep 17 00:00:00 2001 From: Cache8063 Date: Mon, 15 Dec 2025 10:35:40 -0600 Subject: [PATCH] Leverage Express 5 features and enable projectService (#10) Express 5 async error handling: - Add HttpError class for typed HTTP errors with status codes - Update error middleware to handle HttpError instances - Remove try/catch blocks from all async route handlers - Replace res.status().json() error responses with throw httpError.x() - Async errors now automatically flow to error middleware typescript-eslint projectService: - Enable projectService in both eslint configs for better performance - Uses TypeScript's native project service instead of custom parsing --- gateway/eslint.config.js | 1 + gateway/src/index.ts | 12 +- gateway/src/routes/admin.ts | 200 +++++++--------- gateway/src/routes/auth.ts | 419 ++++++++++++++-------------------- gateway/src/routes/session.ts | 328 +++++++++++--------------- gateway/src/routes/token.ts | 149 ++++++------ gateway/src/utils/errors.ts | 43 +++- ts/eslint.config.mjs | 1 + 8 files changed, 504 insertions(+), 649 deletions(-) diff --git a/gateway/eslint.config.js b/gateway/eslint.config.js index e316fd7..5f5f0d7 100644 --- a/gateway/eslint.config.js +++ b/gateway/eslint.config.js @@ -11,6 +11,7 @@ export default tseslint.config( parserOptions: { ecmaVersion: 2022, sourceType: 'module', + projectService: true, // Use TypeScript's project service for better performance }, }, rules: { diff --git a/gateway/src/index.ts b/gateway/src/index.ts index 7cae159..fbb5346 100644 --- a/gateway/src/index.ts +++ b/gateway/src/index.ts @@ -18,6 +18,7 @@ import { createTokenRoutes } from './routes/token.js'; import { createAdminRoutes } from './routes/admin.js'; import { createSessionRoutes } from './routes/session.js'; import { authRateLimit, apiRateLimit, adminRateLimit } from './middleware/rateLimit.js'; +import { HttpError } from './utils/errors.js'; // Configuration from environment const config = { @@ -110,8 +111,17 @@ async function main(): Promise { }); }); - // Error handler + // Error handler - Express 5 automatically forwards async errors here app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + // Handle known HTTP errors (thrown by route handlers) + if (err instanceof HttpError) { + return res.status(err.statusCode).json({ + error: err.code, + message: err.message, + }); + } + + // Log unexpected errors console.error('Unhandled error:', err); res.status(500).json({ error: 'internal_error', diff --git a/gateway/src/routes/admin.ts b/gateway/src/routes/admin.ts index 3c3a6d0..8d057e4 100644 --- a/gateway/src/routes/admin.ts +++ b/gateway/src/routes/admin.ts @@ -1,14 +1,15 @@ /** * Admin Routes * - * Application registration and management endpoints + * Application registration and management endpoints. + * Express 5 automatically forwards async errors to the error handler. */ import crypto from 'crypto'; -import { Router, Request, Response } from 'express'; +import { Router, Request, Response, NextFunction } from 'express'; import { DatabaseService } from '../services/database.js'; import { generateHmacSecret } from '../utils/hmac.js'; -import { internalError } from '../utils/errors.js'; +import { httpError } from '../utils/errors.js'; /** * Constant-time string comparison to prevent timing attacks. @@ -27,28 +28,19 @@ function secureCompare(a: string, b: string): boolean { export function createAdminRoutes(db: DatabaseService, adminToken?: string): Router { const router = Router(); - const requireAdmin = (req: Request, res: Response, next: () => void) => { + const requireAdmin = (req: Request, _res: Response, next: NextFunction) => { if (!adminToken) { - return res.status(403).json({ - error: 'admin_disabled', - message: 'Admin endpoints are disabled (set ADMIN_TOKEN)', - }); + throw httpError.forbidden('admin_disabled', 'Admin endpoints are disabled (set ADMIN_TOKEN)'); } const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { - return res.status(401).json({ - error: 'missing_auth', - message: 'Authorization header required', - }); + throw httpError.unauthorized('missing_auth', 'Authorization header required'); } const token = authHeader.substring(7); if (!secureCompare(token, adminToken)) { - return res.status(403).json({ - error: 'invalid_token', - message: 'Invalid admin token', - }); + throw httpError.forbidden('invalid_token', 'Invalid admin token'); } next(); @@ -65,45 +57,35 @@ export function createAdminRoutes(db: DatabaseService, adminToken?: string): Rou * - callback_url: OAuth callback URL */ router.post('/apps', requireAdmin, async (req: Request, res: Response) => { - try { - const { id, name, token_ttl_seconds, callback_url } = req.body; - - if (!id || !name) { - return res.status(400).json({ - error: 'missing_params', - message: 'id and name are required', - }); - } - - const existing = db.getApp(id); - if (existing) { - return res.status(409).json({ - error: 'app_exists', - message: `Application '${id}' already exists`, - }); - } - - const hmac_secret = generateHmacSecret(); - - db.upsertApp({ - id, - name, - hmac_secret, - token_ttl_seconds: token_ttl_seconds || 3600, - callback_url, - }); - - res.status(201).json({ - id, - name, - hmac_secret, - token_ttl_seconds: token_ttl_seconds || 3600, - callback_url, - message: 'Application registered. Store the hmac_secret securely!', - }); - } catch (error) { - res.status(500).json(internalError('registration_failed', error, 'App registration')); + const { id, name, token_ttl_seconds, callback_url } = req.body; + + if (!id || !name) { + throw httpError.badRequest('missing_params', 'id and name are required'); + } + + const existing = db.getApp(id); + if (existing) { + throw httpError.conflict('app_exists', `Application '${id}' already exists`); } + + const hmac_secret = generateHmacSecret(); + + db.upsertApp({ + id, + name, + hmac_secret, + token_ttl_seconds: token_ttl_seconds || 3600, + callback_url, + }); + + res.status(201).json({ + id, + name, + hmac_secret, + token_ttl_seconds: token_ttl_seconds || 3600, + callback_url, + message: 'Application registered. Store the hmac_secret securely!', + }); }); /** @@ -111,24 +93,17 @@ export function createAdminRoutes(db: DatabaseService, adminToken?: string): Rou * Get application configuration (without secret) */ router.get('/apps/:id', requireAdmin, async (req: Request, res: Response) => { - try { - const app = db.getApp(req.params.id); - if (!app) { - return res.status(404).json({ - error: 'app_not_found', - message: `Application '${req.params.id}' not found`, - }); - } - - res.json({ - id: app.id, - name: app.name, - token_ttl_seconds: app.token_ttl_seconds, - callback_url: app.callback_url, - }); - } catch (error) { - res.status(500).json(internalError('get_failed', error, 'Get app')); + const app = db.getApp(req.params.id); + if (!app) { + throw httpError.notFound('app_not_found', `Application '${req.params.id}' not found`); } + + res.json({ + id: app.id, + name: app.name, + token_ttl_seconds: app.token_ttl_seconds, + callback_url: app.callback_url, + }); }); /** @@ -136,43 +111,36 @@ export function createAdminRoutes(db: DatabaseService, adminToken?: string): Rou * Update application configuration */ router.put('/apps/:id', requireAdmin, async (req: Request, res: Response) => { - try { - const existing = db.getApp(req.params.id); - if (!existing) { - return res.status(404).json({ - error: 'app_not_found', - message: `Application '${req.params.id}' not found`, - }); - } - - const { name, token_ttl_seconds, callback_url, rotate_secret } = req.body; - - const updated = { - id: req.params.id, - name: name || existing.name, - hmac_secret: rotate_secret ? generateHmacSecret() : existing.hmac_secret, - token_ttl_seconds: token_ttl_seconds || existing.token_ttl_seconds, - callback_url: callback_url !== undefined ? callback_url : existing.callback_url, - }; - - db.upsertApp(updated); - - const response: Record = { - id: updated.id, - name: updated.name, - token_ttl_seconds: updated.token_ttl_seconds, - callback_url: updated.callback_url, - }; - - if (rotate_secret) { - response.hmac_secret = updated.hmac_secret; - response.message = 'Secret rotated. Update your backend configuration!'; - } - - res.json(response); - } catch (error) { - res.status(500).json(internalError('update_failed', error, 'Update app')); + const existing = db.getApp(req.params.id); + if (!existing) { + throw httpError.notFound('app_not_found', `Application '${req.params.id}' not found`); + } + + const { name, token_ttl_seconds, callback_url, rotate_secret } = req.body; + + const updated = { + id: req.params.id, + name: name || existing.name, + hmac_secret: rotate_secret ? generateHmacSecret() : existing.hmac_secret, + token_ttl_seconds: token_ttl_seconds || existing.token_ttl_seconds, + callback_url: callback_url !== undefined ? callback_url : existing.callback_url, + }; + + db.upsertApp(updated); + + const response: Record = { + id: updated.id, + name: updated.name, + token_ttl_seconds: updated.token_ttl_seconds, + callback_url: updated.callback_url, + }; + + if (rotate_secret) { + response.hmac_secret = updated.hmac_secret; + response.message = 'Secret rotated. Update your backend configuration!'; } + + res.json(response); }); /** @@ -180,17 +148,13 @@ export function createAdminRoutes(db: DatabaseService, adminToken?: string): Rou * Clean up expired sessions and OAuth states */ router.post('/cleanup', requireAdmin, async (_req: Request, res: Response) => { - try { - const statesDeleted = db.cleanupOldOAuthStates(); - const sessionsDeleted = db.cleanupExpiredSessions(); - - res.json({ - oauth_states_deleted: statesDeleted, - sessions_deleted: sessionsDeleted, - }); - } catch (error) { - res.status(500).json(internalError('cleanup_failed', error, 'Cleanup')); - } + const statesDeleted = db.cleanupOldOAuthStates(); + const sessionsDeleted = db.cleanupExpiredSessions(); + + res.json({ + oauth_states_deleted: statesDeleted, + sessions_deleted: sessionsDeleted, + }); }); return router; diff --git a/gateway/src/routes/auth.ts b/gateway/src/routes/auth.ts index ae65297..069a73b 100644 --- a/gateway/src/routes/auth.ts +++ b/gateway/src/routes/auth.ts @@ -1,7 +1,8 @@ /** * Auth Routes * - * OAuth flow endpoints for AT Protocol authentication + * OAuth flow endpoints for AT Protocol authentication. + * Express 5 automatically forwards async errors to the error handler. */ import { Router, Request, Response } from 'express'; @@ -9,7 +10,7 @@ import { v4 as uuidv4 } from 'uuid'; import { OAuthService } from '../services/oauth.js'; import { DatabaseService } from '../services/database.js'; import { createGatewayToken } from '../utils/hmac.js'; -import { internalError } from '../utils/errors.js'; +import { httpError } from '../utils/errors.js'; /** * Validate a redirect URI against an app's allowed callback URL. @@ -75,60 +76,44 @@ export function createAuthRoutes( * - redirect_uri: Where to redirect after auth (optional) */ router.post('/init', async (req: Request, res: Response) => { - try { - const { app_id, handle, redirect_uri } = req.body; - - if (!app_id || typeof app_id !== 'string') { - return res.status(400).json({ - error: 'missing_app_id', - message: 'app_id is required', - }); - } - - if (!handle || typeof handle !== 'string') { - return res.status(400).json({ - error: 'missing_handle', - message: 'handle is required (e.g., yourname.bsky.social)', - }); - } + const { app_id, handle, redirect_uri } = req.body; - const app = db.getApp(app_id); - if (!app) { - return res.status(404).json({ - error: 'app_not_found', - message: `Application '${app_id}' is not registered`, - }); - } + if (!app_id || typeof app_id !== 'string') { + throw httpError.badRequest('missing_app_id', 'app_id is required'); + } - // Validate redirect_uri if provided - let validatedRedirectUri: string | undefined; - if (typeof redirect_uri === 'string') { - if (!isValidRedirectUri(redirect_uri, app.callback_url)) { - return res.status(400).json({ - error: 'invalid_redirect_uri', - message: 'redirect_uri does not match the registered callback URL for this application', - }); - } - validatedRedirectUri = redirect_uri; - } else if (app.callback_url) { - // Use the registered callback URL as default - validatedRedirectUri = app.callback_url; - } + if (!handle || typeof handle !== 'string') { + throw httpError.badRequest('missing_handle', 'handle is required (e.g., yourname.bsky.social)'); + } - const { url, state } = await oauth.generateAuthUrl( - app_id, - handle, - validatedRedirectUri - ); + const app = db.getApp(app_id); + if (!app) { + throw httpError.notFound('app_not_found', `Application '${app_id}' is not registered`); + } - res.json({ - auth_url: url, - state, - app_id, - }); - } catch (error) { - res.status(500).json(internalError('auth_init_failed', error, 'Auth init')); + // Validate redirect_uri if provided + let validatedRedirectUri: string | undefined; + if (typeof redirect_uri === 'string') { + if (!isValidRedirectUri(redirect_uri, app.callback_url)) { + throw httpError.badRequest('invalid_redirect_uri', 'redirect_uri does not match the registered callback URL for this application'); + } + validatedRedirectUri = redirect_uri; + } else if (app.callback_url) { + // Use the registered callback URL as default + validatedRedirectUri = app.callback_url; } + + const { url, state } = await oauth.generateAuthUrl( + app_id, + handle, + validatedRedirectUri + ); + + res.json({ + auth_url: url, + state, + app_id, + }); }); /** @@ -136,101 +121,85 @@ export function createAuthRoutes( * OAuth callback handler */ router.get('/callback', async (req: Request, res: Response) => { - try { - const params = new URLSearchParams(req.url.split('?')[1] || ''); - - const state = params.get('state'); - if (!state) { - return res.status(400).json({ - error: 'missing_state', - message: 'OAuth state parameter is missing', - }); - } - - const savedState = db.getOAuthState(state); - if (!savedState) { - return res.status(400).json({ - error: 'invalid_state', - message: 'OAuth state not found or expired', - }); - } + const params = new URLSearchParams(req.url.split('?')[1] || ''); - db.deleteOAuthState(state); + const state = params.get('state'); + if (!state) { + throw httpError.badRequest('missing_state', 'OAuth state parameter is missing'); + } - const app = db.getApp(savedState.app_id); - if (!app) { - return res.status(404).json({ - error: 'app_not_found', - message: 'Application configuration not found', - }); - } + const savedState = db.getOAuthState(state); + if (!savedState) { + throw httpError.badRequest('invalid_state', 'OAuth state not found or expired'); + } - const result = await oauth.handleCallback(params); + db.deleteOAuthState(state); - const existingMapping = db.getUserMapping(result.did, savedState.app_id); - const userId = existingMapping?.user_id ?? null; + const app = db.getApp(savedState.app_id); + if (!app) { + throw httpError.notFound('app_not_found', 'Application configuration not found'); + } - const token = createGatewayToken( - { - did: result.did, - handle: result.handle, - user_id: userId, - app_id: savedState.app_id, - }, - app.hmac_secret, - app.token_ttl_seconds - ); + const result = await oauth.handleCallback(params); - const sessionId = uuidv4(); - const expiresAt = new Date(Date.now() + app.token_ttl_seconds * 1000); + const existingMapping = db.getUserMapping(result.did, savedState.app_id); + const userId = existingMapping?.user_id ?? null; - db.createSession({ - id: sessionId, + const token = createGatewayToken( + { did: result.did, handle: result.handle, user_id: userId, app_id: savedState.app_id, - expires_at: expiresAt, - }); - - if (savedState.redirect_uri) { - // Defense in depth: re-validate redirect URI before redirecting - if (!isValidRedirectUri(savedState.redirect_uri, app.callback_url)) { - console.error(`[SECURITY] Invalid redirect_uri in saved state: ${savedState.redirect_uri}`); - return res.status(400).json({ - error: 'invalid_redirect_uri', - message: 'Redirect URI validation failed', - }); - } - - const redirectUrl = new URL(savedState.redirect_uri); - // Use URL fragment (hash) for sensitive data to prevent logging in: - // - Server access logs - // - Browser history - // - Referrer headers - // Fragments are only available client-side and never sent to servers - const fragmentParams = new URLSearchParams(); - fragmentParams.set('token', token); - fragmentParams.set('session_id', sessionId); - if (userId === null) { - fragmentParams.set('needs_linking', 'true'); - } - redirectUrl.hash = fragmentParams.toString(); - return res.redirect(redirectUrl.toString()); + }, + app.hmac_secret, + app.token_ttl_seconds + ); + + const sessionId = uuidv4(); + const expiresAt = new Date(Date.now() + app.token_ttl_seconds * 1000); + + db.createSession({ + id: sessionId, + did: result.did, + handle: result.handle, + user_id: userId, + app_id: savedState.app_id, + expires_at: expiresAt, + }); + + if (savedState.redirect_uri) { + // Defense in depth: re-validate redirect URI before redirecting + if (!isValidRedirectUri(savedState.redirect_uri, app.callback_url)) { + console.error(`[SECURITY] Invalid redirect_uri in saved state: ${savedState.redirect_uri}`); + throw httpError.badRequest('invalid_redirect_uri', 'Redirect URI validation failed'); } - res.json({ - token, - session_id: sessionId, - did: result.did, - handle: result.handle, - user_id: userId, - needs_linking: userId === null, - expires_at: expiresAt.toISOString(), - }); - } catch (error) { - res.status(500).json(internalError('auth_callback_failed', error, 'Auth callback')); + const redirectUrl = new URL(savedState.redirect_uri); + // Use URL fragment (hash) for sensitive data to prevent logging in: + // - Server access logs + // - Browser history + // - Referrer headers + // Fragments are only available client-side and never sent to servers + const fragmentParams = new URLSearchParams(); + fragmentParams.set('token', token); + fragmentParams.set('session_id', sessionId); + if (userId === null) { + fragmentParams.set('needs_linking', 'true'); + } + redirectUrl.hash = fragmentParams.toString(); + return res.redirect(redirectUrl.toString()); } + + res.json({ + token, + session_id: sessionId, + did: result.did, + handle: result.handle, + user_id: userId, + needs_linking: userId === null, + expires_at: expiresAt.toISOString(), + }); }); /** @@ -238,67 +207,51 @@ export function createAuthRoutes( * Link an AT Protocol identity to an application user account */ router.post('/link', async (req: Request, res: Response) => { - try { - const { session_id, user_id, app_id } = req.body; - - if (!session_id || !user_id || !app_id) { - return res.status(400).json({ - error: 'missing_params', - message: 'session_id, user_id, and app_id are required', - }); - } + const { session_id, user_id, app_id } = req.body; - const session = db.getSession(session_id); - if (!session) { - return res.status(404).json({ - error: 'session_not_found', - message: 'Session not found or expired', - }); - } + if (!session_id || !user_id || !app_id) { + throw httpError.badRequest('missing_params', 'session_id, user_id, and app_id are required'); + } - if (session.app_id !== app_id) { - return res.status(400).json({ - error: 'app_mismatch', - message: 'Session app_id does not match', - }); - } + const session = db.getSession(session_id); + if (!session) { + throw httpError.notFound('session_not_found', 'Session not found or expired'); + } - const app = db.getApp(app_id); - if (!app) { - return res.status(404).json({ - error: 'app_not_found', - message: 'Application not found', - }); - } + if (session.app_id !== app_id) { + throw httpError.badRequest('app_mismatch', 'Session app_id does not match'); + } - db.setUserMapping({ - did: session.did, - app_id, - user_id: parseInt(user_id, 10), - handle: session.handle, - }); - - const token = createGatewayToken( - { - did: session.did, - handle: session.handle, - user_id: parseInt(user_id, 10), - app_id, - }, - app.hmac_secret, - app.token_ttl_seconds - ); - - res.json({ - success: true, - token, + const app = db.getApp(app_id); + if (!app) { + throw httpError.notFound('app_not_found', 'Application not found'); + } + + db.setUserMapping({ + did: session.did, + app_id, + user_id: parseInt(user_id, 10), + handle: session.handle, + }); + + const token = createGatewayToken( + { did: session.did, handle: session.handle, user_id: parseInt(user_id, 10), - }); - } catch (error) { - res.status(500).json(internalError('link_failed', error, 'Link')); - } + app_id, + }, + app.hmac_secret, + app.token_ttl_seconds + ); + + res.json({ + success: true, + token, + did: session.did, + handle: session.handle, + user_id: parseInt(user_id, 10), + }); }); /** @@ -306,53 +259,40 @@ export function createAuthRoutes( * Refresh an expired gateway token */ router.post('/refresh', async (req: Request, res: Response) => { - try { - const { session_id, app_id } = req.body; - - if (!session_id || !app_id) { - return res.status(400).json({ - error: 'missing_params', - message: 'session_id and app_id are required', - }); - } + const { session_id, app_id } = req.body; - const session = db.getSession(session_id); - if (!session) { - return res.status(404).json({ - error: 'session_not_found', - message: 'Session not found or expired', - }); - } + if (!session_id || !app_id) { + throw httpError.badRequest('missing_params', 'session_id and app_id are required'); + } - const app = db.getApp(app_id); - if (!app) { - return res.status(404).json({ - error: 'app_not_found', - message: 'Application not found', - }); - } + const session = db.getSession(session_id); + if (!session) { + throw httpError.notFound('session_not_found', 'Session not found or expired'); + } - const mapping = db.getUserMapping(session.did, app_id); - const userId = mapping?.user_id ?? session.user_id; - - const token = createGatewayToken( - { - did: session.did, - handle: session.handle, - user_id: userId, - app_id, - }, - app.hmac_secret, - app.token_ttl_seconds - ); - - res.json({ - token, - expires_in: app.token_ttl_seconds, - }); - } catch (error) { - res.status(500).json(internalError('refresh_failed', error, 'Refresh')); + const app = db.getApp(app_id); + if (!app) { + throw httpError.notFound('app_not_found', 'Application not found'); } + + const mapping = db.getUserMapping(session.did, app_id); + const userId = mapping?.user_id ?? session.user_id; + + const token = createGatewayToken( + { + did: session.did, + handle: session.handle, + user_id: userId, + app_id, + }, + app.hmac_secret, + app.token_ttl_seconds + ); + + res.json({ + token, + expires_in: app.token_ttl_seconds, + }); }); /** @@ -360,22 +300,15 @@ export function createAuthRoutes( * Invalidate a session */ router.post('/logout', async (req: Request, res: Response) => { - try { - const { session_id } = req.body; - - if (!session_id) { - return res.status(400).json({ - error: 'missing_session_id', - message: 'session_id is required', - }); - } + const { session_id } = req.body; - db.deleteSession(session_id); - - res.json({ success: true }); - } catch (error) { - res.status(500).json(internalError('logout_failed', error, 'Logout')); + if (!session_id) { + throw httpError.badRequest('missing_session_id', 'session_id is required'); } + + db.deleteSession(session_id); + + res.json({ success: true }); }); return router; diff --git a/gateway/src/routes/session.ts b/gateway/src/routes/session.ts index 7e48a55..d768bdc 100644 --- a/gateway/src/routes/session.ts +++ b/gateway/src/routes/session.ts @@ -1,13 +1,14 @@ /** * Session Routes * - * Endpoints for session conflict detection and resolution + * Endpoints for session conflict detection and resolution. + * Express 5 automatically forwards async errors to the error handler. */ import { Router, Request, Response } from 'express'; import { DatabaseService } from '../services/database.js'; import { createGatewayToken } from '../utils/hmac.js'; -import { internalError } from '../utils/errors.js'; +import { httpError } from '../utils/errors.js'; import type { SessionResolution, SessionConflict } from '../types/index.js'; export function createSessionRoutes(db: DatabaseService): Router { @@ -18,55 +19,42 @@ export function createSessionRoutes(db: DatabaseService): Router { * Check for existing active sessions */ router.post('/check-conflict', async (req: Request, res: Response) => { - try { - const { session_id, app_id } = req.body; + const { session_id, app_id } = req.body; - if (!session_id || typeof session_id !== 'string') { - return res.status(400).json({ - error: 'missing_session_id', - message: 'session_id is required', - }); - } - - if (!app_id || typeof app_id !== 'string') { - return res.status(400).json({ - error: 'missing_app_id', - message: 'app_id is required', - }); - } + if (!session_id || typeof session_id !== 'string') { + throw httpError.badRequest('missing_session_id', 'session_id is required'); + } - const pendingSession = db.getSession(session_id); - if (!pendingSession) { - return res.status(404).json({ - error: 'session_not_found', - message: 'Pending session not found or expired', - }); - } + if (!app_id || typeof app_id !== 'string') { + throw httpError.badRequest('missing_app_id', 'app_id is required'); + } - const activeSessions = db.getActiveSessionsByDid(pendingSession.did, app_id); - const otherSessions = activeSessions.filter((s) => s.id !== session_id); - - const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); - const conflictingSessions = otherSessions.filter( - (s) => s.connection_state === 'connected' || s.last_activity > fiveMinutesAgo - ); - - const response: SessionConflict = { - has_conflict: conflictingSessions.length > 0, - existing_sessions: conflictingSessions.map((s) => ({ - session_id: s.id, - created_at: s.created_at.toISOString(), - last_activity: s.last_activity.toISOString(), - connection_state: s.connection_state, - client_info: s.client_info, - })), - pending_session_id: session_id, - }; - - res.json(response); - } catch (error) { - res.status(500).json(internalError('check_conflict_failed', error, 'Check conflict')); + const pendingSession = db.getSession(session_id); + if (!pendingSession) { + throw httpError.notFound('session_not_found', 'Pending session not found or expired'); } + + const activeSessions = db.getActiveSessionsByDid(pendingSession.did, app_id); + const otherSessions = activeSessions.filter((s) => s.id !== session_id); + + const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); + const conflictingSessions = otherSessions.filter( + (s) => s.connection_state === 'connected' || s.last_activity > fiveMinutesAgo + ); + + const response: SessionConflict = { + has_conflict: conflictingSessions.length > 0, + existing_sessions: conflictingSessions.map((s) => ({ + session_id: s.id, + created_at: s.created_at.toISOString(), + last_activity: s.last_activity.toISOString(), + connection_state: s.connection_state, + client_info: s.client_info, + })), + pending_session_id: session_id, + }; + + res.json(response); }); /** @@ -74,82 +62,66 @@ export function createSessionRoutes(db: DatabaseService): Router { * Resolve a session conflict */ router.post('/resolve-conflict', async (req: Request, res: Response) => { - try { - const { session_id, app_id, resolution } = req.body; + const { session_id, app_id, resolution } = req.body; - if (!session_id || !app_id) { - return res.status(400).json({ - error: 'missing_params', - message: 'session_id and app_id are required', - }); - } + if (!session_id || !app_id) { + throw httpError.badRequest('missing_params', 'session_id and app_id are required'); + } - const validResolutions: SessionResolution[] = ['transfer', 'cancel', 'close_others']; - if (!resolution || !validResolutions.includes(resolution)) { - return res.status(400).json({ - error: 'invalid_resolution', - message: "resolution must be: 'transfer', 'cancel', or 'close_others'", - }); - } + const validResolutions: SessionResolution[] = ['transfer', 'cancel', 'close_others']; + if (!resolution || !validResolutions.includes(resolution)) { + throw httpError.badRequest('invalid_resolution', "resolution must be: 'transfer', 'cancel', or 'close_others'"); + } - const session = db.getSession(session_id); - if (!session) { - return res.status(404).json({ - error: 'session_not_found', - message: 'Session not found or expired', - }); - } + const session = db.getSession(session_id); + if (!session) { + throw httpError.notFound('session_not_found', 'Session not found or expired'); + } - const app = db.getApp(app_id); - if (!app) { - return res.status(404).json({ - error: 'app_not_found', - message: 'Application not found', + const app = db.getApp(app_id); + if (!app) { + throw httpError.notFound('app_not_found', 'Application not found'); + } + + switch (resolution as SessionResolution) { + case 'cancel': + db.deleteSession(session_id); + return res.json({ + success: true, + action: 'cancelled', + message: 'Login cancelled', }); - } - switch (resolution as SessionResolution) { - case 'cancel': - db.deleteSession(session_id); - return res.json({ - success: true, - action: 'cancelled', - message: 'Login cancelled', - }); - - case 'close_others': - case 'transfer': { - const closedCount = db.deleteOtherSessions(session_id, session.did, app_id); - db.updateSessionConnectionState(session_id, 'pending'); - - const mapping = db.getUserMapping(session.did, app_id); - const userId = mapping?.user_id ?? session.user_id; - - const token = createGatewayToken( - { - did: session.did, - handle: session.handle, - user_id: userId, - app_id, - }, - app.hmac_secret, - app.token_ttl_seconds - ); - - return res.json({ - success: true, - action: resolution === 'transfer' ? 'transferred' : 'closed_others', - closed_count: closedCount, - token, - session_id, + case 'close_others': + case 'transfer': { + const closedCount = db.deleteOtherSessions(session_id, session.did, app_id); + db.updateSessionConnectionState(session_id, 'pending'); + + const mapping = db.getUserMapping(session.did, app_id); + const userId = mapping?.user_id ?? session.user_id; + + const token = createGatewayToken( + { did: session.did, handle: session.handle, user_id: userId, - }); - } + app_id, + }, + app.hmac_secret, + app.token_ttl_seconds + ); + + return res.json({ + success: true, + action: resolution === 'transfer' ? 'transferred' : 'closed_others', + closed_count: closedCount, + token, + session_id, + did: session.did, + handle: session.handle, + user_id: userId, + }); } - } catch (error) { - res.status(500).json(internalError('resolve_conflict_failed', error, 'Resolve conflict')); } }); @@ -158,38 +130,25 @@ export function createSessionRoutes(db: DatabaseService): Router { * Update session connection state */ router.post('/update-state', async (req: Request, res: Response) => { - try { - const { session_id, state, client_info } = req.body; + const { session_id, state, client_info } = req.body; - if (!session_id) { - return res.status(400).json({ - error: 'missing_session_id', - message: 'session_id is required', - }); - } + if (!session_id) { + throw httpError.badRequest('missing_session_id', 'session_id is required'); + } - const validStates = ['connected', 'disconnected', 'pending']; - if (!state || !validStates.includes(state)) { - return res.status(400).json({ - error: 'invalid_state', - message: "state must be: 'connected', 'disconnected', or 'pending'", - }); - } + const validStates = ['connected', 'disconnected', 'pending']; + if (!state || !validStates.includes(state)) { + throw httpError.badRequest('invalid_state', "state must be: 'connected', 'disconnected', or 'pending'"); + } - const session = db.getSession(session_id); - if (!session) { - return res.status(404).json({ - error: 'session_not_found', - message: 'Session not found', - }); - } + const session = db.getSession(session_id); + if (!session) { + throw httpError.notFound('session_not_found', 'Session not found'); + } - db.updateSessionConnectionState(session_id, state, client_info); + db.updateSessionConnectionState(session_id, state, client_info); - res.json({ success: true, session_id, state }); - } catch (error) { - res.status(500).json(internalError('update_state_failed', error, 'Update state')); - } + res.json({ success: true, session_id, state }); }); /** @@ -197,30 +156,20 @@ export function createSessionRoutes(db: DatabaseService): Router { * Update session last activity */ router.post('/heartbeat', async (req: Request, res: Response) => { - try { - const { session_id } = req.body; + const { session_id } = req.body; - if (!session_id) { - return res.status(400).json({ - error: 'missing_session_id', - message: 'session_id is required', - }); - } + if (!session_id) { + throw httpError.badRequest('missing_session_id', 'session_id is required'); + } - const session = db.getSession(session_id); - if (!session) { - return res.status(404).json({ - error: 'session_not_found', - message: 'Session not found or expired', - }); - } + const session = db.getSession(session_id); + if (!session) { + throw httpError.notFound('session_not_found', 'Session not found or expired'); + } - db.updateSessionActivity(session_id); + db.updateSessionActivity(session_id); - res.json({ success: true, session_id }); - } catch (error) { - res.status(500).json(internalError('heartbeat_failed', error, 'Heartbeat')); - } + res.json({ success: true, session_id }); }); /** @@ -228,46 +177,33 @@ export function createSessionRoutes(db: DatabaseService): Router { * List all active sessions for the user */ router.get('/active', async (req: Request, res: Response) => { - try { - const { session_id, app_id } = req.query; + const { session_id, app_id } = req.query; - if (!session_id || typeof session_id !== 'string') { - return res.status(400).json({ - error: 'missing_session_id', - message: 'session_id is required', - }); - } - - if (!app_id || typeof app_id !== 'string') { - return res.status(400).json({ - error: 'missing_app_id', - message: 'app_id is required', - }); - } + if (!session_id || typeof session_id !== 'string') { + throw httpError.badRequest('missing_session_id', 'session_id is required'); + } - const session = db.getSession(session_id); - if (!session) { - return res.status(404).json({ - error: 'session_not_found', - message: 'Session not found or expired', - }); - } + if (!app_id || typeof app_id !== 'string') { + throw httpError.badRequest('missing_app_id', 'app_id is required'); + } - const activeSessions = db.getActiveSessionsByDid(session.did, app_id); - - res.json({ - sessions: activeSessions.map((s) => ({ - session_id: s.id, - is_current: s.id === session_id, - created_at: s.created_at.toISOString(), - last_activity: s.last_activity.toISOString(), - connection_state: s.connection_state, - client_info: s.client_info, - })), - }); - } catch (error) { - res.status(500).json(internalError('list_sessions_failed', error, 'List active sessions')); + const session = db.getSession(session_id); + if (!session) { + throw httpError.notFound('session_not_found', 'Session not found or expired'); } + + const activeSessions = db.getActiveSessionsByDid(session.did, app_id); + + res.json({ + sessions: activeSessions.map((s) => ({ + session_id: s.id, + is_current: s.id === session_id, + created_at: s.created_at.toISOString(), + last_activity: s.last_activity.toISOString(), + connection_state: s.connection_state, + client_info: s.client_info, + })), + }); }); return router; diff --git a/gateway/src/routes/token.ts b/gateway/src/routes/token.ts index 1884de4..4dcf7ae 100644 --- a/gateway/src/routes/token.ts +++ b/gateway/src/routes/token.ts @@ -1,13 +1,14 @@ /** * Token Routes * - * Token verification and management endpoints + * Token verification and management endpoints. + * Express 5 automatically forwards async errors to the error handler. */ import { Router, Request, Response } from 'express'; import { DatabaseService } from '../services/database.js'; import { verifyGatewayToken } from '../utils/hmac.js'; -import { internalError } from '../utils/errors.js'; +import { httpError } from '../utils/errors.js'; export function createTokenRoutes(db: DatabaseService): Router { const router = Router(); @@ -21,57 +22,52 @@ export function createTokenRoutes(db: DatabaseService): Router { * - app_id: The application identifier */ router.post('/verify', async (req: Request, res: Response) => { - try { - const { token, app_id } = req.body; + const { token, app_id } = req.body; - if (!token || !app_id) { - return res.status(400).json({ - valid: false, - error: 'missing_params', - message: 'token and app_id are required', - }); - } - - const app = db.getApp(app_id); - if (!app) { - return res.status(404).json({ - valid: false, - error: 'app_not_found', - message: `Application '${app_id}' is not registered`, - }); - } + if (!token || !app_id) { + return res.status(400).json({ + valid: false, + error: 'missing_params', + message: 'token and app_id are required', + }); + } - const payload = verifyGatewayToken(token, app.hmac_secret); - if (!payload) { - return res.status(401).json({ - valid: false, - error: 'invalid_token', - message: 'Token is invalid or expired', - }); - } + const app = db.getApp(app_id); + if (!app) { + return res.status(404).json({ + valid: false, + error: 'app_not_found', + message: `Application '${app_id}' is not registered`, + }); + } - if (payload.app_id !== app_id) { - return res.status(401).json({ - valid: false, - error: 'app_mismatch', - message: 'Token was issued for a different application', - }); - } + const payload = verifyGatewayToken(token, app.hmac_secret); + if (!payload) { + return res.status(401).json({ + valid: false, + error: 'invalid_token', + message: 'Token is invalid or expired', + }); + } - res.json({ - valid: true, - payload: { - did: payload.did, - handle: payload.handle, - user_id: payload.user_id, - app_id: payload.app_id, - exp: payload.exp, - }, + if (payload.app_id !== app_id) { + return res.status(401).json({ + valid: false, + error: 'app_mismatch', + message: 'Token was issued for a different application', }); - } catch (error) { - const errResponse = internalError('verify_failed', error, 'Token verify'); - res.status(500).json({ valid: false, ...errResponse }); } + + res.json({ + valid: true, + payload: { + did: payload.did, + handle: payload.handle, + user_id: payload.user_id, + app_id: payload.app_id, + exp: payload.exp, + }, + }); }); /** @@ -79,47 +75,34 @@ export function createTokenRoutes(db: DatabaseService): Router { * Get information about a token */ router.get('/info', async (req: Request, res: Response) => { - try { - const { token, app_id } = req.query; + const { token, app_id } = req.query; - if (!token || typeof token !== 'string' || !app_id || typeof app_id !== 'string') { - return res.status(400).json({ - error: 'missing_params', - message: 'token and app_id query parameters are required', - }); - } + if (!token || typeof token !== 'string' || !app_id || typeof app_id !== 'string') { + throw httpError.badRequest('missing_params', 'token and app_id query parameters are required'); + } - const app = db.getApp(app_id); - if (!app) { - return res.status(404).json({ - error: 'app_not_found', - message: `Application '${app_id}' is not registered`, - }); - } + const app = db.getApp(app_id); + if (!app) { + throw httpError.notFound('app_not_found', `Application '${app_id}' is not registered`); + } - const payload = verifyGatewayToken(token, app.hmac_secret); - if (!payload) { - return res.status(401).json({ - error: 'invalid_token', - message: 'Token is invalid or expired', - }); - } + const payload = verifyGatewayToken(token, app.hmac_secret); + if (!payload) { + throw httpError.unauthorized('invalid_token', 'Token is invalid or expired'); + } - const now = Math.floor(Date.now() / 1000); - const remainingSeconds = payload.exp - now; + const now = Math.floor(Date.now() / 1000); + const remainingSeconds = payload.exp - now; - res.json({ - did: payload.did, - handle: payload.handle, - user_id: payload.user_id, - app_id: payload.app_id, - issued_at: new Date(payload.iat * 1000).toISOString(), - expires_at: new Date(payload.exp * 1000).toISOString(), - remaining_seconds: remainingSeconds, - }); - } catch (error) { - res.status(500).json(internalError('info_failed', error, 'Token info')); - } + res.json({ + did: payload.did, + handle: payload.handle, + user_id: payload.user_id, + app_id: payload.app_id, + issued_at: new Date(payload.iat * 1000).toISOString(), + expires_at: new Date(payload.exp * 1000).toISOString(), + remaining_seconds: remainingSeconds, + }); }); return router; diff --git a/gateway/src/utils/errors.ts b/gateway/src/utils/errors.ts index a57aac1..bfa2532 100644 --- a/gateway/src/utils/errors.ts +++ b/gateway/src/utils/errors.ts @@ -2,8 +2,43 @@ * Error Handling Utilities * * Provides safe error responses that don't leak internal details. + * Express 5 automatically forwards async errors to the error handler. */ +/** + * Standard error response format. + */ +export interface ErrorResponse { + error: string; + message: string; +} + +/** + * HTTP error with status code and machine-readable error code. + * Thrown from route handlers and caught by the global error middleware. + */ +export class HttpError extends Error { + constructor( + public readonly statusCode: number, + public readonly code: string, + message: string + ) { + super(message); + this.name = 'HttpError'; + } +} + +/** + * Convenience factory functions for common HTTP errors. + */ +export const httpError = { + badRequest: (code: string, message: string) => new HttpError(400, code, message), + unauthorized: (code: string, message: string) => new HttpError(401, code, message), + forbidden: (code: string, message: string) => new HttpError(403, code, message), + notFound: (code: string, message: string) => new HttpError(404, code, message), + conflict: (code: string, message: string) => new HttpError(409, code, message), +}; + /** * Sanitize an error for client response. * Logs the full error server-side but returns a safe message to clients. @@ -29,14 +64,6 @@ export function sanitizeError(error: unknown, context: string): string { return 'An internal error occurred. Please try again later.'; } -/** - * Standard error response format. - */ -export interface ErrorResponse { - error: string; - message: string; -} - /** * Create a safe 500 error response. * diff --git a/ts/eslint.config.mjs b/ts/eslint.config.mjs index 479770b..4cb1485 100644 --- a/ts/eslint.config.mjs +++ b/ts/eslint.config.mjs @@ -14,6 +14,7 @@ export default tseslint.config( parserOptions: { ecmaVersion: 2022, sourceType: 'module', + projectService: true, // Use TypeScript's project service for better performance }, }, rules: { -- knotmirror 0.0.1