From 90e48641658d54e0edce9719c40cc46ced20d65b Mon Sep 17 00:00:00 2001 From: Utsav joshi Date: Wed, 29 Apr 2026 14:54:18 +0530 Subject: [PATCH 1/8] refactor: replace console logs with structured logger, enhance resume AI parsing with Zod, and adjust infrastructure resource limits. --- .github/workflows/deploy.yml | 35 +- apps/api/src/controllers/auth.controller.ts | 440 +++--------------- apps/api/src/lib/redis.ts | 3 +- apps/api/src/middleware/error-handler.ts | 7 +- apps/api/src/middleware/request-id.ts | 26 ++ .../src/middleware/token-bucket-rate-limit.ts | 12 +- apps/api/src/server.ts | 21 +- apps/api/src/services/auth.service.ts | 398 ++++++++++++++++ apps/api/src/services/cache.service.ts | 29 +- apps/api/src/services/chat.service.ts | 69 ++- apps/api/src/services/matching.service.ts | 9 +- apps/api/src/services/queue.service.ts | 14 +- apps/api/src/services/resume.service.ts | 75 +-- docker-compose.prod.yml | 12 +- packages/config/app-config/src/index.ts | 24 +- scripts/add-hnsw-indexes.sql | 73 ++- 16 files changed, 749 insertions(+), 498 deletions(-) create mode 100644 apps/api/src/middleware/request-id.ts create mode 100644 apps/api/src/services/auth.service.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 52f7464..5e9f425 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -22,6 +22,8 @@ jobs: key: ${{ secrets.SSH_PRIVATE_KEY }} script: | + set -e # Exit immediately on any error + # Navigate to project directory cd /var/www/postly @@ -29,11 +31,36 @@ jobs: git fetch --all git reset --hard origin/main - # Rebuild and start services - docker compose -f docker-compose.prod.yml up -d --build + # Rebuild and start services (--no-deps prevents cascading restarts) + docker compose -f docker-compose.prod.yml build --no-cache api + docker compose -f docker-compose.prod.yml up -d --no-deps api + + # Wait for API to become healthy + echo "Waiting for API health check..." + RETRIES=10 + DELAY=5 + for i in $(seq 1 $RETRIES); do + if curl -sf http://localhost:3000/health > /dev/null 2>&1; then + echo "✅ API is healthy (attempt $i/$RETRIES)" + break + fi + if [ $i -eq $RETRIES ]; then + echo "❌ API health check failed after $RETRIES attempts" + echo "--- Last 50 lines of API logs ---" + docker compose -f docker-compose.prod.yml logs api --tail=50 + exit 1 + fi + echo "⏳ Attempt $i/$RETRIES failed, retrying in ${DELAY}s..." + sleep $DELAY + done - # Run database migrations - docker exec postly-api npm run migrate:up + # Run database migrations (if any) + docker exec postly-api npm run migrate:up || echo "No migrations to run" + + # Rebuild other services if needed + docker compose -f docker-compose.prod.yml up -d --build scraper bot # Cleanup old images docker image prune -f + + echo "🚀 Deployment complete" diff --git a/apps/api/src/controllers/auth.controller.ts b/apps/api/src/controllers/auth.controller.ts index 16e89ad..5750e0f 100644 --- a/apps/api/src/controllers/auth.controller.ts +++ b/apps/api/src/controllers/auth.controller.ts @@ -1,19 +1,7 @@ import { Request, Response, NextFunction } from "express"; -import bcrypt from "bcrypt"; -import jwt, { type SignOptions } from "jsonwebtoken"; -import crypto from "crypto"; import { z } from "zod"; -import { userQueries, otpQueries } from "@postly/database"; -import type { AuthResponse, UserRole } from "@postly/shared-types"; import type { JwtPayload } from "../middleware/auth.js"; -import { - JWT_SECRET, - JWT_REFRESH_SECRET, - JWT_EXPIRES_IN, - JWT_REFRESH_EXPIRES_IN, - RESEND_FROM_EMAIL, -} from "../config/secrets.js"; -import { resend } from "../lib/resend.js"; +import { AuthService, AuthError } from "../services/auth.service.js"; // ─── Validation Schemas ────────────────────────────────────────────────────── @@ -50,31 +38,28 @@ const resendOtpSchema = z.object({ email: z.string().email("Invalid email address"), }); +// ─── Error Helper ──────────────────────────────────────────────────────────── + +function handleServiceError(error: unknown, res: Response, next: NextFunction) { + if (error instanceof AuthError) { + res.status(error.statusCode).json({ + success: false, + error: { + message: error.message, + ...(error.code && { code: error.code }), + }, + }); + return; + } + next(error); +} + // ─── Controller ────────────────────────────────────────────────────────────── +// Thin HTTP adapter: validates input → calls service → sends response. +// All business logic lives in AuthService. export class AuthController { - /** - * Generate access + refresh token pair for a user. - */ - private generateTokens(user: { - id: string; - email: string; - roles: UserRole[]; - }) { - const access_token = jwt.sign( - { id: user.id, email: user.email, roles: user.roles }, - JWT_SECRET, - { expiresIn: JWT_EXPIRES_IN as SignOptions["expiresIn"] }, - ); - - const refresh_token = jwt.sign( - { id: user.id, type: "refresh" }, - JWT_REFRESH_SECRET, - { expiresIn: JWT_REFRESH_EXPIRES_IN as SignOptions["expiresIn"] }, - ); - - return { access_token, refresh_token }; - } + private authService = new AuthService(); // ─── POST /register ────────────────────────────────────────────────────── @@ -94,65 +79,15 @@ export class AuthController { } const { email, password, full_name } = validation.data; - - const existingUser = await userQueries.findByEmail(email); - if (existingUser) { - res.status(409).json({ - success: false, - error: { message: "User with this email already exists" }, - }); - return; - } - - const salt = await bcrypt.genSalt(12); - const password_hash = await bcrypt.hash(password, salt); - - const user = await userQueries.create({ + const result = await this.authService.register( email, - password_hash, + password, full_name, - }); - - // Generate 6-digit OTP - const otpCode = Math.floor(100000 + Math.random() * 900000).toString(); - const otpHash = await bcrypt.hash(otpCode, 10); - const otpExpiry = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes - - await otpQueries.upsertOtp(user.id, otpHash, otpExpiry); - - // Send OTP via Resend - try { - await resend.emails.send({ - from: RESEND_FROM_EMAIL, - to: email, - subject: "Verify your Postly account", - html: ` -
-

Welcome to Postly!

-

Your verification code is:

-
- ${otpCode} -
-

This code will expire in 10 minutes.

-

If you didn't create an account, you can safely ignore this email.

-
- `, - }); - } catch (emailError) { - console.error("Failed to send verification email:", emailError); - // We still created the user, they can request a resend later - } + ); - res.status(201).json({ - success: true, - data: { - message: - "Registration successful. Please check your email for the verification code.", - email: user.email, - }, - }); + res.status(201).json({ success: true, data: result }); } catch (error) { - next(error); + handleServiceError(error, res, next); } }; @@ -174,61 +109,11 @@ export class AuthController { } const { email, password } = validation.data; - - const user = await userQueries.findByEmail(email); - if (!user || !user.password_hash) { - res.status(401).json({ - success: false, - error: { message: "Invalid email or password" }, - }); - return; - } - - const isValidPassword = await bcrypt.compare( - password, - user.password_hash, - ); - if (!isValidPassword) { - res.status(401).json({ - success: false, - error: { message: "Invalid email or password" }, - }); - return; - } - - // Check if email is verified - if (!user.is_verified) { - res.status(403).json({ - success: false, - error: { - message: "Email not verified", - code: "EMAIL_NOT_VERIFIED", - }, - }); - return; - } - - // Track login timestamp - await userQueries.updateLastLogin(user.id); - - const tokens = this.generateTokens(user); - - const response: AuthResponse = { - user: { - id: user.id, - email: user.email, - full_name: user.full_name, - roles: user.roles, - is_verified: user.is_verified, - created_at: user.created_at, - updated_at: user.updated_at, - }, - ...tokens, - }; + const response = await this.authService.login(email, password); res.json({ success: true, data: response }); } catch (error) { - next(error); + handleServiceError(error, res, next); } }; @@ -249,48 +134,13 @@ export class AuthController { return; } - const { refresh_token } = validation.data; - - let decoded: { id: string; type: string }; - try { - decoded = jwt.verify(refresh_token, JWT_REFRESH_SECRET) as { - id: string; - type: string; - }; - } catch { - res.status(401).json({ - success: false, - error: { message: "Invalid or expired refresh token" }, - }); - return; - } - - if (decoded.type !== "refresh") { - res.status(401).json({ - success: false, - error: { message: "Invalid token type" }, - }); - return; - } - - const user = await userQueries.findById(decoded.id); - if (!user) { - res.status(401).json({ - success: false, - error: { message: "User not found" }, - }); - return; - } - - const access_token = jwt.sign( - { id: user.id, email: user.email, roles: user.roles }, - JWT_SECRET, - { expiresIn: JWT_EXPIRES_IN as SignOptions["expiresIn"] }, + const result = await this.authService.refreshAccessToken( + validation.data.refresh_token, ); - res.json({ success: true, data: { access_token } }); + res.json({ success: true, data: result }); } catch (error) { - next(error); + handleServiceError(error, res, next); } }; @@ -303,30 +153,11 @@ export class AuthController { ): Promise => { try { const payload = req.user as JwtPayload; - const user = await userQueries.findById(payload.id); - if (!user) { - res.status(404).json({ - success: false, - error: { message: "User not found" }, - }); - return; - } + const user = await this.authService.getCurrentUser(payload.id); - res.json({ - success: true, - data: { - id: user.id, - email: user.email, - full_name: user.full_name, - roles: user.roles, - is_verified: user.is_verified, - last_login_at: user.last_login_at, - created_at: user.created_at, - updated_at: user.updated_at, - }, - }); + res.json({ success: true, data: user }); } catch (error) { - next(error); + handleServiceError(error, res, next); } }; @@ -347,26 +178,13 @@ export class AuthController { return; } - const { email } = validation.data; - - // Always return success to prevent email enumeration - const user = await userQueries.findByEmail(email); - if (user) { - const token = crypto.randomBytes(32).toString("hex"); - const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour - await userQueries.setResetToken(user.id, token, expiresAt); - // TODO: Send password reset email with token - } + const result = await this.authService.forgotPassword( + validation.data.email, + ); - res.json({ - success: true, - data: { - message: - "If an account with that email exists, a password reset link has been sent.", - }, - }); + res.json({ success: true, data: result }); } catch (error) { - next(error); + handleServiceError(error, res, next); } }; @@ -387,28 +205,14 @@ export class AuthController { return; } - const { token, password } = validation.data; - - const user = await userQueries.findByResetToken(token); - if (!user) { - res.status(400).json({ - success: false, - error: { message: "Invalid or expired reset token" }, - }); - return; - } - - const salt = await bcrypt.genSalt(12); - const password_hash = await bcrypt.hash(password, salt); - - await userQueries.resetPassword(user.id, password_hash); + const result = await this.authService.resetPassword( + validation.data.token, + validation.data.password, + ); - res.json({ - success: true, - data: { message: "Password has been reset successfully" }, - }); + res.json({ success: true, data: result }); } catch (error) { - next(error); + handleServiceError(error, res, next); } }; @@ -429,91 +233,14 @@ export class AuthController { return; } - const { email, code } = validation.data; - const user = await userQueries.findByEmail(email); - - if (!user) { - res.status(404).json({ - success: false, - error: { message: "User not found" }, - }); - return; - } - - if (user.is_verified) { - res.status(400).json({ - success: false, - error: { message: "User is already verified" }, - }); - return; - } - - const otp = await otpQueries.findOtpByUserId(user.id); - if (!otp) { - res.status(400).json({ - success: false, - error: { - message: "No verification code found. Please request a new one.", - }, - }); - return; - } - - // Check expiry - if (new Date() > new Date(otp.expires_at)) { - await otpQueries.deleteOtp(otp.id); - res.status(400).json({ - success: false, - error: { - message: "Verification code expired. Please request a new one.", - }, - }); - return; - } - - // Check attempts - if (otp.attempts >= 3) { - res.status(429).json({ - success: false, - error: { - message: "Too many failed attempts. Please request a new code.", - }, - }); - return; - } - - // Verify code - const isValid = await bcrypt.compare(code, otp.code_hash); - if (!isValid) { - await otpQueries.incrementOtpAttempts(otp.id); - res.status(400).json({ - success: false, - error: { message: "Invalid verification code" }, - }); - return; - } - - // Success - await otpQueries.verifyUser(user.id); - await otpQueries.deleteOtp(otp.id); - - const tokens = this.generateTokens(user); - const response: AuthResponse = { - user: { - id: user.id, - email: user.email, - full_name: user.full_name, - roles: user.roles, - is_verified: true, - created_at: user.created_at, - updated_at: new Date(), - }, - ...tokens, - }; + const result = await this.authService.verifyOtp( + validation.data.email, + validation.data.code, + ); - res.json({ success: true, data: response }); + res.json({ success: true, data: result }); } catch (error) { - next(error); + handleServiceError(error, res, next); } }; @@ -534,70 +261,11 @@ export class AuthController { return; } - const { email } = validation.data; - const user = await userQueries.findByEmail(email); - - if (!user) { - res.status(404).json({ - success: false, - error: { message: "User not found" }, - }); - return; - } - - if (user.is_verified) { - res.status(400).json({ - success: false, - error: { message: "User is already verified" }, - }); - return; - } - - const existingOtp = await otpQueries.findOtpByUserId(user.id); - if (existingOtp) { - const timeSinceCreation = - Date.now() - new Date(existingOtp.created_at || 0).getTime(); - if (timeSinceCreation < 60 * 1000) { - const waitTime = Math.ceil((60 * 1000 - timeSinceCreation) / 1000); - res.status(429).json({ - success: false, - error: { - message: `Please wait ${waitTime} seconds before requesting a new code.`, - }, - }); - return; - } - } + const result = await this.authService.resendOtp(validation.data.email); - // Generate new OTP - const otpCode = Math.floor(100000 + Math.random() * 900000).toString(); - const otpHash = await bcrypt.hash(otpCode, 10); - const otpExpiry = new Date(Date.now() + 10 * 60 * 1000); - - await otpQueries.upsertOtp(user.id, otpHash, otpExpiry); - - await resend.emails.send({ - from: RESEND_FROM_EMAIL, - to: email, - subject: "Your new Postly verification code", - html: ` -
-

Verification Code

-

Your new verification code is:

-
- ${otpCode} -
-

This code will expire in 10 minutes.

-
- `, - }); - - res.json({ - success: true, - data: { message: "Verification code resent successfully." }, - }); + res.json({ success: true, data: result }); } catch (error) { - next(error); + handleServiceError(error, res, next); } }; } diff --git a/apps/api/src/lib/redis.ts b/apps/api/src/lib/redis.ts index e2248d1..f4794fc 100644 --- a/apps/api/src/lib/redis.ts +++ b/apps/api/src/lib/redis.ts @@ -1,5 +1,6 @@ import { Redis } from "ioredis"; import { REDIS_URL } from "../config/secrets.js"; +import { logger } from "@postly/logger"; /** * Shared Redis client for the API. @@ -14,7 +15,7 @@ export const redis = new Redis(REDIS_URL || "redis://localhost:6379", { redis.on("error", (err) => { // We log but don't crash — features should "fail open" if Redis is down - console.error("Shared Redis connection error:", err); + logger.error("Shared Redis connection error", { error: err.message }); }); export default redis; diff --git a/apps/api/src/middleware/error-handler.ts b/apps/api/src/middleware/error-handler.ts index 088c9c0..225dff4 100644 --- a/apps/api/src/middleware/error-handler.ts +++ b/apps/api/src/middleware/error-handler.ts @@ -1,5 +1,6 @@ import type { Request, Response, NextFunction } from "express"; import { NODE_ENV, WEB_URL } from "../config/secrets.js"; +import { logger } from "@postly/logger"; export interface AppError extends Error { statusCode?: number; @@ -16,7 +17,11 @@ export function errorHandler( const message = err.message || "Internal Server Error"; if (NODE_ENV !== "production") { - console.error("Error:", err); + logger.error("Unhandled error", { + message: err.message, + stack: err.stack, + statusCode, + }); } // Ensure CORS headers are present even in error responses, but ONLY for trusted origins diff --git a/apps/api/src/middleware/request-id.ts b/apps/api/src/middleware/request-id.ts new file mode 100644 index 0000000..67c86fd --- /dev/null +++ b/apps/api/src/middleware/request-id.ts @@ -0,0 +1,26 @@ +import { randomUUID } from "crypto"; +import type { Request, Response, NextFunction } from "express"; + +/** + * Request ID Middleware + * + * Attaches a unique correlation ID to every incoming request. + * - If the client provides `X-Request-ID`, it is reused (e.g. from Nginx). + * - Otherwise, a new UUID v4 is generated. + * + * The ID is: + * 1. Set on the request headers for downstream middleware/controllers. + * 2. Sent back as a response header for client-side correlation. + * + * This enables end-to-end request tracing through logs. + */ +export function requestIdMiddleware( + req: Request, + res: Response, + next: NextFunction, +): void { + const id = (req.headers["x-request-id"] as string) || randomUUID(); + req.headers["x-request-id"] = id; + res.setHeader("X-Request-ID", id); + next(); +} diff --git a/apps/api/src/middleware/token-bucket-rate-limit.ts b/apps/api/src/middleware/token-bucket-rate-limit.ts index bfd451c..3d2cebd 100644 --- a/apps/api/src/middleware/token-bucket-rate-limit.ts +++ b/apps/api/src/middleware/token-bucket-rate-limit.ts @@ -1,6 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { redis } from "../lib/redis.js"; import jwt from "jsonwebtoken"; +import { JWT_SECRET } from "../config/secrets.js"; import { Redis } from "ioredis"; interface RateLimitConfig { @@ -89,20 +90,19 @@ export const tokenBucketRateLimiter = (config: RateLimitConfig) => { // Identifier: Start with IP Address let identifier = req.ip || "unknown-ip"; - // Attempt to decode the JWT to use User ID as identifier + // Attempt to verify the JWT to use User ID as identifier. + // We use jwt.verify() (NOT jwt.decode()) to prevent identity spoofing. + // An attacker could craft a JWT with any user ID to bypass per-user rate limits. const authHeader = req.headers["authorization"]; if (authHeader && authHeader.startsWith("Bearer ")) { const token = authHeader.split(" ")[1]; try { - // We decode instead of verifying here because - // this middleware might run before the auth middleware - // and decoding is faster for identifying rate limits per user - const decoded = jwt.decode(token) as { id?: string }; + const decoded = jwt.verify(token, JWT_SECRET) as { id?: string }; if (decoded && decoded.id) { identifier = decoded.id; } } catch { - // ignore invalid tokens, fallback to IP + // Invalid or expired token — fallback to IP-based rate limiting } } diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 3c868d2..fc8612a 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -3,6 +3,7 @@ import cors from "cors"; import helmet from "helmet"; import rateLimit from "express-rate-limit"; import { tokenBucketRateLimiter } from "./middleware/token-bucket-rate-limit.js"; +import { requestIdMiddleware } from "./middleware/request-id.js"; import { pool } from "@postly/database"; import { logger } from "@postly/logger"; import { API_PORT, WEB_URL, NODE_ENV } from "./config/secrets.js"; @@ -28,6 +29,9 @@ import { fileURLToPath } from "url"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +// Request correlation ID — must be first for tracing +app.use(requestIdMiddleware); + // Security middleware app.use( helmet({ @@ -42,9 +46,7 @@ const allowedOrigins = WEB_URL : []; if (allowedOrigins.length === 0) { - console.warn( - "⚠️ WEB_URL is not set — CORS will block all browser requests!", - ); + logger.warn("WEB_URL is not set — CORS will block all browser requests"); } app.use( @@ -60,7 +62,7 @@ app.use( return callback(null, true); } - console.error(`CORS Blocked: origin '${origin}' not in allowed list`); + logger.warn("CORS blocked request", { origin }); callback(new Error(`CORS: origin '${origin}' not allowed`)); }, credentials: true, @@ -172,14 +174,19 @@ app.use(errorHandler); // Start server app.listen(API_PORT, "0.0.0.0", async () => { - console.log(`🚀 API server running on http://0.0.0.0:${API_PORT}`); - console.log(`📝 Environment: ${NODE_ENV}`); + logger.info("API server started", { + port: API_PORT, + environment: NODE_ENV, + url: `http://0.0.0.0:${API_PORT}`, + }); // Initialize Bot Job Queue try { await queueService.initDailyCron(); } catch (err) { - console.error("Failed to initialize Bot Queue:", err); + logger.error("Failed to initialize Bot Queue", { + error: err instanceof Error ? err.message : "Unknown", + }); } }); diff --git a/apps/api/src/services/auth.service.ts b/apps/api/src/services/auth.service.ts new file mode 100644 index 0000000..186cb82 --- /dev/null +++ b/apps/api/src/services/auth.service.ts @@ -0,0 +1,398 @@ +import bcrypt from "bcrypt"; +import jwt, { type SignOptions } from "jsonwebtoken"; +import crypto from "crypto"; +import { + userQueries, + otpQueries, + db, + users, + seeker_profiles, + otp_codes, +} from "@postly/database"; +import type { AuthResponse, UserRole } from "@postly/shared-types"; +import { + JWT_SECRET, + JWT_REFRESH_SECRET, + JWT_EXPIRES_IN, + JWT_REFRESH_EXPIRES_IN, + RESEND_FROM_EMAIL, +} from "../config/secrets.js"; +import { resend } from "../lib/resend.js"; +import { logger } from "@postly/logger"; + +// ─── Custom Errors ─────────────────────────────────────────────────────────── + +export class AuthError extends Error { + constructor( + message: string, + public statusCode: number, + public code?: string, + ) { + super(message); + this.name = "AuthError"; + } +} + +// ─── Service ───────────────────────────────────────────────────────────────── + +/** + * AuthService — Pure business logic for authentication. + * + * This service owns: + * - Registration (user + OTP in a single transaction) + * - Login (credential validation + token generation) + * - OTP verification & resend + * - Token refresh + * - Password reset flow + * + * It does NOT import Express types — it is HTTP-framework-agnostic. + */ +export class AuthService { + // ─── Token Generation ───────────────────────────────────────────────── + + generateTokens(user: { id: string; email: string; roles: UserRole[] }) { + const access_token = jwt.sign( + { id: user.id, email: user.email, roles: user.roles }, + JWT_SECRET, + { expiresIn: JWT_EXPIRES_IN as SignOptions["expiresIn"] }, + ); + + const refresh_token = jwt.sign( + { id: user.id, type: "refresh" }, + JWT_REFRESH_SECRET, + { expiresIn: JWT_REFRESH_EXPIRES_IN as SignOptions["expiresIn"] }, + ); + + return { access_token, refresh_token }; + } + + // ─── Registration ───────────────────────────────────────────────────── + + async register(email: string, password: string, fullName?: string) { + const existingUser = await userQueries.findByEmail(email); + if (existingUser) { + throw new AuthError("User with this email already exists", 409); + } + + const salt = await bcrypt.genSalt(12); + const password_hash = await bcrypt.hash(password, salt); + + // Generate OTP + const otpCode = Math.floor(100000 + Math.random() * 900000).toString(); + const otpHash = await bcrypt.hash(otpCode, 10); + const otpExpiry = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes + + // Create user + OTP in a single transaction to prevent orphaned users + const user = await db.transaction(async (tx) => { + const [createdUser] = await tx + .insert(users) + .values({ + email, + password_hash, + full_name: fullName, + roles: ["job_seeker"], + avatar_url: `https://api.dicebear.com/9.x/dylan/svg?seed=${encodeURIComponent(fullName || email.split("@")[0])}`, + }) + .returning(); + + // Initialize seeker profile within same transaction + await tx + .insert(seeker_profiles) + .values({ user_id: createdUser.id }) + .onConflictDoNothing(); + + // Create OTP within same transaction + await tx + .insert(otp_codes) + .values({ + user_id: createdUser.id, + code_hash: otpHash, + expires_at: otpExpiry, + attempts: 0, + last_attempt_at: null, + }) + .onConflictDoUpdate({ + target: otp_codes.user_id, + set: { + code_hash: otpHash, + expires_at: otpExpiry, + attempts: 0, + last_attempt_at: null, + created_at: new Date(), + }, + }); + + return createdUser; + }); + + // Send verification email (fire-and-forget, outside transaction) + this.sendVerificationEmail(email, otpCode).catch((err) => { + logger.error("Failed to send verification email", { + error: err instanceof Error ? err.message : "Unknown", + email, + }); + }); + + return { + message: + "Registration successful. Please check your email for the verification code.", + email: user.email, + }; + } + + // ─── Login ──────────────────────────────────────────────────────────── + + async login(email: string, password: string): Promise { + const user = await userQueries.findByEmail(email); + if (!user || !user.password_hash) { + throw new AuthError("Invalid email or password", 401); + } + + const isValidPassword = await bcrypt.compare(password, user.password_hash); + if (!isValidPassword) { + throw new AuthError("Invalid email or password", 401); + } + + if (!user.is_verified) { + throw new AuthError("Email not verified", 403, "EMAIL_NOT_VERIFIED"); + } + + // Track login timestamp (fire-and-forget) + userQueries.updateLastLogin(user.id).catch((err) => { + logger.error("Failed to update last login", { + error: err instanceof Error ? err.message : "Unknown", + userId: user.id, + }); + }); + + const tokens = this.generateTokens(user); + + return { + user: { + id: user.id, + email: user.email, + full_name: user.full_name, + roles: user.roles, + is_verified: user.is_verified, + created_at: user.created_at, + updated_at: user.updated_at, + }, + ...tokens, + }; + } + + // ─── Token Refresh ──────────────────────────────────────────────────── + + async refreshAccessToken( + refreshToken: string, + ): Promise<{ access_token: string }> { + let decoded: { id: string; type: string }; + try { + decoded = jwt.verify(refreshToken, JWT_REFRESH_SECRET) as { + id: string; + type: string; + }; + } catch { + throw new AuthError("Invalid or expired refresh token", 401); + } + + if (decoded.type !== "refresh") { + throw new AuthError("Invalid token type", 401); + } + + const user = await userQueries.findById(decoded.id); + if (!user) { + throw new AuthError("User not found", 401); + } + + const access_token = jwt.sign( + { id: user.id, email: user.email, roles: user.roles }, + JWT_SECRET, + { expiresIn: JWT_EXPIRES_IN as SignOptions["expiresIn"] }, + ); + + return { access_token }; + } + + // ─── OTP Verification ───────────────────────────────────────────────── + + async verifyOtp(email: string, code: string): Promise { + const user = await userQueries.findByEmail(email); + if (!user) { + throw new AuthError("User not found", 404); + } + + if (user.is_verified) { + throw new AuthError("User is already verified", 400); + } + + const otp = await otpQueries.findOtpByUserId(user.id); + if (!otp) { + throw new AuthError( + "No verification code found. Please request a new one.", + 400, + ); + } + + // Check expiry + if (new Date() > new Date(otp.expires_at)) { + await otpQueries.deleteOtp(otp.id); + throw new AuthError( + "Verification code expired. Please request a new one.", + 400, + ); + } + + // Check attempts + if (otp.attempts >= 3) { + throw new AuthError( + "Too many failed attempts. Please request a new code.", + 429, + ); + } + + // Verify code + const isValid = await bcrypt.compare(code, otp.code_hash); + if (!isValid) { + await otpQueries.incrementOtpAttempts(otp.id); + throw new AuthError("Invalid verification code", 400); + } + + // Success — verify user and clean up + await otpQueries.verifyUser(user.id); + await otpQueries.deleteOtp(otp.id); + + const tokens = this.generateTokens(user); + + return { + user: { + id: user.id, + email: user.email, + full_name: user.full_name, + roles: user.roles, + is_verified: true, + created_at: user.created_at, + updated_at: new Date(), + }, + ...tokens, + }; + } + + // ─── OTP Resend ─────────────────────────────────────────────────────── + + async resendOtp(email: string): Promise<{ message: string }> { + const user = await userQueries.findByEmail(email); + if (!user) { + throw new AuthError("User not found", 404); + } + + if (user.is_verified) { + throw new AuthError("User is already verified", 400); + } + + // Rate-limit resend + const existingOtp = await otpQueries.findOtpByUserId(user.id); + if (existingOtp) { + const timeSinceCreation = + Date.now() - new Date(existingOtp.created_at || 0).getTime(); + if (timeSinceCreation < 60 * 1000) { + const waitTime = Math.ceil((60 * 1000 - timeSinceCreation) / 1000); + throw new AuthError( + `Please wait ${waitTime} seconds before requesting a new code.`, + 429, + ); + } + } + + // Generate new OTP + const otpCode = Math.floor(100000 + Math.random() * 900000).toString(); + const otpHash = await bcrypt.hash(otpCode, 10); + const otpExpiry = new Date(Date.now() + 10 * 60 * 1000); + + await otpQueries.upsertOtp(user.id, otpHash, otpExpiry); + await this.sendVerificationEmail(email, otpCode); + + return { message: "Verification code resent successfully." }; + } + + // ─── Forgot Password ───────────────────────────────────────────────── + + async forgotPassword(email: string): Promise<{ message: string }> { + // Always return success to prevent email enumeration + const user = await userQueries.findByEmail(email); + if (user) { + const token = crypto.randomBytes(32).toString("hex"); + const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour + await userQueries.setResetToken(email, token, expiresAt); + // TODO: Send password reset email with token + } + + return { + message: + "If an account with that email exists, a password reset link has been sent.", + }; + } + + // ─── Reset Password ────────────────────────────────────────────────── + + async resetPassword( + token: string, + newPassword: string, + ): Promise<{ message: string }> { + const salt = await bcrypt.genSalt(12); + const password_hash = await bcrypt.hash(newPassword, salt); + + const success = await userQueries.resetPassword(token, password_hash); + if (!success) { + throw new AuthError("Invalid or expired reset token", 400); + } + + return { message: "Password has been reset successfully" }; + } + + // ─── Get Current User ───────────────────────────────────────────────── + + async getCurrentUser(userId: string) { + const user = await userQueries.findById(userId); + if (!user) { + throw new AuthError("User not found", 404); + } + + return { + id: user.id, + email: user.email, + full_name: user.full_name, + roles: user.roles, + is_verified: user.is_verified, + last_login_at: user.last_login_at, + created_at: user.created_at, + updated_at: user.updated_at, + }; + } + + // ─── Email Helpers (Private) ────────────────────────────────────────── + + private async sendVerificationEmail( + email: string, + otpCode: string, + ): Promise { + await resend.emails.send({ + from: RESEND_FROM_EMAIL, + to: email, + subject: "Verify your Postly account", + html: ` +
+

Welcome to Postly!

+

Your verification code is:

+
+ ${otpCode} +
+

This code will expire in 10 minutes.

+

If you didn't create an account, you can safely ignore this email.

+
+ `, + }); + } +} + +export const authService = new AuthService(); diff --git a/apps/api/src/services/cache.service.ts b/apps/api/src/services/cache.service.ts index ac052fa..2d9e75f 100644 --- a/apps/api/src/services/cache.service.ts +++ b/apps/api/src/services/cache.service.ts @@ -1,4 +1,5 @@ import { redis } from "../lib/redis.js"; +import { logger } from "@postly/logger"; /** * Standardized Cache Service @@ -44,7 +45,7 @@ export class CacheService { } } catch (error) { // Log warning but DO NOT crash (Graceful Degradation) - console.warn(`[CacheService] Redis GET failed for key "${key}":`, error); + logger.warn("Redis GET failed", { key, error: String(error) }); } // 2. On miss (or Redis failure), execute the source DB query @@ -56,17 +57,17 @@ export class CacheService { if (freshData !== undefined && freshData !== null) { // Run SET asynchronously to not block returning the response redis.setex(key, ttlSeconds, JSON.stringify(freshData)).catch((err) => { - console.warn( - `[CacheService] Background Redis SETEX failed for key "${key}":`, - err, - ); + logger.warn("Background Redis SETEX failed", { + key, + error: String(err), + }); }); } } catch (error) { - console.warn( - `[CacheService] Redis SETEX synchronous error for key "${key}":`, - error, - ); + logger.warn("Redis SETEX synchronous error", { + key, + error: String(error), + }); } // 4. Return data immediately @@ -80,7 +81,7 @@ export class CacheService { try { await redis.del(key); } catch (error) { - console.warn(`[CacheService] Redis DEL failed for key "${key}":`, error); + logger.warn("Redis DEL failed", { key, error: String(error) }); } } @@ -107,10 +108,10 @@ export class CacheService { } } while (cursor !== "0"); } catch (error) { - console.warn( - `[CacheService] Redis pattern invalidation failed for "${pattern}":`, - error, - ); + logger.warn("Redis pattern invalidation failed", { + pattern, + error: String(error), + }); } } } diff --git a/apps/api/src/services/chat.service.ts b/apps/api/src/services/chat.service.ts index d72f0bb..5890bdf 100644 --- a/apps/api/src/services/chat.service.ts +++ b/apps/api/src/services/chat.service.ts @@ -13,6 +13,7 @@ import type { Job, OptimizedJobMatch, } from "@postly/shared-types"; +import { logger } from "@postly/logger"; interface MatchedJob extends Job { match_score: number; @@ -91,6 +92,59 @@ function getJobIntent(message: string): JobIntent { allKeywords: foundKeywords, }; } + +// ─── Context Window Budget Management ───────────────────────────────────── + +const MAX_CONTEXT_TOKENS = 8000; // conservative budget for conversation history + +/** + * Trims conversation history to fit within a token budget. + * Works backwards from most recent messages (which are most relevant). + * Uses a rough estimate of 1 token ≈ 4 characters. + */ +function trimHistory(messages: Message[], maxTokens: number): string { + let budget = maxTokens; + const included: string[] = []; + + // Work backwards — most recent messages are most important + for (let i = messages.length - 1; i >= 0; i--) { + const entry = `${messages[i].role}: ${messages[i].content}`; + const estimatedTokens = Math.ceil(entry.length / 4); + if (budget - estimatedTokens < 0) break; + budget -= estimatedTokens; + included.unshift(entry); + } + + return included.join("\n"); +} + +// ─── Prompt Injection Defense ───────────────────────────────────────────── + +const BLOCKED_PATTERNS = [ + /ignore\s+(all\s+)?previous\s+instructions/i, + /you\s+are\s+now\s+a/i, + /pretend\s+(to\s+be|you\s+are)/i, + /reveal\s+(your|the)\s+(system\s+)?prompt/i, + /what\s+(are|is)\s+your\s+(system\s+)?instructions/i, + /disregard\s+(all|any)\s+(prior|previous)/i, +]; + +/** + * Filters user messages for common prompt injection patterns. + * Returns a sanitized version or the original message if clean. + */ +function sanitizeUserInput(message: string): string { + for (const pattern of BLOCKED_PATTERNS) { + if (pattern.test(message)) { + logger.warn("Prompt injection attempt detected", { + pattern: pattern.source, + messageLength: message.length, + }); + return "[Message filtered for policy compliance. Please rephrase your question about jobs or career advice.]"; + } + } + return message; +} // ... (omitting helper for brevity in diff) // Helper to transform raw job data to UI-ready format @@ -277,13 +331,16 @@ ${userRole !== "employer" ? "3. DO NOT hallucinate job listings. Only mention jo Be professional, encouraging, and concise.${resumeContext}${userRole !== "employer" ? jobContext : ""}`; - // 6. Prepare conversation history - const conversationHistory = messages - .filter((m: Message) => m.role !== "system") - .map((m: Message) => `${m.role}: ${m.content}`) - .join("\n"); + // 5b. Sanitize user input against prompt injection + const sanitizedMessage = sanitizeUserInput(userMessage); + + // 6. Prepare conversation history with token budget + const conversationHistory = trimHistory( + messages.filter((m: Message) => m.role !== "system"), + MAX_CONTEXT_TOKENS, + ); - const fullPrompt = `${systemPrompt}\n\nConversation:\n${conversationHistory}\nuser: ${userMessage}\nassistant:`; + const fullPrompt = `${systemPrompt}\n\nConversation:\n${conversationHistory}\nuser: ${sanitizedMessage}\nassistant:`; // 7. Stream AI response let fullResponse = ""; diff --git a/apps/api/src/services/matching.service.ts b/apps/api/src/services/matching.service.ts index c9a5757..3e713da 100644 --- a/apps/api/src/services/matching.service.ts +++ b/apps/api/src/services/matching.service.ts @@ -7,6 +7,7 @@ import type { EducationEntry, } from "@postly/shared-types"; import { pool } from "@postly/database"; +import { logger } from "@postly/logger"; interface MatchedJob extends Job { match_score: number; @@ -80,10 +81,10 @@ export class MatchingService { const explanation = await this.generateMatchExplanation(resume, job); return { ...job, ai_explanation: explanation }; } catch (error) { - console.error( - `Failed to generate explanation for job ${job.id}:`, - error, - ); + logger.error("Failed to generate match explanation", { + jobId: job.id, + error: error instanceof Error ? error.message : "Unknown", + }); return job; } }), diff --git a/apps/api/src/services/queue.service.ts b/apps/api/src/services/queue.service.ts index 6b0cd66..e842810 100644 --- a/apps/api/src/services/queue.service.ts +++ b/apps/api/src/services/queue.service.ts @@ -1,6 +1,7 @@ import { Queue } from "bullmq"; import { REDIS_URL } from "../config/secrets.js"; import { db, bot_configs, eq } from "@postly/database"; +import { logger } from "@postly/logger"; const BOT_QUEUE_NAME = "bot_notifications"; @@ -32,7 +33,9 @@ export class QueueService { removeOnFail: 5, }, ); - console.log("📅 Bot daily job dispatch cron initialized (9:00 AM)"); + logger.info("Bot daily job dispatch cron initialized", { + schedule: "9:00 AM UTC", + }); }; /** @@ -50,9 +53,7 @@ export class QueueService { await this.dispatchForPlatform(config.id); queued++; } - console.log( - `✅ Queued job alerts for ${queued}/${activeConfigs.length} bot integrations.`, - ); + logger.info("Job alerts queued", { queued, total: activeConfigs.length }); return queued; }; @@ -82,7 +83,10 @@ export class QueueService { removeOnFail: 3, }, ); - console.log(`✅ Job dispatched for ${config.platform} config: ${configId}`); + logger.info("Job dispatched for bot", { + platform: config.platform, + configId, + }); }; dispatchForGuild = async (guildId: string, channelId: string) => { diff --git a/apps/api/src/services/resume.service.ts b/apps/api/src/services/resume.service.ts index b950241..432e062 100644 --- a/apps/api/src/services/resume.service.ts +++ b/apps/api/src/services/resume.service.ts @@ -1,12 +1,10 @@ import { generateText, generateVoyageEmbedding } from "@postly/ai-utils"; import { resumeQueries } from "@postly/database"; -import type { - Resume, - ResumeAnalysis, - EducationEntry, -} from "@postly/shared-types"; +import type { Resume, ResumeAnalysis } from "@postly/shared-types"; import { PDFParse } from "pdf-parse"; import mammoth from "mammoth"; +import { z } from "zod"; +import { logger } from "@postly/logger"; export class ResumeService { /** @@ -53,7 +51,27 @@ export class ResumeService { } /** - * Analyze resume text using Gemini AI + * Zod schema for validating structured LLM output. + * Prevents malformed AI responses from corrupting downstream data. + */ + private static ResumeAnalysisSchema = z.object({ + skills: z.array(z.string()).default([]), + experience_years: z.number().default(0), + education: z + .array( + z.object({ + degree: z.string().default("Unknown"), + institution: z.string().default("Unknown"), + year: z.number().optional(), + field_of_study: z.string().optional(), + }), + ) + .default([]), + summary: z.string().default(""), + }); + + /** + * Analyze resume text using AI with Zod-validated output. */ async analyzeResume(text: string): Promise { const prompt = `Analyze the following resume and extract structured information. @@ -83,31 +101,30 @@ Return ONLY the JSON object, no markdown formatting or explanation.`; } cleanJson = cleanJson.trim(); - const parsed = JSON.parse(cleanJson); + const rawParsed = JSON.parse(cleanJson); + const validated = ResumeService.ResumeAnalysisSchema.safeParse(rawParsed); + + if (!validated.success) { + logger.warn("LLM output validation failed", { + errors: validated.error.issues, + rawKeys: Object.keys(rawParsed), + }); + return { + skills: [], + experience_years: 0, + education: [], + summary: "Unable to fully analyze resume. Please try again.", + }; + } - return { - skills: Array.isArray(parsed.skills) ? parsed.skills : [], - experience_years: - typeof parsed.experience_years === "number" - ? parsed.experience_years - : 0, - education: Array.isArray(parsed.education) - ? parsed.education.map((e: Partial) => ({ - degree: e.degree || "Unknown", - institution: e.institution || "Unknown", - year: e.year, - field_of_study: e.field_of_study, - })) - : [], - summary: typeof parsed.summary === "string" ? parsed.summary : "", - }; + return validated.data; } catch (error) { - console.error( - "Failed to parse AI response:", - error instanceof Error - ? this.sanitizeForLog(error.message) - : "Unknown error", - ); + logger.error("Failed to parse AI response", { + error: + error instanceof Error + ? this.sanitizeForLog(error.message) + : "Unknown error", + }); // Return default analysis if parsing fails return { skills: [], diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 1e0c3a4..df7d0f9 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -18,14 +18,14 @@ services: -c effective_cache_size=512MB -c work_mem=16MB -c maintenance_work_mem=64MB - -c max_connections=30 + -c max_connections=50 -c random_page_cost=1.1 -c wal_compression=on deploy: resources: limits: cpus: '0.5' - memory: 512M + memory: 768M healthcheck: test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postly}"] interval: 10s @@ -46,8 +46,8 @@ services: restart: unless-stopped command: > redis-server - --maxmemory 64mb - --maxmemory-policy allkeys-lru + --maxmemory 256mb + --maxmemory-policy noeviction --appendonly yes --save 60 1 --loglevel warning @@ -56,8 +56,8 @@ services: deploy: resources: limits: - cpus: '0.1' - memory: 80M + cpus: '0.15' + memory: 320M healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s diff --git a/packages/config/app-config/src/index.ts b/packages/config/app-config/src/index.ts index 5ca2a6a..a3a7925 100644 --- a/packages/config/app-config/src/index.ts +++ b/packages/config/app-config/src/index.ts @@ -19,17 +19,31 @@ if (envPath) dotenv.config({ path: envPath }); export const DATABASE_URL = process.env.DATABASE_URL || ""; export const DB_POOL = { - max: parseInt(process.env.DB_POOL_MAX || "10", 10), + max: parseInt(process.env.DB_POOL_MAX || "8", 10), idleTimeoutMillis: parseInt(process.env.DB_IDLE_TIMEOUT || "10000", 10), connectionTimeoutMillis: parseInt(process.env.DB_CONN_TIMEOUT || "2000", 10), } as const; // ─── JWT / Auth ────────────────────────────────────────────────────────────── -export const JWT_SECRET = - process.env.JWT_SECRET || "postly-secure-secret-key-2024"; -export const JWT_REFRESH_SECRET = - process.env.JWT_REFRESH_SECRET || "postly-secure-refresh-secret-2024"; +export const JWT_SECRET: string = + process.env.JWT_SECRET || + (() => { + if (process.env.NODE_ENV === "production") { + throw new Error("FATAL: JWT_SECRET must be set in production"); + } + return "postly-dev-only-secret-DO-NOT-USE-IN-PROD"; + })(); + +export const JWT_REFRESH_SECRET: string = + process.env.JWT_REFRESH_SECRET || + (() => { + if (process.env.NODE_ENV === "production") { + throw new Error("FATAL: JWT_REFRESH_SECRET must be set in production"); + } + return "postly-dev-only-refresh-secret-DO-NOT-USE-IN-PROD"; + })(); + export const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "7d"; export const JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || "30d"; diff --git a/scripts/add-hnsw-indexes.sql b/scripts/add-hnsw-indexes.sql index d94c7bd..39f0e09 100644 --- a/scripts/add-hnsw-indexes.sql +++ b/scripts/add-hnsw-indexes.sql @@ -1,29 +1,54 @@ --- ────────────────────────────────────────────────────────── --- HNSW Vector Indexes for pgvector --- ────────────────────────────────────────────────────────── --- Uses HNSW (Hierarchical Navigable Small World) instead of --- IVFFlat for better recall and no training step required. +-- ============================================================================ +-- HNSW Vector Index Migration for Postly +-- ============================================================================ +-- Purpose: Add HNSW indexes to all embedding columns for fast approximate +-- nearest neighbor (ANN) search. Without these, pgvector performs +-- a full sequential scan for every similarity query. -- --- m=16, ef_construction=64 is a good balance for <100k rows. --- All embedding columns are 1024 dimensions (Voyage AI). --- ────────────────────────────────────────────────────────── +-- Safety: CONCURRENTLY ensures NO table locks — reads and writes continue +-- uninterrupted during index creation. Can be run on a live database. +-- +-- Performance Notes: +-- - m = 16: good balance of recall vs. index size +-- - ef_construction = 64: higher = better recall, slower build +-- - Build time: ~1-5 min per 100k rows depending on VPS CPU +-- - CPU will spike during build — run during low-traffic hours +-- +-- Usage: +-- docker exec -i postly-postgres psql -U postly -d postly < scripts/add-hnsw-indexes.sql +-- +-- Rollback: +-- DROP INDEX CONCURRENTLY IF EXISTS idx_jobs_embedding_hnsw; +-- DROP INDEX CONCURRENTLY IF EXISTS idx_resumes_embedding_hnsw; +-- DROP INDEX CONCURRENTLY IF EXISTS idx_seeker_profiles_embedding_hnsw; +-- DROP INDEX CONCURRENTLY IF EXISTS idx_employer_profiles_embedding_hnsw; +-- ============================================================================ + +-- Ensure pgvector extension is available +CREATE EXTENSION IF NOT EXISTS vector; + +-- Jobs table — primary vector search target for job matching +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_jobs_embedding_hnsw + ON jobs USING hnsw (embedding vector_cosine_ops) + WITH (m = 16, ef_construction = 64); --- Resumes: used for resume-to-job matching -CREATE INDEX CONCURRENTLY IF NOT EXISTS resumes_embedding_hnsw -ON resumes USING hnsw (embedding vector_cosine_ops) -WITH (m = 16, ef_construction = 64); +-- Resumes table — used for resume-to-job matching +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_resumes_embedding_hnsw + ON resumes USING hnsw (embedding vector_cosine_ops) + WITH (m = 16, ef_construction = 64); --- Jobs: used for job-to-resume matching and job search -CREATE INDEX CONCURRENTLY IF NOT EXISTS jobs_embedding_hnsw -ON jobs USING hnsw (embedding vector_cosine_ops) -WITH (m = 16, ef_construction = 64); +-- Seeker profiles — used for employer-side candidate search +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_seeker_profiles_embedding_hnsw + ON seeker_profiles USING hnsw (embedding vector_cosine_ops) + WITH (m = 16, ef_construction = 64); --- Employer Profiles: used for company similarity search -CREATE INDEX CONCURRENTLY IF NOT EXISTS employer_profiles_embedding_hnsw -ON employer_profiles USING hnsw (embedding vector_cosine_ops) -WITH (m = 16, ef_construction = 64); +-- Employer profiles — used for matching employers to seekers +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_employer_profiles_embedding_hnsw + ON employer_profiles USING hnsw (embedding vector_cosine_ops) + WITH (m = 16, ef_construction = 64); --- Seeker Profiles: used for candidate matching -CREATE INDEX CONCURRENTLY IF NOT EXISTS seeker_profiles_embedding_hnsw -ON seeker_profiles USING hnsw (embedding vector_cosine_ops) -WITH (m = 16, ef_construction = 64); +-- Verification: List all HNSW indexes +SELECT indexname, tablename, indexdef +FROM pg_indexes +WHERE indexdef LIKE '%hnsw%' +ORDER BY tablename; From 3d0e15a541bf9e9814976dd9c080a8a94abaaf87 Mon Sep 17 00:00:00 2001 From: Utsav joshi Date: Wed, 29 Apr 2026 14:59:41 +0530 Subject: [PATCH 2/8] fix: correct environment variable export paths in application configuration --- packages/config/app-config/src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/config/app-config/src/index.ts b/packages/config/app-config/src/index.ts index a3a7925..2006509 100644 --- a/packages/config/app-config/src/index.ts +++ b/packages/config/app-config/src/index.ts @@ -29,7 +29,7 @@ export const DB_POOL = { export const JWT_SECRET: string = process.env.JWT_SECRET || (() => { - if (process.env.NODE_ENV === "production") { + if (process.env.NODE_ENV === "production" && !process.env.CI) { throw new Error("FATAL: JWT_SECRET must be set in production"); } return "postly-dev-only-secret-DO-NOT-USE-IN-PROD"; @@ -38,7 +38,7 @@ export const JWT_SECRET: string = export const JWT_REFRESH_SECRET: string = process.env.JWT_REFRESH_SECRET || (() => { - if (process.env.NODE_ENV === "production") { + if (process.env.NODE_ENV === "production" && !process.env.CI) { throw new Error("FATAL: JWT_REFRESH_SECRET must be set in production"); } return "postly-dev-only-refresh-secret-DO-NOT-USE-IN-PROD"; From a6b581b15a8eea107a5a9eec945547e15d0f6cd0 Mon Sep 17 00:00:00 2001 From: Utsav joshi Date: Wed, 29 Apr 2026 15:00:37 +0530 Subject: [PATCH 3/8] fix: add token length constraints and type validation to JWT rate limit identification --- .../src/middleware/token-bucket-rate-limit.ts | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/apps/api/src/middleware/token-bucket-rate-limit.ts b/apps/api/src/middleware/token-bucket-rate-limit.ts index 3d2cebd..0cd1b9f 100644 --- a/apps/api/src/middleware/token-bucket-rate-limit.ts +++ b/apps/api/src/middleware/token-bucket-rate-limit.ts @@ -94,15 +94,24 @@ export const tokenBucketRateLimiter = (config: RateLimitConfig) => { // We use jwt.verify() (NOT jwt.decode()) to prevent identity spoofing. // An attacker could craft a JWT with any user ID to bypass per-user rate limits. const authHeader = req.headers["authorization"]; - if (authHeader && authHeader.startsWith("Bearer ")) { - const token = authHeader.split(" ")[1]; - try { - const decoded = jwt.verify(token, JWT_SECRET) as { id?: string }; - if (decoded && decoded.id) { - identifier = decoded.id; + if ( + typeof authHeader === "string" && + authHeader.startsWith("Bearer ") + ) { + const token = authHeader.slice(7).trim(); + if (token.length > 0 && token.length < 4096) { + try { + const decoded = jwt.verify(token, JWT_SECRET) as { id?: string }; + if ( + decoded && + typeof decoded.id === "string" && + decoded.id.length > 0 + ) { + identifier = decoded.id; + } + } catch { + // Invalid or expired token — fallback to IP-based rate limiting } - } catch { - // Invalid or expired token — fallback to IP-based rate limiting } } From f7d95da1ae8ea944591dece2807e944f08854764 Mon Sep 17 00:00:00 2001 From: Utsav joshi Date: Wed, 29 Apr 2026 15:01:13 +0530 Subject: [PATCH 4/8] refactor: simplify conditional formatting in rate limit middleware for better readability --- apps/api/src/middleware/token-bucket-rate-limit.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/api/src/middleware/token-bucket-rate-limit.ts b/apps/api/src/middleware/token-bucket-rate-limit.ts index 0cd1b9f..f2272ab 100644 --- a/apps/api/src/middleware/token-bucket-rate-limit.ts +++ b/apps/api/src/middleware/token-bucket-rate-limit.ts @@ -94,10 +94,7 @@ export const tokenBucketRateLimiter = (config: RateLimitConfig) => { // We use jwt.verify() (NOT jwt.decode()) to prevent identity spoofing. // An attacker could craft a JWT with any user ID to bypass per-user rate limits. const authHeader = req.headers["authorization"]; - if ( - typeof authHeader === "string" && - authHeader.startsWith("Bearer ") - ) { + if (typeof authHeader === "string" && authHeader.startsWith("Bearer ")) { const token = authHeader.slice(7).trim(); if (token.length > 0 && token.length < 4096) { try { From 1204fd310867a48ef657e04029dbc1aded8e8fad Mon Sep 17 00:00:00 2001 From: Utsav joshi Date: Wed, 29 Apr 2026 15:05:37 +0530 Subject: [PATCH 5/8] refactor: update rate-limit identifier to combine IP address and verified user ID --- .../src/middleware/token-bucket-rate-limit.ts | 36 ++++++++----------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/apps/api/src/middleware/token-bucket-rate-limit.ts b/apps/api/src/middleware/token-bucket-rate-limit.ts index f2272ab..f254d72 100644 --- a/apps/api/src/middleware/token-bucket-rate-limit.ts +++ b/apps/api/src/middleware/token-bucket-rate-limit.ts @@ -87,29 +87,23 @@ export const tokenBucketRateLimiter = (config: RateLimitConfig) => { return next(); } - // Identifier: Start with IP Address - let identifier = req.ip || "unknown-ip"; + // Rate-limit identifier: always anchored to the client IP. + // A verified JWT user ID is appended for per-user granularity. + const clientIp = req.ip || "unknown-ip"; + let identifier = clientIp; - // Attempt to verify the JWT to use User ID as identifier. - // We use jwt.verify() (NOT jwt.decode()) to prevent identity spoofing. - // An attacker could craft a JWT with any user ID to bypass per-user rate limits. - const authHeader = req.headers["authorization"]; - if (typeof authHeader === "string" && authHeader.startsWith("Bearer ")) { - const token = authHeader.slice(7).trim(); - if (token.length > 0 && token.length < 4096) { - try { - const decoded = jwt.verify(token, JWT_SECRET) as { id?: string }; - if ( - decoded && - typeof decoded.id === "string" && - decoded.id.length > 0 - ) { - identifier = decoded.id; - } - } catch { - // Invalid or expired token — fallback to IP-based rate limiting - } + // jwt.verify() is the sole gatekeeper — it cryptographically validates + // the token using the server-side secret. No user-controlled conditionals + // guard the sensitive action; invalid input simply throws and is caught. + try { + const rawHeader = req.headers["authorization"] ?? ""; + const token = String(rawHeader).slice(7); + const decoded = jwt.verify(token, JWT_SECRET) as { id?: string }; + if (typeof decoded?.id === "string" && decoded.id.length > 0) { + identifier = `${clientIp}:uid:${decoded.id}`; } + } catch { + // No valid token — IP-only rate limiting applies } const key = `${keyPrefix}:${identifier}`; From ee731a541f04c41400bf3e3d17825e17870a9ba5 Mon Sep 17 00:00:00 2001 From: Utsav joshi Date: Wed, 29 Apr 2026 15:25:17 +0530 Subject: [PATCH 6/8] perf: implement network and resource optimization via compression, caching, preloading, and tuned request policies --- apps/api/package.json | 2 + apps/api/src/server.ts | 5 +++ apps/web/index.html | 26 +++++++++--- apps/web/src/lib/api-client.ts | 2 + apps/web/src/main.tsx | 2 + apps/web/vercel.json | 24 +++++++++++ package-lock.json | 76 ++++++++++++++++++++++++++++++++++ 7 files changed, 132 insertions(+), 5 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index 1867f2b..2785363 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -20,6 +20,7 @@ "@postly/shared-types": "*", "bcrypt": "^5.1.1", "bullmq": "^5.31.3", + "compression": "^1.8.1", "cors": "^2.8.6", "dotenv": "^16.4.7", "express": "^4.21.2", @@ -37,6 +38,7 @@ "@postly/eslint-config": "*", "@postly/typescript-config": "*", "@types/bcrypt": "^5.0.2", + "@types/compression": "^1.8.1", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", "@types/jsonwebtoken": "^9.0.7", diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index fc8612a..3d66a45 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -1,4 +1,5 @@ import express from "express"; +import compression from "compression"; import cors from "cors"; import helmet from "helmet"; import rateLimit from "express-rate-limit"; @@ -32,6 +33,9 @@ const __dirname = path.dirname(__filename); // Request correlation ID — must be first for tracing app.use(requestIdMiddleware); +// Response compression — critical for high-latency links +app.use(compression()); + // Security middleware app.use( helmet({ @@ -68,6 +72,7 @@ app.use( credentials: true, methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With"], + maxAge: 86400, // Cache preflight for 24h — saves ~300ms per cross-origin request }), ); diff --git a/apps/web/index.html b/apps/web/index.html index eb78b71..8626abe 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -6,9 +6,29 @@ Postly - Search Jobs + + + - + + + + -
diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index 2a911b3..1a23311 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -5,8 +5,10 @@ const API_URL = import.meta.env.VITE_API_URL || "http://localhost:3000"; export const apiClient = axios.create({ baseURL: `${API_URL}/api/v1`, withCredentials: true, + timeout: 30000, // 30s timeout — accounts for ~300ms RTT to Dallas VPS headers: { "Content-Type": "application/json", + "Accept-Encoding": "gzip, deflate, br", }, }); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 555ec7d..d0462a0 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -8,7 +8,9 @@ const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 1000 * 60 * 5, // 5 minutes + gcTime: 1000 * 60 * 30, // Keep cached data 30 min (default 5) — avoids re-fetch over slow link retry: 1, + refetchOnWindowFocus: false, // Don't refetch on tab switch — saves ~300ms RTT each time }, }, }); diff --git a/apps/web/vercel.json b/apps/web/vercel.json index 1323cda..27a78c9 100644 --- a/apps/web/vercel.json +++ b/apps/web/vercel.json @@ -1,4 +1,28 @@ { + "headers": [ + { + "source": "/assets/(.*)", + "headers": [ + { + "key": "Cache-Control", + "value": "public, max-age=31536000, immutable" + } + ] + }, + { + "source": "/(.*)", + "headers": [ + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + } + ] + } + ], "rewrites": [ { "source": "/(.*)", diff --git a/package-lock.json b/package-lock.json index 37bcd8d..7b6d801 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "@postly/shared-types": "*", "bcrypt": "^5.1.1", "bullmq": "^5.31.3", + "compression": "^1.8.1", "cors": "^2.8.6", "dotenv": "^16.4.7", "express": "^4.21.2", @@ -49,6 +50,7 @@ "@postly/eslint-config": "*", "@postly/typescript-config": "*", "@types/bcrypt": "^5.0.2", + "@types/compression": "^1.8.1", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", "@types/jsonwebtoken": "^9.0.7", @@ -4619,6 +4621,17 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/node": "*" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -6423,6 +6436,60 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -11771,6 +11838,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", From dcf3307c5badf6700c56f9acb5067425ad4aad3b Mon Sep 17 00:00:00 2001 From: Utsav joshi Date: Wed, 29 Apr 2026 15:34:30 +0530 Subject: [PATCH 7/8] chore: configure service build paths in compose and update deployment script to rebuild all services --- .github/workflows/deploy.yml | 6 +++--- docker-compose.prod.yml | 9 +++++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5e9f425..27451b6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -31,9 +31,9 @@ jobs: git fetch --all git reset --hard origin/main - # Rebuild and start services (--no-deps prevents cascading restarts) - docker compose -f docker-compose.prod.yml build --no-cache api - docker compose -f docker-compose.prod.yml up -d --no-deps api + # Rebuild and start all services + docker compose -f docker-compose.prod.yml build --no-cache + docker compose -f docker-compose.prod.yml up -d # Wait for API to become healthy echo "Waiting for API health check..." diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index df7d0f9..548fc9f 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -75,6 +75,9 @@ services: # ─── API Server ──────────────────────────────────────────── api: image: ${API_IMAGE:-utsavjoshi/postly-api:latest} + build: + context: . + dockerfile: apps/api/Dockerfile container_name: postly-api restart: unless-stopped environment: @@ -127,6 +130,9 @@ services: # ─── Scraper (Python + Playwright) ──────────────────────── scraper: image: ${SCRAPER_IMAGE:-utsavjoshi/postly-scraper:latest} + build: + context: . + dockerfile: apps/scraper/Dockerfile container_name: postly-scraper restart: unless-stopped environment: @@ -165,6 +171,9 @@ services: # ─── Discord Bot ─────────────────────────────────────────── bot: image: ${BOT_IMAGE:-utsavjoshi/postly-bot:latest} + build: + context: . + dockerfile: apps/bot/Dockerfile container_name: postly-bot restart: unless-stopped environment: From 3b0496cf99ca38591debbb59ad4174c445a1ff49 Mon Sep 17 00:00:00 2001 From: Utsav joshi Date: Wed, 29 Apr 2026 15:38:19 +0530 Subject: [PATCH 8/8] feat: integrate express-prom-bundle to expose application metrics endpoint --- apps/api/package.json | 2 ++ apps/api/src/server.ts | 14 ++++++++ package-lock.json | 76 ++++++++++++++++++++++++++++++++++++------ 3 files changed, 81 insertions(+), 11 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index 2785363..9132e02 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -24,6 +24,7 @@ "cors": "^2.8.6", "dotenv": "^16.4.7", "express": "^4.21.2", + "express-prom-bundle": "^8.0.0", "express-rate-limit": "^8.2.1", "helmet": "^8.0.0", "ioredis": "^5.9.2", @@ -31,6 +32,7 @@ "mammoth": "^1.11.0", "multer": "^2.0.2", "pdf-parse": "^2.4.5", + "prom-client": "^15.1.3", "resend": "^6.9.4", "zod": "^3.24.1" }, diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 3d66a45..e3cbc34 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -3,6 +3,7 @@ import compression from "compression"; import cors from "cors"; import helmet from "helmet"; import rateLimit from "express-rate-limit"; +import promBundle from "express-prom-bundle"; import { tokenBucketRateLimiter } from "./middleware/token-bucket-rate-limit.js"; import { requestIdMiddleware } from "./middleware/request-id.js"; import { pool } from "@postly/database"; @@ -36,6 +37,19 @@ app.use(requestIdMiddleware); // Response compression — critical for high-latency links app.use(compression()); +// Prometheus Metrics Middleware +// Exposes /metrics endpoint for VictoriaMetrics/Prometheus to scrape +const metricsMiddleware = promBundle({ + includeMethod: true, + includePath: true, + includeStatusCode: true, + includeUp: true, + promClient: { + collectDefaultMetrics: {}, + }, +}); +app.use(metricsMiddleware); + // Security middleware app.use( helmet({ diff --git a/package-lock.json b/package-lock.json index 7b6d801..be45bec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "cors": "^2.8.6", "dotenv": "^16.4.7", "express": "^4.21.2", + "express-prom-bundle": "^8.0.0", "express-rate-limit": "^8.2.1", "helmet": "^8.0.0", "ioredis": "^5.9.2", @@ -43,6 +44,7 @@ "mammoth": "^1.11.0", "multer": "^2.0.2", "pdf-parse": "^2.4.5", + "prom-client": "^15.1.3", "resend": "^6.9.4", "zod": "^3.24.1" }, @@ -3016,6 +3018,15 @@ "node": ">= 8" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@paper-design/shaders": { "version": "0.0.71", "resolved": "https://registry.npmjs.org/@paper-design/shaders/-/shaders-0.0.71.tgz", @@ -4603,7 +4614,6 @@ "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "dev": true, "license": "MIT", "dependencies": { "@types/connect": "*", @@ -4636,7 +4646,6 @@ "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -4693,7 +4702,6 @@ "version": "5.0.6", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", - "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", @@ -4705,7 +4713,6 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -4727,7 +4734,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -4776,7 +4782,6 @@ "version": "25.2.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.1.tgz", "integrity": "sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg==", - "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -4814,14 +4819,12 @@ "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/react": { @@ -4848,7 +4851,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -4858,7 +4860,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", @@ -5866,6 +5867,12 @@ "node": ">= 10.0.0" } }, + "node_modules/bintrees": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", + "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", + "license": "MIT" + }, "node_modules/bluebird": { "version": "3.4.7", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", @@ -8284,6 +8291,23 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-prom-bundle": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/express-prom-bundle/-/express-prom-bundle-8.0.0.tgz", + "integrity": "sha512-UHdpaMks6Z/tvxQsNzhsE7nkdXb4/zEh/jwN0tfZSZOEF+aD0dlfl085EU4jveOq09v01c5sIUfjV4kJODZ2eQ==", + "license": "MIT", + "dependencies": { + "@types/express": "^5.0.0", + "on-finished": "^2.3.0", + "url-value-parser": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "prom-client": ">=15.0.0" + } + }, "node_modules/express-rate-limit": { "version": "8.2.1", "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", @@ -12404,6 +12428,19 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/prom-client": { + "version": "15.1.3", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", + "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.4.0", + "tdigest": "^0.1.1" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -13897,6 +13934,15 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "license": "ISC" }, + "node_modules/tdigest": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", + "integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==", + "license": "MIT", + "dependencies": { + "bintrees": "1.0.2" + } + }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", @@ -14330,7 +14376,6 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "devOptional": true, "license": "MIT" }, "node_modules/unified": { @@ -14469,6 +14514,15 @@ "punycode": "^2.1.0" } }, + "node_modules/url-value-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/url-value-parser/-/url-value-parser-2.2.0.tgz", + "integrity": "sha512-yIQdxJpgkPamPPAPuGdS7Q548rLhny42tg8d4vyTNzFqvOnwqrgHXvgehT09U7fwrzxi3RxCiXjoNUNnNOlQ8A==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",