From a720654f0c81261af1e8232bd0e0fa4563860182 Mon Sep 17 00:00:00 2001 From: Utpal Date: Fri, 2 Jan 2026 00:08:51 +0530 Subject: [PATCH 1/4] feat(server): implement Phase 1.1 backend foundation - Add Fastify 5 HTTP server with CORS, Helmet, JWT, rate limiting - Integrate Prisma 7 with PostgreSQL adapter and ESM support - Add Redis service with separate pub/sub clients for presence - Implement WebSocket handler with JWT auth and heartbeat protocol - Create GitHub OAuth authentication flow - Add REST routes: /auth, /users, /friends with full CRUD - Set up structured Pino logging with sensitive data redaction - Add hierarchical error system distinguishing operational errors - Configure graceful shutdown with connection cleanup --- .gitignore | 1 + apps/server/eslint.config.js | 8 + apps/server/package.json | 50 + apps/server/prisma.config.ts | 26 + apps/server/prisma/schema.prisma | 108 ++ apps/server/src/config.ts | 90 ++ apps/server/src/lib/errors.ts | 217 +++ apps/server/src/lib/logger.ts | 103 ++ apps/server/src/routes/auth.ts | 144 ++ apps/server/src/routes/friends.ts | 351 +++++ apps/server/src/routes/users.ts | 233 ++++ apps/server/src/server.ts | 321 +++++ apps/server/src/services/db.ts | 85 ++ apps/server/src/services/github.ts | 207 +++ apps/server/src/services/redis.ts | 233 ++++ apps/server/src/types/fastify.d.ts | 28 + apps/server/src/ws/handler.ts | 386 ++++++ apps/server/src/ws/types.ts | 134 ++ apps/server/tsconfig.json | 16 + packages/eslint-config/package.json | 11 +- packages/shared/package.json | 8 +- packages/shared/src/validators.ts | 4 +- pnpm-lock.yaml | 1966 ++++++++++++++++++++++++++- 23 files changed, 4655 insertions(+), 75 deletions(-) create mode 100644 apps/server/eslint.config.js create mode 100644 apps/server/package.json create mode 100644 apps/server/prisma.config.ts create mode 100644 apps/server/prisma/schema.prisma create mode 100644 apps/server/src/config.ts create mode 100644 apps/server/src/lib/errors.ts create mode 100644 apps/server/src/lib/logger.ts create mode 100644 apps/server/src/routes/auth.ts create mode 100644 apps/server/src/routes/friends.ts create mode 100644 apps/server/src/routes/users.ts create mode 100644 apps/server/src/server.ts create mode 100644 apps/server/src/services/db.ts create mode 100644 apps/server/src/services/github.ts create mode 100644 apps/server/src/services/redis.ts create mode 100644 apps/server/src/types/fastify.d.ts create mode 100644 apps/server/src/ws/handler.ts create mode 100644 apps/server/src/ws/types.ts create mode 100644 apps/server/tsconfig.json diff --git a/.gitignore b/.gitignore index 7e31057..9b5bfd6 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ coverage/ # Docker docker-compose.override.yml +apps/server/src/generated/ diff --git a/apps/server/eslint.config.js b/apps/server/eslint.config.js new file mode 100644 index 0000000..4cea9ac --- /dev/null +++ b/apps/server/eslint.config.js @@ -0,0 +1,8 @@ +import baseConfig from '@devradar/eslint-config'; + +export default [ + ...baseConfig, + { + ignores: ['prisma.config.ts', 'prisma/generated/**'], + }, +]; diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100644 index 0000000..d687d6c --- /dev/null +++ b/apps/server/package.json @@ -0,0 +1,50 @@ +{ + "name": "@devradar/server", + "version": "0.0.0", + "private": true, + "license": "MIT", + "type": "module", + "main": "./dist/server.js", + "scripts": { + "dev": "tsx watch --env-file=.env src/server.ts", + "build": "tsc", + "start": "node dist/server.js", + "check-types": "tsc --noEmit", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "clean": "rimraf dist", + "db:generate": "prisma generate", + "db:push": "prisma db push", + "db:migrate": "prisma migrate dev", + "db:studio": "prisma studio" + }, + "dependencies": { + "@devradar/shared": "workspace:*", + "@fastify/cors": "^11.2.0", + "@fastify/helmet": "^13.0.2", + "@fastify/jwt": "^10.0.0", + "@fastify/rate-limit": "^10.3.0", + "@fastify/websocket": "^11.2.0", + "@prisma/adapter-pg": "^7.2.0", + "@prisma/client": "^7.2.0", + "dotenv": "^16.5.0", + "fastify": "^5.6.2", + "ioredis": "^5.8.2", + "pg": "^8.16.3", + "pino": "^10.1.0", + "pino-pretty": "^13.1.3", + "zod": "^3.24.0" + }, + "devDependencies": { + "@devradar/eslint-config": "workspace:*", + "@devradar/tsconfig": "workspace:*", + "@types/node": "^25.0.3", + "@types/pg": "^8.16.0", + "@types/ws": "^8.18.1", + "eslint": "^9.39.2", + "prisma": "^7.2.0", + "rimraf": "^6.1.2", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/apps/server/prisma.config.ts b/apps/server/prisma.config.ts new file mode 100644 index 0000000..3c180d3 --- /dev/null +++ b/apps/server/prisma.config.ts @@ -0,0 +1,26 @@ +/** + * Prisma Configuration + * + * Prisma 7 configuration file for CLI commands (migrate, db push, etc.) + * Database URL for migrations is configured here. + * + * IMPORTANT: In Prisma 7, env vars must be explicitly loaded with dotenv. + */ + +import 'dotenv/config'; +import { defineConfig, env } from 'prisma/config'; + +export default defineConfig({ + // Path to the Prisma schema file + schema: 'prisma/schema.prisma', + + // Migration settings + migrations: { + path: 'prisma/migrations', + }, + + // Database connection for Prisma CLI (migrate, db push, studio) + datasource: { + url: env('DATABASE_URL'), + }, +}); diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma new file mode 100644 index 0000000..7206c55 --- /dev/null +++ b/apps/server/prisma/schema.prisma @@ -0,0 +1,108 @@ +// DevRadar Database Schema +// Using Prisma 7 with PostgreSQL + +generator client { + provider = "prisma-client" + output = "../src/generated/prisma" + moduleFormat = "esm" +} + +datasource db { + provider = "postgresql" +} + +// =================== +// User Management +// =================== + +model User { + id String @id @default(cuid()) + githubId String @unique + username String + displayName String? + avatarUrl String? + email String? + tier Tier @default(FREE) + privacyMode Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + following Follow[] @relation("Following") + followers Follow[] @relation("Followers") + teams TeamMember[] + + @@index([username]) + @@index([githubId]) +} + +// =================== +// Social Graph +// =================== + +model Follow { + id String @id @default(cuid()) + followerId String + followingId String + createdAt DateTime @default(now()) + + // Relations + follower User @relation("Following", fields: [followerId], references: [id], onDelete: Cascade) + following User @relation("Followers", fields: [followingId], references: [id], onDelete: Cascade) + + @@unique([followerId, followingId]) + @@index([followerId]) + @@index([followingId]) +} + +// =================== +// Team Management +// =================== + +model Team { + id String @id @default(cuid()) + name String + slug String @unique + ownerId String + tier Tier @default(TEAM) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + members TeamMember[] + + @@index([slug]) + @@index([ownerId]) +} + +model TeamMember { + id String @id @default(cuid()) + userId String + teamId String + role Role @default(MEMBER) + joinedAt DateTime @default(now()) + + // Relations + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + + @@unique([userId, teamId]) + @@index([userId]) + @@index([teamId]) +} + +// =================== +// Enums +// =================== + +enum Tier { + FREE + PRO + TEAM +} + +enum Role { + OWNER + ADMIN + MEMBER +} diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts new file mode 100644 index 0000000..6f8ac41 --- /dev/null +++ b/apps/server/src/config.ts @@ -0,0 +1,90 @@ +/** + * Environment Configuration + * + * Validates and exports environment variables using Zod. + * Follows 12-factor app principles - all config from environment. + */ + +import { z } from 'zod'; + +/** + * Environment schema with validation rules. + * All required variables must be set or have sensible defaults. + */ +const envSchema = z.object({ + // Node environment + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + + // Server + PORT: z.coerce.number().int().min(1).max(65535).default(3000), + HOST: z.string().default('0.0.0.0'), + + // Database + DATABASE_URL: z + .string() + .url() + .refine((url: string) => url.startsWith('postgresql://') || url.startsWith('postgres://'), { + message: 'DATABASE_URL must be a valid PostgreSQL connection string', + }), + + // Redis + REDIS_URL: z + .string() + .url() + .refine((url: string) => url.startsWith('redis://') || url.startsWith('rediss://'), { + message: 'REDIS_URL must be a valid Redis connection string', + }), + + // JWT + JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'), + JWT_EXPIRES_IN: z.string().default('7d'), + + // GitHub OAuth + GITHUB_CLIENT_ID: z.string().min(1, 'GITHUB_CLIENT_ID is required'), + GITHUB_CLIENT_SECRET: z.string().min(1, 'GITHUB_CLIENT_SECRET is required'), + GITHUB_CALLBACK_URL: z.string().url(), + + // Logging + LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'), +}); + +/** + * Parsed and validated environment configuration. + */ +export type Env = z.infer; + +/** + * Parse and validate environment variables. + * Throws descriptive error if validation fails. + */ +function parseEnv(): Env { + const result = envSchema.safeParse(process.env); + + if (!result.success) { + console.error('❌ Invalid environment variables:\n', result.error.format()); + throw new Error('Invalid environment configuration'); + } + + return result.data; +} + +/** + * Validated environment configuration. + * Access this throughout the application for type-safe config. + */ +export const env = parseEnv(); + +/** + * Check if running in production mode. + */ +export const isProduction = env.NODE_ENV === 'production'; + +/** + * Check if running in development mode. + */ +export const isDevelopment = env.NODE_ENV === 'development'; + +/** + * Check if running in test mode. + */ +export const isTest = env.NODE_ENV === 'test'; diff --git a/apps/server/src/lib/errors.ts b/apps/server/src/lib/errors.ts new file mode 100644 index 0000000..fd8ce67 --- /dev/null +++ b/apps/server/src/lib/errors.ts @@ -0,0 +1,217 @@ +/** + * Custom Error Classes + * + * Hierarchical error system following rules/09_ERROR_HANDLING.md. + * Distinguishes between operational errors (expected) and programming errors (bugs). + */ + +/** + * Base application error class. + * All custom errors extend from this. + */ +export abstract class AppError extends Error { + /** Error code for programmatic handling */ + abstract readonly code: string; + + /** HTTP status code */ + abstract readonly statusCode: number; + + /** Whether this is an operational error (expected) vs programming error */ + abstract readonly isOperational: boolean; + + /** ISO timestamp when error occurred */ + readonly timestamp: string; + + /** Optional trace ID for correlation */ + traceId?: string; + + /** Additional error details */ + details?: Record | undefined; + + constructor(message: string, options?: { cause?: Error; details?: Record }) { + super(message, { cause: options?.cause }); + this.name = this.constructor.name; + this.timestamp = new Date().toISOString(); + if (options?.details) { + this.details = options.details; + } + Error.captureStackTrace(this, this.constructor); + } + + /** + * Convert to JSON-safe object for API responses. + * Excludes sensitive information like stack traces. + */ + toJSON(): Record { + return { + code: this.code, + message: this.message, + details: this.details, + timestamp: this.timestamp, + traceId: this.traceId, + }; + } +} + +// =================== +// Operational Errors (Expected, Recoverable) +// =================== + +/** + * 400 Bad Request - Invalid input data + */ +export class ValidationError extends AppError { + readonly code = 'VALIDATION_ERROR'; + readonly statusCode = 400; + readonly isOperational = true; + + constructor( + message: string, + options?: { cause?: Error; details?: Record; fields?: FieldError[] } + ) { + super(message, options); + if (options?.fields) { + this.details = { ...this.details, fields: options.fields }; + } + } +} + +/** + * Field-level validation error detail + */ +export interface FieldError { + field: string; + message: string; + code?: string; +} + +/** + * 401 Unauthorized - Missing or invalid authentication + */ +export class AuthenticationError extends AppError { + readonly code = 'AUTHENTICATION_ERROR'; + readonly statusCode = 401; + readonly isOperational = true; + + constructor(message = 'Authentication required') { + super(message); + } +} + +/** + * 403 Forbidden - Authenticated but not authorized + */ +export class AuthorizationError extends AppError { + readonly code = 'AUTHORIZATION_ERROR'; + readonly statusCode = 403; + readonly isOperational = true; + + constructor(message = 'You do not have permission to access this resource') { + super(message); + } +} + +/** + * 404 Not Found - Resource does not exist + */ +export class NotFoundError extends AppError { + readonly code = 'NOT_FOUND'; + readonly statusCode = 404; + readonly isOperational = true; + + constructor(resource = 'Resource', id?: string) { + super(id ? `${resource} with ID '${id}' not found` : `${resource} not found`); + } +} + +/** + * 409 Conflict - Resource already exists or state conflict + */ +export class ConflictError extends AppError { + readonly code = 'CONFLICT'; + readonly statusCode = 409; + readonly isOperational = true; +} + +/** + * 429 Too Many Requests - Rate limit exceeded + */ +export class RateLimitError extends AppError { + readonly code = 'RATE_LIMIT_EXCEEDED'; + readonly statusCode = 429; + readonly isOperational = true; + + /** Seconds until rate limit resets */ + retryAfter?: number | undefined; + + constructor(message = 'Too many requests. Please try again later.', retryAfter?: number) { + super(message); + if (retryAfter !== undefined) { + this.retryAfter = retryAfter; + } + } +} + +// =================== +// Programming Errors (Unexpected, Non-Recoverable) +// =================== + +/** + * 500 Internal Server Error - Unexpected programming error + */ +export class InternalError extends AppError { + readonly code = 'INTERNAL_ERROR'; + readonly statusCode = 500; + readonly isOperational = false; + + constructor(message = 'An unexpected error occurred', options?: { cause?: Error }) { + super(message, options); + } +} + +/** + * 503 Service Unavailable - External service failure + */ +export class ServiceUnavailableError extends AppError { + readonly code = 'SERVICE_UNAVAILABLE'; + readonly statusCode = 503; + readonly isOperational = true; + + constructor(service: string, options?: { cause?: Error }) { + super(`${service} is temporarily unavailable. Please try again later.`, options); + } +} + +// =================== +// Type Guards +// =================== + +/** + * Check if an error is an AppError instance. + */ +export function isAppError(error: unknown): error is AppError { + return error instanceof AppError; +} + +/** + * Check if an error is operational (expected/recoverable). + */ +export function isOperationalError(error: unknown): boolean { + return isAppError(error) && error.isOperational; +} + +/** + * Wrap unknown errors into AppError. + * Use this to ensure consistent error handling. + */ +export function toAppError(error: unknown): AppError { + if (isAppError(error)) { + return error; + } + + if (error instanceof Error) { + return new InternalError(error.message, { cause: error }); + } + + return new InternalError(String(error)); +} diff --git a/apps/server/src/lib/logger.ts b/apps/server/src/lib/logger.ts new file mode 100644 index 0000000..55173fb --- /dev/null +++ b/apps/server/src/lib/logger.ts @@ -0,0 +1,103 @@ +/** + * Structured Logger + * + * Pino-based logger with: + * - JSON structured logging for production + * - Pretty printing for development + * - Sensitive data redaction + * - Correlation ID support + */ + +import pino, { type Logger } from 'pino'; + +import { env, isDevelopment } from '@/config'; + +/** + * Fields to redact from logs for security. + * Never log passwords, tokens, or PII. + */ +const REDACT_PATHS = [ + 'password', + 'accessToken', + 'refreshToken', + 'authorization', + 'token', + 'secret', + 'apiKey', + '*.password', + '*.token', + '*.secret', + 'headers.authorization', + 'headers.cookie', +]; + +/** + * Create the Pino logger instance. + */ +function createLogger(): Logger { + const options: pino.LoggerOptions = { + level: env.LOG_LEVEL, + redact: { + paths: REDACT_PATHS, + censor: '[REDACTED]', + }, + formatters: { + level: (label) => ({ level: label }), + }, + timestamp: pino.stdTimeFunctions.isoTime, + base: { + service: 'devradar-server', + env: env.NODE_ENV, + }, + }; + + // Pretty print in development for readability + if (isDevelopment) { + return pino({ + ...options, + transport: { + target: 'pino-pretty', + options: { + colorize: true, + translateTime: 'SYS:standard', + ignore: 'pid,hostname', + }, + }, + }); + } + + return pino(options); +} + +/** + * Application logger instance. + * Use this for all logging throughout the application. + */ +export const logger = createLogger(); + +/** + * Create a child logger with additional context. + * Useful for request-scoped logging with correlation IDs. + * + * @param bindings - Additional context to include in all logs + * @returns Child logger with bindings + * + * @example + * const reqLogger = createChildLogger({ traceId: 'abc123', userId: 'user_1' }); + * reqLogger.info('Processing request'); + */ +export function createChildLogger(bindings: Record): Logger { + return logger.child(bindings); +} + +/** + * Log levels for reference. + */ +export const LogLevel = { + FATAL: 'fatal', + ERROR: 'error', + WARN: 'warn', + INFO: 'info', + DEBUG: 'debug', + TRACE: 'trace', +} as const; diff --git a/apps/server/src/routes/auth.ts b/apps/server/src/routes/auth.ts new file mode 100644 index 0000000..b446081 --- /dev/null +++ b/apps/server/src/routes/auth.ts @@ -0,0 +1,144 @@ +/** + * Authentication Routes + * + * Handles GitHub OAuth flow: + * - GET /auth/github - Redirect to GitHub login + * - GET /auth/callback - Handle OAuth callback + */ + +import { z } from 'zod'; + +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + +import { ValidationError } from '@/lib/errors'; +import { logger } from '@/lib/logger'; +import { getGitHubAuthUrl, authenticateWithGitHub } from '@/services/github'; + +/** + * Callback query params schema. + */ +const CallbackQuerySchema = z.object({ + code: z.string().min(1, 'Authorization code is required'), + state: z.string().optional(), + error: z.string().optional(), + error_description: z.string().optional(), +}); + +/** + * Register authentication routes. + */ +export function authRoutes(app: FastifyInstance): void { + /** + * GET /auth/github + * Redirect to GitHub OAuth authorization page. + */ + app.get('/auth/github', async (_request: FastifyRequest, reply: FastifyReply) => { + // Generate state for CSRF protection + const state = crypto.randomUUID(); + + // Store state in session/cookie for validation (simplified for now) + // In production, use secure cookies or session storage + + const authUrl = getGitHubAuthUrl(state); + + logger.debug({ state }, 'Redirecting to GitHub OAuth'); + + return reply.redirect(authUrl); + }); + + /** + * GET /auth/callback + * Handle GitHub OAuth callback. + * Returns JWT token on success. + */ + app.get('/auth/callback', async (request: FastifyRequest, reply: FastifyReply) => { + const result = CallbackQuerySchema.safeParse(request.query); + + if (!result.success) { + throw new ValidationError('Invalid callback parameters', { + details: { errors: result.error.issues }, + }); + } + + const { code, error, error_description } = result.data; + + // Handle OAuth errors from GitHub + if (error) { + logger.warn({ error, error_description }, 'GitHub OAuth error'); + return reply.status(400).send({ + error: { + code: 'OAUTH_ERROR', + message: error_description ?? error, + }, + }); + } + + // Authenticate with GitHub + const user = await authenticateWithGitHub(code); + + // Generate JWT + const token = app.jwt.sign( + { + userId: user.id, + username: user.username, + tier: user.tier, + }, + { expiresIn: '7d' } + ); + + logger.info({ userId: user.id }, 'User authenticated successfully'); + + // Return token and user info + // In production, the extension would intercept this or use a custom URI scheme + return reply.send({ + data: { + token, + user: { + id: user.id, + username: user.username, + displayName: user.displayName, + avatarUrl: user.avatarUrl, + tier: user.tier, + }, + }, + }); + }); + + /** + * POST /auth/refresh + * Refresh JWT token (requires valid token). + */ + app.post( + '/auth/refresh', + { onRequest: [app.authenticate] }, + async (request: FastifyRequest, reply: FastifyReply) => { + const user = request.user as { userId: string; username: string; tier: string }; + + const token = app.jwt.sign( + { + userId: user.userId, + username: user.username, + tier: user.tier, + }, + { expiresIn: '7d' } + ); + + return reply.send({ + data: { token }, + }); + } + ); + + /** + * POST /auth/logout + * Logout (client-side token removal). + * Server-side we could invalidate refresh tokens if we had them. + */ + app.post('/auth/logout', async (_request: FastifyRequest, reply: FastifyReply) => { + // With JWT, logout is primarily client-side + // We could add token blacklisting for enhanced security + return reply.send({ + data: { message: 'Logged out successfully' }, + }); + }); +} diff --git a/apps/server/src/routes/friends.ts b/apps/server/src/routes/friends.ts new file mode 100644 index 0000000..153a5d8 --- /dev/null +++ b/apps/server/src/routes/friends.ts @@ -0,0 +1,351 @@ +/** + * Friends Routes + * + * Follow/unfollow functionality: + * - GET /friends - List friends (following) + * - POST /friends/:id - Follow a user + * - DELETE /friends/:id - Unfollow a user + * - GET /friends/followers - List followers + */ + +import { PaginationQuerySchema } from '@devradar/shared'; +import { z } from 'zod'; + +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + +import { NotFoundError, ConflictError, ValidationError } from '@/lib/errors'; +import { logger } from '@/lib/logger'; +import { getDb } from '@/services/db'; +import { getPresences } from '@/services/redis'; + +/** + * User ID params schema. + */ +const UserIdParamsSchema = z.object({ + id: z.string().min(1, 'User ID is required'), +}); + +/** + * Follow with user details type. + */ +interface FollowWithUser { + following: { + id: string; + username: string; + displayName: string | null; + avatarUrl: string | null; + tier: string; + privacyMode: boolean; + }; + createdAt: Date; +} + +/** + * Follow with follower details type. + */ +interface FollowWithFollower { + follower: { + id: string; + username: string; + displayName: string | null; + avatarUrl: string | null; + tier: string; + }; + createdAt: Date; +} + +/** + * Register friend routes. + */ +export function friendRoutes(app: FastifyInstance): void { + const db = getDb(); + + /** + * GET /friends + * List users the current user is following (friends). + */ + app.get( + '/friends', + { onRequest: [app.authenticate] }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = request.user as { userId: string }; + + const paginationResult = PaginationQuerySchema.safeParse(request.query); + const { page, limit } = paginationResult.success + ? paginationResult.data + : { page: 1, limit: 20 }; + + const skip = (page - 1) * limit; + + // Get following with user details + const [follows, total] = await Promise.all([ + db.follow.findMany({ + where: { followerId: userId }, + include: { + following: { + select: { + id: true, + username: true, + displayName: true, + avatarUrl: true, + tier: true, + privacyMode: true, + }, + }, + }, + skip, + take: limit, + orderBy: { createdAt: 'desc' }, + }) as Promise, + db.follow.count({ where: { followerId: userId } }), + ]); + + // Get presences for friends + const friendIds = follows.map((f: FollowWithUser) => f.following.id); + const presences = await getPresences(friendIds); + + // Combine user data with presence + const friends = follows.map((f: FollowWithUser) => { + const user = f.following; + const presence = presences.get(user.id); + + return { + id: user.id, + username: user.username, + displayName: user.displayName, + avatarUrl: user.avatarUrl, + tier: user.tier, + privacyMode: user.privacyMode, + status: user.privacyMode ? 'incognito' : (presence?.status ?? 'offline'), + activity: user.privacyMode ? undefined : presence?.activity, + followedAt: f.createdAt.toISOString(), + }; + }); + + return reply.send({ + data: friends, + pagination: { + page, + limit, + total, + hasMore: skip + friends.length < total, + }, + }); + } + ); + + /** + * GET /friends/followers + * List users following the current user. + */ + app.get( + '/friends/followers', + { onRequest: [app.authenticate] }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = request.user as { userId: string }; + + const paginationResult = PaginationQuerySchema.safeParse(request.query); + const { page, limit } = paginationResult.success + ? paginationResult.data + : { page: 1, limit: 20 }; + + const skip = (page - 1) * limit; + + const [follows, total] = await Promise.all([ + db.follow.findMany({ + where: { followingId: userId }, + include: { + follower: { + select: { + id: true, + username: true, + displayName: true, + avatarUrl: true, + tier: true, + }, + }, + }, + skip, + take: limit, + orderBy: { createdAt: 'desc' }, + }) as Promise, + db.follow.count({ where: { followingId: userId } }), + ]); + + const followers = follows.map((f: FollowWithFollower) => ({ + ...f.follower, + followedAt: f.createdAt.toISOString(), + })); + + return reply.send({ + data: followers, + pagination: { + page, + limit, + total, + hasMore: skip + followers.length < total, + }, + }); + } + ); + + /** + * POST /friends/:id + * Follow a user. + */ + app.post( + '/friends/:id', + { onRequest: [app.authenticate] }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = request.user as { userId: string }; + + const paramsResult = UserIdParamsSchema.safeParse(request.params); + if (!paramsResult.success) { + throw new ValidationError('Invalid user ID'); + } + + const { id: targetUserId } = paramsResult.data; + + // Prevent self-follow + if (userId === targetUserId) { + throw new ConflictError('You cannot follow yourself'); + } + + // Check if target user exists + const targetUser = await db.user.findUnique({ + where: { id: targetUserId }, + select: { id: true, username: true }, + }); + + if (!targetUser) { + throw new NotFoundError('User', targetUserId); + } + + // Check if already following + const existingFollow = await db.follow.findUnique({ + where: { + followerId_followingId: { + followerId: userId, + followingId: targetUserId, + }, + }, + }); + + if (existingFollow) { + throw new ConflictError(`You are already following ${targetUser.username}`); + } + + // Create follow + const follow = await db.follow.create({ + data: { + followerId: userId, + followingId: targetUserId, + }, + }); + + logger.info({ userId, targetUserId }, 'User followed'); + + return reply.status(201).send({ + data: { + id: follow.id, + followingId: targetUserId, + username: targetUser.username, + createdAt: follow.createdAt.toISOString(), + }, + }); + } + ); + + /** + * DELETE /friends/:id + * Unfollow a user. + */ + app.delete( + '/friends/:id', + { onRequest: [app.authenticate] }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = request.user as { userId: string }; + + const paramsResult = UserIdParamsSchema.safeParse(request.params); + if (!paramsResult.success) { + throw new ValidationError('Invalid user ID'); + } + + const { id: targetUserId } = paramsResult.data; + + // Find and delete follow + const follow = await db.follow.findUnique({ + where: { + followerId_followingId: { + followerId: userId, + followingId: targetUserId, + }, + }, + }); + + if (!follow) { + throw new NotFoundError('Follow relationship'); + } + + await db.follow.delete({ + where: { id: follow.id }, + }); + + logger.info({ userId, targetUserId }, 'User unfollowed'); + + return reply.status(204).send(); + } + ); + + /** + * GET /friends/:id/mutual + * Get mutual friends with a user. + */ + app.get( + '/friends/:id/mutual', + { onRequest: [app.authenticate] }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = request.user as { userId: string }; + + const paramsResult = UserIdParamsSchema.safeParse(request.params); + if (!paramsResult.success) { + throw new ValidationError('Invalid user ID'); + } + + const { id: targetUserId } = paramsResult.data; + + // Get users both are following + const myFollowing = await db.follow.findMany({ + where: { followerId: userId }, + select: { followingId: true }, + }); + + const theirFollowing = await db.follow.findMany({ + where: { followerId: targetUserId }, + select: { followingId: true }, + }); + + const myFollowingIds = new Set( + myFollowing.map((f: { followingId: string }) => f.followingId) + ); + const mutualIds = theirFollowing + .filter((f: { followingId: string }) => myFollowingIds.has(f.followingId)) + .map((f: { followingId: string }) => f.followingId); + + // Get user details for mutual friends + const mutualFriends = await db.user.findMany({ + where: { id: { in: mutualIds } }, + select: { + id: true, + username: true, + displayName: true, + avatarUrl: true, + }, + }); + + return reply.send({ + data: mutualFriends, + }); + } + ); +} diff --git a/apps/server/src/routes/users.ts b/apps/server/src/routes/users.ts new file mode 100644 index 0000000..92d8766 --- /dev/null +++ b/apps/server/src/routes/users.ts @@ -0,0 +1,233 @@ +/** + * User Routes + * + * User profile management: + * - GET /users/me - Get current user + * - GET /users/:id - Get user by ID + * - PATCH /users/me - Update current user + */ + +import { UserUpdateSchema } from '@devradar/shared'; +import { z } from 'zod'; + +import type { UserDTO } from '@devradar/shared'; +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + +import { NotFoundError, ValidationError } from '@/lib/errors'; +import { getDb } from '@/services/db'; +import { getPresence } from '@/services/redis'; + +/** + * User ID params schema. + */ +const UserIdParamsSchema = z.object({ + id: z.string().min(1, 'User ID is required'), +}); + +/** + * Transform Prisma user to DTO. + */ +function toUserDTO(user: { + id: string; + githubId: string; + username: string; + displayName: string | null; + avatarUrl: string | null; + tier: string; + privacyMode: boolean; + createdAt: Date; +}): UserDTO { + return { + id: user.id, + githubId: user.githubId, + username: user.username, + displayName: user.displayName, + avatarUrl: user.avatarUrl, + tier: user.tier as UserDTO['tier'], + privacyMode: user.privacyMode, + createdAt: user.createdAt.toISOString(), + }; +} + +/** + * Register user routes. + */ +export function userRoutes(app: FastifyInstance): void { + const db = getDb(); + + /** + * GET /users/me + * Get current authenticated user's profile. + */ + app.get( + '/users/me', + { onRequest: [app.authenticate] }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = request.user as { userId: string }; + + const user = await db.user.findUnique({ + where: { id: userId }, + }); + + if (!user) { + throw new NotFoundError('User', userId); + } + + // Get current presence + const presence = await getPresence(userId); + + return reply.send({ + data: { + ...toUserDTO(user), + status: presence?.status ?? 'offline', + activity: presence?.activity, + }, + }); + } + ); + + /** + * GET /users/:id + * Get user by ID (public profile). + */ + app.get( + '/users/:id', + { onRequest: [app.authenticate] }, + async (request: FastifyRequest, reply: FastifyReply) => { + const result = UserIdParamsSchema.safeParse(request.params); + + if (!result.success) { + throw new ValidationError('Invalid user ID'); + } + + const { id } = result.data; + + const user = await db.user.findUnique({ + where: { id }, + select: { + id: true, + githubId: true, + username: true, + displayName: true, + avatarUrl: true, + tier: true, + privacyMode: true, + createdAt: true, + _count: { + select: { + followers: true, + following: true, + }, + }, + }, + }); + + if (!user) { + throw new NotFoundError('User', id); + } + + // Respect privacy mode + if (user.privacyMode) { + return reply.send({ + data: { + id: user.id, + username: user.username, + displayName: user.displayName, + avatarUrl: user.avatarUrl, + privacyMode: true, + status: 'incognito', + }, + }); + } + + // Get presence if not in privacy mode + const presence = await getPresence(id); + + return reply.send({ + data: { + ...toUserDTO(user), + followerCount: user._count.followers, + followingCount: user._count.following, + status: presence?.status ?? 'offline', + activity: presence?.activity, + }, + }); + } + ); + + /** + * PATCH /users/me + * Update current user's profile. + */ + app.patch( + '/users/me', + { onRequest: [app.authenticate] }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = request.user as { userId: string }; + + const result = UserUpdateSchema.safeParse(request.body); + + if (!result.success) { + throw new ValidationError('Invalid update data', { + details: { errors: result.error.issues }, + }); + } + + const updateData = result.data; + + // Build update object explicitly to handle exactOptionalPropertyTypes + const prismaUpdateData: { displayName?: string | null; privacyMode?: boolean } = {}; + if (updateData.displayName !== undefined) { + prismaUpdateData.displayName = updateData.displayName; + } + if (updateData.privacyMode !== undefined) { + prismaUpdateData.privacyMode = updateData.privacyMode; + } + + const user = await db.user.update({ + where: { id: userId }, + data: prismaUpdateData, + }); + + return reply.send({ + data: toUserDTO(user), + }); + } + ); + + /** + * GET /users/search + * Search users by username. + */ + app.get( + '/users/search', + { onRequest: [app.authenticate] }, + async (request: FastifyRequest, reply: FastifyReply) => { + const query = (request.query as Record).q; + + if (!query || query.length < 2) { + throw new ValidationError('Search query must be at least 2 characters'); + } + + const users = await db.user.findMany({ + where: { + OR: [ + { username: { contains: query, mode: 'insensitive' } }, + { displayName: { contains: query, mode: 'insensitive' } }, + ], + }, + select: { + id: true, + username: true, + displayName: true, + avatarUrl: true, + }, + take: 20, + }); + + return reply.send({ + data: users, + }); + } + ); +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts new file mode 100644 index 0000000..e8b0744 --- /dev/null +++ b/apps/server/src/server.ts @@ -0,0 +1,321 @@ +/** + * DevRadar Server + * + * Main entry point for the Fastify HTTP/WebSocket server. + * + * Architecture: + * - Fastify 5 for HTTP with plugin-based extensibility + * - @fastify/websocket for real-time presence + * - Prisma 7 for PostgreSQL database + * - ioredis for Redis pub/sub + * - Pino for structured logging + */ + +import fastifyCors from '@fastify/cors'; +import fastifyHelmet from '@fastify/helmet'; +import fastifyJwt from '@fastify/jwt'; +import fastifyRateLimit from '@fastify/rate-limit'; +import fastifyWebsocket from '@fastify/websocket'; +import Fastify, { type FastifyRequest, type FastifyReply } from 'fastify'; + +import { env, isProduction, isDevelopment } from '@/config'; +import { toAppError, AuthenticationError } from '@/lib/errors'; +import { logger } from '@/lib/logger'; +import { authRoutes } from '@/routes/auth'; +import { friendRoutes } from '@/routes/friends'; +import { userRoutes } from '@/routes/users'; +import { connectDb, disconnectDb, isDbHealthy } from '@/services/db'; +import { connectRedis, disconnectRedis, isRedisHealthy } from '@/services/redis'; +import { registerWebSocketHandler, getConnectionCount } from '@/ws/handler'; + +/** + * Create and configure the Fastify server. + */ +async function buildServer() { + const app = Fastify({ + logger: false, // We use our own Pino logger + trustProxy: isProduction, + disableRequestLogging: true, // We'll log requests ourselves + }); + + // =================== + // Core Plugins + // =================== + + // CORS - Configure based on environment + await app.register(fastifyCors, { + origin: isDevelopment + ? true // Allow all in development + : ['https://devradar.io', /\.devradar\.io$/], + credentials: true, + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + }); + + // Security headers + await app.register(fastifyHelmet, { + contentSecurityPolicy: isProduction, + crossOriginEmbedderPolicy: false, // Required for some OAuth flows + }); + + // JWT authentication + await app.register(fastifyJwt, { + secret: env.JWT_SECRET, + sign: { + expiresIn: env.JWT_EXPIRES_IN, + }, + }); + + // Rate limiting + await app.register(fastifyRateLimit, { + max: isProduction ? 100 : 1000, // More lenient in development + timeWindow: '1 minute', + keyGenerator: (request) => { + // Use user ID if authenticated, otherwise IP + const user = request.user as { userId?: string } | undefined; + return user?.userId ?? request.ip; + }, + errorResponseBuilder: () => ({ + error: { + code: 'RATE_LIMIT_EXCEEDED', + message: 'Too many requests. Please try again later.', + }, + }), + }); + + // WebSocket support + await app.register(fastifyWebsocket, { + options: { + maxPayload: 1024 * 64, // 64KB max message size + clientTracking: true, + }, + }); + + // =================== + // Authentication Decorator + // =================== + + app.decorate('authenticate', async (request: FastifyRequest, _reply: FastifyReply) => { + try { + await request.jwtVerify(); + } catch { + throw new AuthenticationError('Invalid or expired token'); + } + }); + + // =================== + // Request Logging Hook + // =================== + + app.addHook('onRequest', (request, _reply, done) => { + // Generate trace ID for request correlation + const existingTraceId = request.headers['x-trace-id'] as string | undefined; + const traceId = existingTraceId ?? crypto.randomUUID(); + request.headers['x-trace-id'] = traceId; + + // Attach child logger with trace ID + (request as FastifyRequest & { log: typeof logger }).log = logger.child({ traceId }); + done(); + }); + + app.addHook('onResponse', (request, reply, done) => { + const { method, url } = request; + const { statusCode } = reply; + const responseTime = reply.elapsedTime; + + // Don't log health checks to reduce noise + if (url === '/health') { + done(); + return; + } + + logger.info( + { + method, + url, + statusCode, + responseTime: `${responseTime.toFixed(2)}ms`, + traceId: request.headers['x-trace-id'], + }, + 'Request completed' + ); + done(); + }); + + // =================== + // Global Error Handler + // =================== + + app.setErrorHandler((error, request, reply) => { + const appError = toAppError(error); + const traceId = request.headers['x-trace-id'] as string; + + appError.traceId = traceId; + + // Log based on error type + if (appError.isOperational) { + logger.warn( + { + code: appError.code, + message: appError.message, + traceId, + }, + 'Operational error' + ); + } else { + logger.error( + { + code: appError.code, + message: appError.message, + stack: appError.stack, + traceId, + }, + 'Unexpected error' + ); + } + + // Send error response + return reply.status(appError.statusCode).send({ + error: appError.toJSON(), + }); + }); + + // =================== + // Health Check + // =================== + + app.get('/health', async (_request, reply) => { + const [dbHealthy, redisHealthy] = await Promise.all([isDbHealthy(), isRedisHealthy()]); + + const status = dbHealthy && redisHealthy ? 'healthy' : 'degraded'; + + const health = { + status, + timestamp: new Date().toISOString(), + uptime: process.uptime(), + version: process.env.npm_package_version ?? '0.0.0', + services: { + database: dbHealthy ? 'up' : 'down', + redis: redisHealthy ? 'up' : 'down', + }, + connections: { + websocket: getConnectionCount(), + }, + }; + + const statusCode = status === 'healthy' ? 200 : 503; + return reply.status(statusCode).send(health); + }); + + // =================== + // API Routes + // =================== + + // Prefix all API routes with /api/v1 + app.register( + (api, _opts, done) => { + api.register(authRoutes, { prefix: '/auth' }); + api.register(userRoutes, { prefix: '/users' }); + api.register(friendRoutes, { prefix: '/friends' }); + done(); + }, + { prefix: '/api/v1' } + ); + + // Also register auth at root for OAuth redirects + app.register(authRoutes, { prefix: '/auth' }); + + // =================== + // WebSocket Handler + // =================== + + registerWebSocketHandler(app); + + return app; +} + +/** + * Start the server with graceful shutdown. + */ +async function start(): Promise { + let app: Awaited> | null = null; + + try { + // Build server + app = await buildServer(); + + // Connect to services + logger.info('Connecting to services...'); + await Promise.all([connectDb(), connectRedis()]); + + // Start listening + await app.listen({ + host: env.HOST, + port: env.PORT, + }); + + const serverUrl = `http://${env.HOST}:${String(env.PORT)}`; + logger.info( + { + host: env.HOST, + port: env.PORT, + environment: env.NODE_ENV, + }, + `🚀 DevRadar server started at ${serverUrl}` + ); + + // =================== + // Graceful Shutdown + // =================== + + const shutdown = (signal: string): void => { + logger.info({ signal }, 'Shutdown signal received'); + + // Set a timeout for graceful shutdown + const shutdownTimeout = setTimeout(() => { + logger.error('Graceful shutdown timed out, forcing exit'); + throw new Error('Shutdown timeout'); + }, 30_000); + + Promise.all([ + app?.close().then(() => { + logger.info('HTTP server closed'); + }), + disconnectDb(), + disconnectRedis(), + ]) + .then(() => { + clearTimeout(shutdownTimeout); + logger.info('Graceful shutdown complete'); + }) + .catch((error: unknown) => { + logger.error({ error }, 'Error during shutdown'); + clearTimeout(shutdownTimeout); + throw error; + }); + }; + + process.on('SIGTERM', () => { + shutdown('SIGTERM'); + }); + process.on('SIGINT', () => { + shutdown('SIGINT'); + }); + + // Handle uncaught errors + process.on('uncaughtException', (error) => { + logger.fatal({ error }, 'Uncaught exception'); + shutdown('uncaughtException'); + }); + + process.on('unhandledRejection', (reason) => { + logger.fatal({ reason }, 'Unhandled rejection'); + shutdown('unhandledRejection'); + }); + } catch (error) { + logger.fatal({ error }, 'Failed to start server'); + throw error; + } +} + +// Start the server +void start(); diff --git a/apps/server/src/services/db.ts b/apps/server/src/services/db.ts new file mode 100644 index 0000000..d71ab7d --- /dev/null +++ b/apps/server/src/services/db.ts @@ -0,0 +1,85 @@ +/** + * Database Service + * + * Prisma 7 client with PostgreSQL adapter for direct database connections. + * Follows hexagonal architecture - this is a secondary adapter. + */ + +import { PrismaPg } from '@prisma/adapter-pg'; + +import { env, isDevelopment } from '@/config'; +import { PrismaClient } from '@/generated/prisma/client'; +import { logger } from '@/lib/logger'; + +/** + * Prisma client singleton instance. + */ +let prisma: PrismaClient | null = null; + +/** + * Get or create the Prisma client instance. + * Uses Prisma 7's adapter pattern for database connections. + * + * @returns Prisma client instance + */ +export function getDb(): PrismaClient { + if (!prisma) { + // Prisma 7 uses adapter pattern with connectionString + const adapter = new PrismaPg({ connectionString: env.DATABASE_URL }); + + prisma = new PrismaClient({ + adapter, + log: isDevelopment ? ['query', 'info', 'warn', 'error'] : ['error'], + }); + + logger.info('Database client initialized with Prisma 7 adapter'); + } + + return prisma; +} + +/** + * Connect to the database. + * Call this during server startup. + */ +export async function connectDb(): Promise { + const db = getDb(); + + try { + await db.$connect(); + logger.info('✅ Connected to PostgreSQL database'); + } catch (error) { + logger.fatal({ error }, '❌ Failed to connect to database'); + throw error; + } +} + +/** + * Disconnect from the database. + * Call this during graceful shutdown. + */ +export async function disconnectDb(): Promise { + if (prisma) { + await prisma.$disconnect(); + prisma = null; + logger.info('Prisma client disconnected'); + } +} + +/** + * Health check - verify database connectivity. + * + * @returns true if database is reachable + */ +export async function isDbHealthy(): Promise { + try { + const db = getDb(); + await db.$queryRaw`SELECT 1`; + return true; + } catch { + return false; + } +} + +// Export db as convenient alias +export const db = getDb(); diff --git a/apps/server/src/services/github.ts b/apps/server/src/services/github.ts new file mode 100644 index 0000000..f801c39 --- /dev/null +++ b/apps/server/src/services/github.ts @@ -0,0 +1,207 @@ +/** + * GitHub OAuth Service + * + * Handles GitHub OAuth 2.0 flow for authentication. + * Creates or updates user records on successful authentication. + */ + +import { env } from '@/config'; +import { AuthenticationError, InternalError } from '@/lib/errors'; +import { logger } from '@/lib/logger'; +import { getDb } from '@/services/db'; + +/** + * User type from database. + */ +interface User { + id: string; + githubId: string; + username: string; + displayName: string | null; + avatarUrl: string | null; + email: string | null; + tier: string; + privacyMode: boolean; + createdAt: Date; + updatedAt: Date; +} + +/** + * GitHub OAuth endpoints. + */ +const GITHUB_OAUTH_URL = 'https://github.com/login/oauth/authorize'; +const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token'; +const GITHUB_USER_URL = 'https://api.github.com/user'; + +/** + * GitHub user profile from API. + */ +interface GitHubUser { + id: number; + login: string; + name: string | null; + avatar_url: string | null; + email: string | null; +} + +/** + * OAuth token response from GitHub. + */ +interface TokenResponse { + access_token: string; + token_type: string; + scope: string; + error?: string; + error_description?: string; +} + +/** + * Generate the GitHub OAuth authorization URL. + * + * @param state - Optional state parameter for CSRF protection + * @returns Authorization URL to redirect user to + */ +export function getGitHubAuthUrl(state?: string): string { + const params = new URLSearchParams({ + client_id: env.GITHUB_CLIENT_ID, + redirect_uri: env.GITHUB_CALLBACK_URL, + scope: 'read:user user:email', + allow_signup: 'true', + }); + + if (state) { + params.set('state', state); + } + + return `${GITHUB_OAUTH_URL}?${params.toString()}`; +} + +/** + * Exchange authorization code for access token. + * + * @param code - Authorization code from GitHub callback + * @returns Access token + * @throws AuthenticationError if exchange fails + */ +async function exchangeCodeForToken(code: string): Promise { + const response = await fetch(GITHUB_TOKEN_URL, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + client_id: env.GITHUB_CLIENT_ID, + client_secret: env.GITHUB_CLIENT_SECRET, + code, + redirect_uri: env.GITHUB_CALLBACK_URL, + }), + }); + + if (!response.ok) { + logger.error({ status: response.status }, 'GitHub token exchange failed'); + throw new AuthenticationError('Failed to authenticate with GitHub'); + } + + const data = (await response.json()) as TokenResponse; + + if (data.error) { + logger.error({ error: data.error, description: data.error_description }, 'GitHub OAuth error'); + throw new AuthenticationError(data.error_description ?? 'GitHub authentication failed'); + } + + return data.access_token; +} + +/** + * Fetch GitHub user profile using access token. + * + * @param accessToken - GitHub access token + * @returns GitHub user profile + * @throws AuthenticationError if fetch fails + */ +async function fetchGitHubUser(accessToken: string): Promise { + const response = await fetch(GITHUB_USER_URL, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github.v3+json', + }, + }); + + if (!response.ok) { + logger.error({ status: response.status }, 'Failed to fetch GitHub user'); + throw new AuthenticationError('Failed to fetch GitHub user profile'); + } + + return (await response.json()) as GitHubUser; +} + +/** + * Authenticate user with GitHub OAuth. + * Creates new user if not exists, updates if exists. + * + * @param code - Authorization code from GitHub callback + * @returns Authenticated user + * @throws AuthenticationError if authentication fails + */ +export async function authenticateWithGitHub(code: string): Promise { + try { + // Exchange code for access token + const accessToken = await exchangeCodeForToken(code); + + // Fetch user profile + const githubUser = await fetchGitHubUser(accessToken); + + // Upsert user in database + const db = getDb(); + const user = await db.user.upsert({ + where: { githubId: String(githubUser.id) }, + create: { + githubId: String(githubUser.id), + username: githubUser.login, + displayName: githubUser.name, + avatarUrl: githubUser.avatar_url, + email: githubUser.email, + }, + update: { + username: githubUser.login, + displayName: githubUser.name, + avatarUrl: githubUser.avatar_url, + email: githubUser.email, + }, + }); + + logger.info({ userId: user.id, username: user.username }, 'User authenticated via GitHub'); + + return user as User; + } catch (error) { + if (error instanceof AuthenticationError) { + throw error; + } + + logger.error({ error }, 'GitHub authentication error'); + throw new InternalError('Authentication failed', { cause: error as Error }); + } +} + +/** + * Get user by ID. + * + * @param userId - User ID + * @returns User or null if not found + */ +export async function getUserById(userId: string): Promise { + const db = getDb(); + return db.user.findUnique({ where: { id: userId } }) as Promise; +} + +/** + * Get user by GitHub ID. + * + * @param githubId - GitHub user ID + * @returns User or null if not found + */ +export async function getUserByGitHubId(githubId: string): Promise { + const db = getDb(); + return db.user.findUnique({ where: { githubId } }) as Promise; +} diff --git a/apps/server/src/services/redis.ts b/apps/server/src/services/redis.ts new file mode 100644 index 0000000..4c7b804 --- /dev/null +++ b/apps/server/src/services/redis.ts @@ -0,0 +1,233 @@ +/** + * Redis Service + * + * ioredis client with: + * - Separate clients for commands and pub/sub (required by Redis protocol) + * - Connection health monitoring + * - Graceful disconnect + */ + +import { PRESENCE_TTL_SECONDS, REDIS_KEYS } from '@devradar/shared'; +import { Redis, type RedisOptions } from 'ioredis'; + +import { env } from '@/config'; +import { logger } from '@/lib/logger'; + +/** + * Redis client instances. + * We need separate clients for regular commands and pub/sub. + */ +let commandClient: Redis | null = null; +let subscribeClient: Redis | null = null; +let publishClient: Redis | null = null; + +/** + * Common Redis options for all clients. + */ +const commonOptions: RedisOptions = { + maxRetriesPerRequest: 3, + retryStrategy: (times: number) => { + if (times > 10) { + logger.error('Max Redis reconnection attempts reached'); + return null; // Stop retrying + } + const delay = Math.min(times * 100, 3000); + logger.warn({ attempt: times, delay }, 'Retrying Redis connection'); + return delay; + }, + lazyConnect: true, +}; + +/** + * Create a Redis client with event handlers. + */ +function createClient(name: string): Redis { + const client = new Redis(env.REDIS_URL, { + ...commonOptions, + connectionName: `devradar-${name}`, + }); + + client.on('connect', () => { + logger.debug({ client: name }, 'Redis connecting'); + }); + + client.on('ready', () => { + logger.info({ client: name }, '✅ Redis connected'); + }); + + client.on('error', (error: Error) => { + logger.error({ client: name, error }, 'Redis error'); + }); + + client.on('close', () => { + logger.debug({ client: name }, 'Redis connection closed'); + }); + + return client; +} + +/** + * Get the command Redis client (for SET, GET, etc.). + */ +export function getRedis(): Redis { + commandClient ??= createClient('command'); + return commandClient; +} + +/** + * Get the subscribe Redis client (for subscribing to channels). + * Must be a separate client from command client. + */ +export function getRedisSubscriber(): Redis { + subscribeClient ??= createClient('subscriber'); + return subscribeClient; +} + +/** + * Get the publish Redis client (for publishing to channels). + */ +export function getRedisPublisher(): Redis { + publishClient ??= createClient('publisher'); + return publishClient; +} + +/** + * Connect all Redis clients. + * Call this during server startup. + */ +export async function connectRedis(): Promise { + try { + const cmd = getRedis(); + const sub = getRedisSubscriber(); + const pub = getRedisPublisher(); + + await Promise.all([cmd.connect(), sub.connect(), pub.connect()]); + + logger.info('✅ All Redis clients connected'); + } catch (error) { + logger.fatal({ error }, '❌ Failed to connect to Redis'); + throw error; + } +} + +/** + * Disconnect all Redis clients. + * Call this during graceful shutdown. + */ +export async function disconnectRedis(): Promise { + const clients = [commandClient, subscribeClient, publishClient].filter(Boolean) as Redis[]; + + await Promise.all(clients.map((client) => client.quit())); + + commandClient = null; + subscribeClient = null; + publishClient = null; + + logger.info('Redis connections closed'); +} + +/** + * Health check - verify Redis connectivity. + */ +export async function isRedisHealthy(): Promise { + try { + const redis = getRedis(); + const pong = await redis.ping(); + return pong.toUpperCase() === 'PONG'; + } catch { + return false; + } +} + +// =================== +// Presence Helpers +// =================== + +interface PresenceData { + userId: string; + status: string; + activity?: Record | undefined; + updatedAt: number; +} + +/** + * Set user presence with TTL. + * + * @param userId - User ID + * @param data - Presence data + */ +export async function setPresence(userId: string, data: PresenceData): Promise { + const redis = getRedis(); + const key = REDIS_KEYS.presence(userId); + + await redis.setex(key, PRESENCE_TTL_SECONDS, JSON.stringify(data)); + + // Publish presence update for real-time subscribers + const pub = getRedisPublisher(); + await pub.publish(REDIS_KEYS.presenceChannel(userId), JSON.stringify(data)); +} + +/** + * Get user presence. + * + * @param userId - User ID + * @returns Presence data or null if not found/expired + */ +export async function getPresence(userId: string): Promise { + const redis = getRedis(); + const key = REDIS_KEYS.presence(userId); + + const data = await redis.get(key); + if (!data) return null; + + try { + return JSON.parse(data) as PresenceData; + } catch { + return null; + } +} + +/** + * Delete user presence. + * + * @param userId - User ID + */ +export async function deletePresence(userId: string): Promise { + const redis = getRedis(); + const key = REDIS_KEYS.presence(userId); + + await redis.del(key); +} + +/** + * Get multiple user presences at once. + * + * @param userIds - Array of user IDs + * @returns Map of userId -> PresenceData + */ +export async function getPresences(userIds: string[]): Promise> { + if (userIds.length === 0) { + return new Map(); + } + + const redis = getRedis(); + const keys = userIds.map((id) => REDIS_KEYS.presence(id)); + + const results = await redis.mget(...keys); + const presenceMap = new Map(); + + results.forEach((data: string | null, index: number) => { + if (data) { + try { + const userId = userIds[index]; + if (userId) { + presenceMap.set(userId, JSON.parse(data) as PresenceData); + } + } catch { + // Skip invalid data + } + } + }); + + return presenceMap; +} diff --git a/apps/server/src/types/fastify.d.ts b/apps/server/src/types/fastify.d.ts new file mode 100644 index 0000000..871edc7 --- /dev/null +++ b/apps/server/src/types/fastify.d.ts @@ -0,0 +1,28 @@ +/** + * Fastify Type Extensions + * + * Extends Fastify's type system for custom decorators. + */ + +import type { FastifyRequest, FastifyReply } from 'fastify'; + +declare module 'fastify' { + interface FastifyInstance { + authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise; + } +} + +declare module '@fastify/jwt' { + interface FastifyJWT { + payload: { + userId: string; + username: string; + tier: string; + }; + user: { + userId: string; + username: string; + tier: string; + }; + } +} diff --git a/apps/server/src/ws/handler.ts b/apps/server/src/ws/handler.ts new file mode 100644 index 0000000..75003b2 --- /dev/null +++ b/apps/server/src/ws/handler.ts @@ -0,0 +1,386 @@ +/** + * WebSocket Handler + * + * Handles WebSocket connections with: + * - JWT authentication on connection + * - Message validation with Zod + * - Heartbeat processing + * - Presence broadcasting + */ + +import { + UserStatusTypeSchema, + ActivityPayloadSchema, + PokePayloadSchema, + REDIS_KEYS, +} from '@devradar/shared'; +import { z } from 'zod'; + +import type { + AuthenticatedWebSocket, + WsMessage, + PokePayload, + FriendStatusPayload, + ConnectedPayload, + ErrorPayload, +} from '@/ws/types'; +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import type { WebSocket as WsSocket } from 'ws'; + +import { logger, createChildLogger } from '@/lib/logger'; +import { getDb } from '@/services/db'; +import { setPresence, getPresences, deletePresence, getRedisSubscriber } from '@/services/redis'; +import { WsCloseCodes } from '@/ws/types'; + +/** + * Inbound message schema. + */ +const InboundMessageSchema = z.object({ + type: z.enum(['HEARTBEAT', 'POKE', 'SUBSCRIBE', 'UNSUBSCRIBE']), + payload: z.unknown(), + timestamp: z.number().int().positive(), + correlationId: z.string().optional(), +}); + +/** + * Heartbeat message schema. + */ +const HeartbeatMessageSchema = z.object({ + status: UserStatusTypeSchema, + activity: ActivityPayloadSchema.optional(), +}); + +/** + * Active WebSocket connections mapped by userId. + */ +const connections = new Map(); + +/** + * Send a message to a WebSocket client. + */ +function send(ws: WsSocket, type: string, payload: unknown, correlationId?: string): void { + if (ws.readyState !== ws.OPEN) return; + + const message: WsMessage = { + type: type as WsMessage['type'], + payload, + timestamp: Date.now(), + }; + + if (correlationId) { + message.correlationId = correlationId; + } + + ws.send(JSON.stringify(message)); +} + +/** + * Send error to client. + */ +function sendError(ws: WsSocket, code: string, message: string, correlationId?: string): void { + const payload: ErrorPayload = { code, message }; + send(ws, 'ERROR', payload, correlationId); +} + +/** + * Follow type for database query. + */ +interface FollowResult { + followingId: string; +} + +/** + * Get user's friend IDs (users they are following). + */ +async function getUserFriendIds(userId: string): Promise { + const db = getDb(); + const follows = (await db.follow.findMany({ + where: { followerId: userId }, + select: { followingId: true }, + })) as FollowResult[]; + return follows.map((f: FollowResult) => f.followingId); +} + +/** + * Subscribe to friend presence updates via Redis pub/sub. + */ +async function subscribeToFriends(ws: AuthenticatedWebSocket): Promise { + const subscriber = getRedisSubscriber(); + + for (const friendId of ws.friendIds) { + const channel = REDIS_KEYS.presenceChannel(friendId); + + // Subscribe to channel + await subscriber.subscribe(channel); + + logger.debug({ userId: ws.userId, friendId, channel }, 'Subscribed to friend presence'); + } + + // Handle incoming presence updates + subscriber.on('message', (channel: string, messageData: string) => { + try { + const data = JSON.parse(messageData) as FriendStatusPayload; + send(ws, 'FRIEND_STATUS', data); + } catch (error) { + logger.error({ error, channel }, 'Failed to parse presence message'); + } + }); +} + +/** + * Handle heartbeat message. + */ +async function handleHeartbeat( + ws: AuthenticatedWebSocket, + payload: unknown, + correlationId?: string +): Promise { + const result = HeartbeatMessageSchema.safeParse(payload); + + if (!result.success) { + sendError(ws, 'INVALID_PAYLOAD', 'Invalid heartbeat payload', correlationId); + return; + } + + const { status, activity } = result.data; + + // Update presence in Redis + await setPresence(ws.userId, { + userId: ws.userId, + status: status as string, + activity: activity as Record | undefined, + updatedAt: Date.now(), + }); + + ws.lastHeartbeat = Date.now(); + + // Send pong + send(ws, 'PONG', { timestamp: Date.now() }, correlationId); +} + +/** + * Handle poke message. + */ +function handlePoke(ws: AuthenticatedWebSocket, payload: unknown, correlationId?: string): void { + const result = PokePayloadSchema.safeParse({ + ...(payload as Record), + fromUserId: ws.userId, + }); + + if (!result.success) { + sendError(ws, 'INVALID_PAYLOAD', 'Invalid poke payload', correlationId); + return; + } + + const { toUserId, message } = result.data; + + // Check if target user is a friend + if (!ws.friendIds.includes(toUserId)) { + sendError(ws, 'NOT_FRIEND', 'You can only poke friends', correlationId); + return; + } + + // Get target connection + const targetWs = connections.get(toUserId); + + if (targetWs && targetWs.readyState === targetWs.OPEN) { + const pokePayload: PokePayload = { + toUserId, + fromUserId: ws.userId, + }; + if (message) { + pokePayload.message = message; + } + send(targetWs, 'POKE', pokePayload); + } else { + // User is offline - could queue for later or just acknowledge + logger.debug({ from: ws.userId, to: toUserId }, 'Poke target offline'); + } +} + +/** + * Handle incoming WebSocket message. + */ +async function handleMessage(ws: AuthenticatedWebSocket, data: string): Promise { + const log = createChildLogger({ userId: ws.userId }); + + try { + const parsed: unknown = JSON.parse(data); + const result = InboundMessageSchema.safeParse(parsed); + + if (!result.success) { + sendError(ws, 'INVALID_MESSAGE', 'Invalid message format'); + return; + } + + const { type, payload, correlationId } = result.data; + + log.debug({ type }, 'Received WebSocket message'); + + switch (type) { + case 'HEARTBEAT': + await handleHeartbeat(ws, payload, correlationId); + break; + + case 'POKE': + handlePoke(ws, payload, correlationId); + break; + + default: + sendError(ws, 'UNKNOWN_TYPE', `Unknown message type: ${type}`, correlationId); + } + } catch (error) { + log.error({ error }, 'Error handling WebSocket message'); + sendError(ws, 'INTERNAL_ERROR', 'Failed to process message'); + } +} + +/** + * Handle connection close. + */ +async function handleClose(ws: AuthenticatedWebSocket, code: number): Promise { + const log = createChildLogger({ userId: ws.userId }); + + log.info({ code }, 'WebSocket connection closed'); + + // Remove from connections + connections.delete(ws.userId); + + // Set user offline after grace period (60 seconds) + // This allows for quick reconnections without appearing offline + const userId = ws.userId; + setTimeout(() => { + const currentConnection = connections.get(userId); + if (!currentConnection) { + void deletePresence(userId).then(() => { + log.debug('User set to offline after grace period'); + }); + } + }, 60_000); + + // Unsubscribe from friend channels + const subscriber = getRedisSubscriber(); + for (const friendId of ws.friendIds) { + const channel = REDIS_KEYS.presenceChannel(friendId); + await subscriber.unsubscribe(channel); + } +} + +/** + * Register WebSocket handler with Fastify. + */ +export function registerWebSocketHandler(app: FastifyInstance): void { + app.get('/ws', { websocket: true }, async (socket: WsSocket, request: FastifyRequest) => { + const ws = socket as AuthenticatedWebSocket; + + try { + // Extract token from query string + const token = (request.query as Record).token; + + if (!token) { + ws.close(WsCloseCodes.UNAUTHORIZED, 'Token required'); + return; + } + + // Verify JWT + let decoded: { userId: string }; + try { + decoded = await request.jwtVerify<{ userId: string }>({ onlyCookie: false }); + } catch { + // Try from query token as header might not be available + try { + decoded = app.jwt.verify<{ userId: string }>(token); + } catch { + ws.close(WsCloseCodes.INVALID_TOKEN, 'Invalid token'); + return; + } + } + + const { userId } = decoded; + + // Set up authenticated connection + ws.userId = userId; + ws.isAuthenticated = true; + ws.connectedAt = Date.now(); + ws.lastHeartbeat = Date.now(); + + // Get user's friends + ws.friendIds = await getUserFriendIds(userId); + + // Store connection + const existingConnection = connections.get(userId); + if (existingConnection) { + existingConnection.close(WsCloseCodes.GOING_AWAY, 'New connection established'); + } + connections.set(userId, ws); + + // Subscribe to friend presence updates + await subscribeToFriends(ws); + + // Get initial friend presences + const friendPresences = await getPresences(ws.friendIds); + + // Send connected message with initial friend statuses + const connectedPayload: ConnectedPayload = { + userId, + friendCount: ws.friendIds.length, + }; + send(ws, 'CONNECTED', connectedPayload); + + // Send initial friend statuses + const presenceEntries = Array.from(friendPresences.entries()); + for (const [friendId, presence] of presenceEntries) { + const payload: FriendStatusPayload = { + userId: friendId, + status: presence.status as FriendStatusPayload['status'], + updatedAt: presence.updatedAt, + }; + if (presence.activity) { + payload.activity = presence.activity as unknown as FriendStatusPayload['activity']; + } + send(ws, 'FRIEND_STATUS', payload); + } + + logger.info({ userId, friendCount: ws.friendIds.length }, 'WebSocket connection established'); + + // Handle messages + ws.on('message', (data: Buffer) => { + void handleMessage(ws, data.toString()); + }); + + // Handle close + ws.on('close', (closeCode: number) => { + void handleClose(ws, closeCode); + }); + + // Handle errors + ws.on('error', (error: Error) => { + logger.error({ error, userId }, 'WebSocket error'); + }); + } catch (error) { + logger.error({ error }, 'WebSocket connection error'); + ws.close(WsCloseCodes.SERVER_ERROR, 'Internal server error'); + } + }); + + logger.info('WebSocket handler registered at /ws'); +} + +/** + * Broadcast a message to specific users. + */ +export function broadcastToUsers(userIds: string[], type: string, payload: unknown): void { + for (const userId of userIds) { + const ws = connections.get(userId); + if (ws && ws.readyState === ws.OPEN) { + send(ws, type, payload); + } + } +} + +/** + * Get count of active connections. + */ +export function getConnectionCount(): number { + return connections.size; +} diff --git a/apps/server/src/ws/types.ts b/apps/server/src/ws/types.ts new file mode 100644 index 0000000..c2e9799 --- /dev/null +++ b/apps/server/src/ws/types.ts @@ -0,0 +1,134 @@ +/** + * WebSocket Types + * + * Type definitions for WebSocket communication. + */ + +import type { UserStatusType, ActivityPayload as SharedActivityPayload } from '@devradar/shared'; +import type { WebSocket } from 'ws'; + +/** + * Extended WebSocket with user context. + */ +export interface AuthenticatedWebSocket extends WebSocket { + /** User ID from JWT */ + userId: string; + + /** User's friends (following) for presence subscriptions */ + friendIds: string[]; + + /** Whether the connection is authenticated */ + isAuthenticated: boolean; + + /** Last heartbeat timestamp */ + lastHeartbeat: number; + + /** Connection established timestamp */ + connectedAt: number; +} + +/** + * Inbound WebSocket message types. + */ +export type InboundMessageType = 'HEARTBEAT' | 'POKE' | 'SUBSCRIBE' | 'UNSUBSCRIBE'; + +/** + * Outbound WebSocket message types. + */ +export type OutboundMessageType = + | 'STATUS_UPDATE' + | 'FRIEND_STATUS' + | 'POKE' + | 'ERROR' + | 'CONNECTED' + | 'PONG'; + +/** + * Base WebSocket message structure. + */ +export interface WsMessage { + type: InboundMessageType | OutboundMessageType; + payload: T; + timestamp: number; + correlationId?: string | undefined; +} + +/** + * Heartbeat payload (inbound). + */ +export interface HeartbeatPayload { + status: UserStatusType; + activity?: SharedActivityPayload | undefined; +} + +/** + * Poke payload (inbound/outbound). + */ +export interface PokePayload { + toUserId: string; + fromUserId?: string | undefined; + message?: string | undefined; +} + +/** + * Friend status update payload (outbound). + */ +export interface FriendStatusPayload { + userId: string; + status: UserStatusType; + activity?: SharedActivityPayload | undefined; + updatedAt: number; +} + +/** + * Connected payload (outbound). + */ +export interface ConnectedPayload { + userId: string; + friendCount: number; +} + +/** + * Error payload (outbound). + */ +export interface ErrorPayload { + code: string; + message: string; + details?: Record | undefined; +} + +/** + * WebSocket close codes. + */ +export const WsCloseCodes = { + NORMAL: 1000, + GOING_AWAY: 1001, + PROTOCOL_ERROR: 1002, + INVALID_DATA: 1003, + POLICY_VIOLATION: 1008, + MESSAGE_TOO_BIG: 1009, + SERVER_ERROR: 1011, + + // Custom codes (4000-4999) + UNAUTHORIZED: 4001, + INVALID_TOKEN: 4002, + TOKEN_EXPIRED: 4003, + RATE_LIMITED: 4029, +} as const; + +/** + * WebSocket close reasons. + */ +export const WsCloseReasons: Record = { + [WsCloseCodes.NORMAL]: 'Normal closure', + [WsCloseCodes.GOING_AWAY]: 'Going away', + [WsCloseCodes.PROTOCOL_ERROR]: 'Protocol error', + [WsCloseCodes.INVALID_DATA]: 'Invalid data', + [WsCloseCodes.POLICY_VIOLATION]: 'Policy violation', + [WsCloseCodes.MESSAGE_TOO_BIG]: 'Message too big', + [WsCloseCodes.SERVER_ERROR]: 'Server error', + [WsCloseCodes.UNAUTHORIZED]: 'Unauthorized', + [WsCloseCodes.INVALID_TOKEN]: 'Invalid token', + [WsCloseCodes.TOKEN_EXPIRED]: 'Token expired', + [WsCloseCodes.RATE_LIMITED]: 'Rate limited', +}; diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json new file mode 100644 index 0000000..478c0cd --- /dev/null +++ b/apps/server/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "@devradar/tsconfig/node.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "rootDir": "src", + "outDir": "dist", + "tsBuildInfoFile": "tsconfig.tsbuildinfo", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index 578bbc6..745801c 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -5,6 +5,7 @@ "license": "MIT", "type": "module", "exports": { + ".": "./node.js", "./base": "./base.js", "./node": "./node.js", "./nextjs": "./nextjs.js" @@ -13,15 +14,15 @@ "*.js" ], "dependencies": { - "@eslint/js": "^9.17.0", + "@eslint/js": "^9.39.2", "@next/eslint-plugin-next": "^16.1.1", - "@typescript-eslint/eslint-plugin": "^8.21.0", - "@typescript-eslint/parser": "^8.21.0", + "@typescript-eslint/eslint-plugin": "^8.51.0", + "@typescript-eslint/parser": "^8.51.0", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-import": "^2.31.0", + "eslint-plugin-import": "^2.32.0", "eslint-plugin-only-warn": "^1.1.0", "globals": "^16.5.0", - "typescript-eslint": "^8.21.0" + "typescript-eslint": "^8.51.0" }, "peerDependencies": { "eslint": "^9.17.0", diff --git a/packages/shared/package.json b/packages/shared/package.json index 87d325b..d3e2875 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -36,13 +36,13 @@ "clean": "rimraf dist" }, "dependencies": { - "zod": "^4.3.4" + "zod": "^3.24.0" }, "devDependencies": { "@devradar/eslint-config": "workspace:*", "@devradar/tsconfig": "workspace:*", - "eslint": "^9.17.0", - "rimraf": "^6.0.1", - "typescript": "^5.7.2" + "eslint": "^9.39.2", + "rimraf": "^6.1.2", + "typescript": "^5.9.3" } } diff --git a/packages/shared/src/validators.ts b/packages/shared/src/validators.ts index d876337..e05478a 100644 --- a/packages/shared/src/validators.ts +++ b/packages/shared/src/validators.ts @@ -1,4 +1,4 @@ -import { z } from 'zod/v4'; +import { z } from 'zod'; /** * User status schema. @@ -57,7 +57,7 @@ export const WebSocketMessageSchema = z.object({ type: MessageTypeSchema, payload: z.unknown(), timestamp: z.number().int().positive(), - correlationId: z.uuid().optional(), + correlationId: z.string().uuid().optional(), }); /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae7040b..612c769 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,7 +16,7 @@ importers: version: 25.0.3 eslint: specifier: ^9.39.2 - version: 9.39.2 + version: 9.39.2(jiti@2.6.1) husky: specifier: ^9.1.7 version: 9.1.7 @@ -36,29 +36,108 @@ importers: specifier: ^5.9.3 version: 5.9.3 + apps/server: + dependencies: + '@devradar/shared': + specifier: workspace:* + version: link:../../packages/shared + '@fastify/cors': + specifier: ^11.2.0 + version: 11.2.0 + '@fastify/helmet': + specifier: ^13.0.2 + version: 13.0.2 + '@fastify/jwt': + specifier: ^10.0.0 + version: 10.0.0 + '@fastify/rate-limit': + specifier: ^10.3.0 + version: 10.3.0 + '@fastify/websocket': + specifier: ^11.2.0 + version: 11.2.0 + '@prisma/adapter-pg': + specifier: ^7.2.0 + version: 7.2.0 + '@prisma/client': + specifier: ^7.2.0 + version: 7.2.0(prisma@7.2.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3) + dotenv: + specifier: ^16.5.0 + version: 16.6.1 + fastify: + specifier: ^5.6.2 + version: 5.6.2 + ioredis: + specifier: ^5.8.2 + version: 5.8.2 + pg: + specifier: ^8.16.3 + version: 8.16.3 + pino: + specifier: ^10.1.0 + version: 10.1.0 + pino-pretty: + specifier: ^13.1.3 + version: 13.1.3 + zod: + specifier: ^3.24.0 + version: 3.25.76 + devDependencies: + '@devradar/eslint-config': + specifier: workspace:* + version: link:../../packages/eslint-config + '@devradar/tsconfig': + specifier: workspace:* + version: link:../../packages/tsconfig + '@types/node': + specifier: ^25.0.3 + version: 25.0.3 + '@types/pg': + specifier: ^8.16.0 + version: 8.16.0 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + eslint: + specifier: ^9.39.2 + version: 9.39.2(jiti@2.6.1) + prisma: + specifier: ^7.2.0 + version: 7.2.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) + rimraf: + specifier: ^6.1.2 + version: 6.1.2 + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + packages/eslint-config: dependencies: '@eslint/js': - specifier: ^9.17.0 + specifier: ^9.39.2 version: 9.39.2 '@next/eslint-plugin-next': specifier: ^16.1.1 version: 16.1.1 '@typescript-eslint/eslint-plugin': - specifier: ^8.21.0 - version: 8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3) + specifier: ^8.51.0 + version: 8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': - specifier: ^8.21.0 - version: 8.51.0(eslint@9.39.2)(typescript@5.9.3) + specifier: ^8.51.0 + version: 8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint: specifier: ^9.17.0 - version: 9.39.2 + version: 9.39.2(jiti@2.6.1) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.2) + version: 10.1.8(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-import: - specifier: ^2.31.0 - version: 2.32.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2) + specifier: ^2.32.0 + version: 2.32.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-only-warn: specifier: ^1.1.0 version: 1.1.0 @@ -69,14 +148,14 @@ importers: specifier: ^5.7.0 version: 5.9.3 typescript-eslint: - specifier: ^8.21.0 - version: 8.51.0(eslint@9.39.2)(typescript@5.9.3) + specifier: ^8.51.0 + version: 8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) packages/shared: dependencies: zod: - specifier: ^4.3.4 - version: 4.3.4 + specifier: ^3.24.0 + version: 3.25.76 devDependencies: '@devradar/eslint-config': specifier: workspace:* @@ -85,19 +164,201 @@ importers: specifier: workspace:* version: link:../tsconfig eslint: - specifier: ^9.17.0 - version: 9.39.2 + specifier: ^9.39.2 + version: 9.39.2(jiti@2.6.1) rimraf: - specifier: ^6.0.1 + specifier: ^6.1.2 version: 6.1.2 typescript: - specifier: ^5.7.2 + specifier: ^5.9.3 version: 5.9.3 packages/tsconfig: {} packages: + '@chevrotain/cst-dts-gen@10.5.0': + resolution: {integrity: sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==} + + '@chevrotain/gast@10.5.0': + resolution: {integrity: sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A==} + + '@chevrotain/types@10.5.0': + resolution: {integrity: sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A==} + + '@chevrotain/utils@10.5.0': + resolution: {integrity: sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==} + + '@electric-sql/pglite-socket@0.0.6': + resolution: {integrity: sha512-6RjmgzphIHIBA4NrMGJsjNWK4pu+bCWJlEWlwcxFTVY3WT86dFpKwbZaGWZV6C5Rd7sCk1Z0CI76QEfukLAUXw==} + hasBin: true + peerDependencies: + '@electric-sql/pglite': 0.3.2 + + '@electric-sql/pglite-tools@0.2.7': + resolution: {integrity: sha512-9dAccClqxx4cZB+Ar9B+FZ5WgxDc/Xvl9DPrTWv+dYTf0YNubLzi4wHHRGRGhrJv15XwnyKcGOZAP1VXSneSUg==} + peerDependencies: + '@electric-sql/pglite': 0.3.2 + + '@electric-sql/pglite@0.3.2': + resolution: {integrity: sha512-zfWWa+V2ViDCY/cmUfRqeWY1yLto+EpxjXnZzenB1TyxsTiXaTWeZFIZw6mac52BsuQm0RjCnisjBtdBaXOI6w==} + + '@esbuild/aix-ppc64@0.27.2': + resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.2': + resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.2': + resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.2': + resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.2': + resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.2': + resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.2': + resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.2': + resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.2': + resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.2': + resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.2': + resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.2': + resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.2': + resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.2': + resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.2': + resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.2': + resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.2': + resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.2': + resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.2': + resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.2': + resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.2': + resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.2': + resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.2': + resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.2': + resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.2': + resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.2': + resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -136,6 +397,45 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@fastify/ajv-compiler@4.0.5': + resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + + '@fastify/cors@11.2.0': + resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.0.3': + resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} + + '@fastify/forwarded@3.0.1': + resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} + + '@fastify/helmet@13.0.2': + resolution: {integrity: sha512-tO1QMkOfNeCt9l4sG/FiWErH4QMm+RjHzbMTrgew1DYOQ2vb/6M1G2iNABBrD7Xq6dUk+HLzWW8u+rmmhQHifA==} + + '@fastify/jwt@10.0.0': + resolution: {integrity: sha512-2Qka3NiyNNcsfejMUvyzot1T4UYIzzcbkFGDdVyrl344fRZ/WkD6VFXOoXhxe2Pzf3LpJNkoSxUM4Ru4DVgkYA==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + + '@fastify/rate-limit@10.3.0': + resolution: {integrity: sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==} + + '@fastify/websocket@11.2.0': + resolution: {integrity: sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w==} + + '@hono/node-server@1.19.6': + resolution: {integrity: sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -152,6 +452,9 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@ioredis/commands@1.4.0': + resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==} + '@isaacs/balanced-match@4.0.1': resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} engines: {node: 20 || >=22} @@ -160,6 +463,14 @@ packages: resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} engines: {node: 20 || >=22} + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + + '@mrleebo/prisma-ast@0.12.1': + resolution: {integrity: sha512-JwqeCQ1U3fvccttHZq7Tk0m/TMC6WcFAQZdukypW3AzlJYKYTGNVd1ANU2GuhKnv4UQuOFj3oAl0LLG/gxFN1w==} + engines: {node: '>=16'} + '@next/eslint-plugin-next@16.1.1': resolution: {integrity: sha512-Ovb/6TuLKbE1UiPcg0p39Ke3puyTCIKN9hGbNItmpQsp+WX3qrjO3WaMVSi6JHr9X1NrmthqIguVHodMJbh/dw==} @@ -175,9 +486,73 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@prisma/adapter-pg@7.2.0': + resolution: {integrity: sha512-euIdQ13cRB2wZ3jPsnDnFhINquo1PYFPCg6yVL8b2rp3EdinQHsX9EDdCtRr489D5uhphcRk463OdQAFlsCr0w==} + + '@prisma/client-runtime-utils@7.2.0': + resolution: {integrity: sha512-dn7oB53v0tqkB0wBdMuTNFNPdEbfICEUe82Tn9FoKAhJCUkDH+fmyEp0ClciGh+9Hp2Tuu2K52kth2MTLstvmA==} + + '@prisma/client@7.2.0': + resolution: {integrity: sha512-JdLF8lWZ+LjKGKpBqyAlenxd/kXjd1Abf/xK+6vUA7R7L2Suo6AFTHFRpPSdAKCan9wzdFApsUpSa/F6+t1AtA==} + engines: {node: ^20.19 || ^22.12 || >=24.0} + peerDependencies: + prisma: '*' + typescript: '>=5.4.0' + peerDependenciesMeta: + prisma: + optional: true + typescript: + optional: true + + '@prisma/config@7.2.0': + resolution: {integrity: sha512-qmvSnfQ6l/srBW1S7RZGfjTQhc44Yl3ldvU6y3pgmuLM+83SBDs6UQVgMtQuMRe9J3gGqB0RF8wER6RlXEr6jQ==} + + '@prisma/debug@6.8.2': + resolution: {integrity: sha512-4muBSSUwJJ9BYth5N8tqts8JtiLT8QI/RSAzEogwEfpbYGFo9mYsInsVo8dqXdPO2+Rm5OG5q0qWDDE3nyUbVg==} + + '@prisma/debug@7.2.0': + resolution: {integrity: sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==} + + '@prisma/dev@0.17.0': + resolution: {integrity: sha512-6sGebe5jxX+FEsQTpjHLzvOGPn6ypFQprcs3jcuIWv1Xp/5v6P/rjfdvAwTkP2iF6pDx2tCd8vGLNWcsWzImTA==} + + '@prisma/driver-adapter-utils@7.2.0': + resolution: {integrity: sha512-gzrUcbI9VmHS24Uf+0+7DNzdIw7keglJsD5m/MHxQOU68OhGVzlphQRobLiDMn8CHNA2XN8uugwKjudVtnfMVQ==} + + '@prisma/engines-version@7.2.0-4.0c8ef2ce45c83248ab3df073180d5eda9e8be7a3': + resolution: {integrity: sha512-KezsjCZDsbjNR7SzIiVlUsn9PnLePI7r5uxABlwL+xoerurZTfgQVbIjvjF2sVr3Uc0ZcsnREw3F84HvbggGdA==} + + '@prisma/engines@7.2.0': + resolution: {integrity: sha512-HUeOI/SvCDsHrR9QZn24cxxZcujOjcS3w1oW/XVhnSATAli5SRMOfp/WkG3TtT5rCxDA4xOnlJkW7xkho4nURA==} + + '@prisma/fetch-engine@7.2.0': + resolution: {integrity: sha512-Z5XZztJ8Ap+wovpjPD2lQKnB8nWFGNouCrglaNFjxIWAGWz0oeHXwUJRiclIoSSXN/ptcs9/behptSk8d0Yy6w==} + + '@prisma/get-platform@6.8.2': + resolution: {integrity: sha512-vXSxyUgX3vm1Q70QwzwkjeYfRryIvKno1SXbIqwSptKwqKzskINnDUcx85oX+ys6ooN2ATGSD0xN2UTfg6Zcow==} + + '@prisma/get-platform@7.2.0': + resolution: {integrity: sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==} + + '@prisma/query-plan-executor@6.18.0': + resolution: {integrity: sha512-jZ8cfzFgL0jReE1R10gT8JLHtQxjWYLiQ//wHmVYZ2rVkFHoh0DT8IXsxcKcFlfKN7ak7k6j0XMNn2xVNyr5cA==} + + '@prisma/studio-core@0.9.0': + resolution: {integrity: sha512-xA2zoR/ADu/NCSQuriBKTh6Ps4XjU0bErkEcgMfnSGh346K1VI7iWKnoq1l2DoxUqiddPHIEWwtxJ6xCHG6W7g==} + peerDependencies: + '@types/react': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -190,6 +565,15 @@ packages: '@types/node@25.0.3': resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==} + '@types/pg@8.16.0': + resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==} + + '@types/react@19.2.7': + resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/eslint-plugin@8.51.0': resolution: {integrity: sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -249,6 +633,9 @@ packages: resolution: {integrity: sha512-mM/JRQOzhVN1ykejrvwnBRV3+7yTKK8tVANVN3o1O0t0v7o+jqdVu9crPy5Y9dov15TJk/FTIgoUGHrTOVL3Zg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -259,9 +646,20 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ansi-escapes@7.2.0: resolution: {integrity: sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==} engines: {node: '>=18'} @@ -305,17 +703,34 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + asn1.js@5.4.1: + resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + avvio@9.1.0: + resolution: {integrity: sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==} + + aws-ssl-profiles@1.1.2: + resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} + engines: {node: '>= 6.0.0'} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + bn.js@4.12.2: + resolution: {integrity: sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -326,6 +741,14 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + c12@3.1.0: + resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==} + peerDependencies: + magicast: ^0.3.5 + peerDependenciesMeta: + magicast: + optional: true + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -346,6 +769,16 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chevrotain@10.5.0: + resolution: {integrity: sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A==} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -354,6 +787,10 @@ packages: resolution: {integrity: sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==} engines: {node: '>=20'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -371,10 +808,24 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + confbox@0.2.2: + resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -387,6 +838,9 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + dateformat@4.6.3: + resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -407,6 +861,10 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + deepmerge-ts@7.1.5: + resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + engines: {node: '>=16.0.0'} + define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} @@ -415,17 +873,51 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + defu@6.1.4: + resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + duplexify@4.1.3: + resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + effect@3.18.4: + resolution: {integrity: sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + empathic@2.0.0: + resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} + engines: {node: '>=14'} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} @@ -458,6 +950,11 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + esbuild@0.27.2: + resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} + engines: {node: '>=18'} + hasBin: true + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -551,6 +1048,19 @@ packages: eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + + fast-copy@4.0.2: + resolution: {integrity: sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==} + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -561,12 +1071,44 @@ packages: fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + fast-json-stringify@6.1.1: + resolution: {integrity: sha512-DbgptncYEXZqDUOEl4krff4mUiVrTZZVI7BBrQR/T3BqMj/eM1flTC1Uk2uUoLcWCxjT95xKulV/Lc6hhOZsBQ==} + + fast-jwt@6.1.0: + resolution: {integrity: sha512-cGK/TXlud8INL49Iv7yRtZy0PHzNJId1shfqNCqdF0gOlWiy+1FPgjxX+ZHp/CYxFYDaoNnxeYEGzcXSkahUEQ==} + engines: {node: '>=20'} + fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fastfall@1.5.1: + resolution: {integrity: sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==} + engines: {node: '>=0.10.0'} + + fastify-plugin@5.1.0: + resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + + fastify@5.6.2: + resolution: {integrity: sha512-dPugdGnsvYkBlENLhCgX8yhyGCsCPrpA8lFWbTNU428l+YOnLgYHR69hzV8HWPC79n536EqzqQtvhtdaCE0dKg==} + + fastparallel@2.4.1: + resolution: {integrity: sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fastseries@1.7.2: + resolution: {integrity: sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -584,6 +1126,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-my-way@9.3.0: + resolution: {integrity: sha512-eRoFWQw+Yv2tuYlK2pjFS2jGXSxSppAs3hSQjfxVKxM5amECzIgYYc1FEI8ZmhSh/Ig+FrKEz43NLRKJjYCZVg==} + engines: {node: '>=20'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -599,6 +1145,15 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -609,6 +1164,9 @@ packages: functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + generate-function@2.3.1: + resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -621,6 +1179,9 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-port-please@3.1.2: + resolution: {integrity: sha512-Gxc29eLs1fbn6LQ4jSU4vXjlwyZhF5HsGuMAa7gqBP4Rw4yxxltyDUuF5MBclFzDTXO+ACchGQoeela4DSfzdQ==} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -629,6 +1190,13 @@ packages: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} + get-tsconfig@4.13.0: + resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + + giget@2.0.0: + resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} + hasBin: true + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -657,6 +1225,12 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + grammex@3.1.12: + resolution: {integrity: sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -684,11 +1258,29 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + helmet@8.1.0: + resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==} + engines: {node: '>=18.0.0'} + + help-me@5.0.0: + resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + + hono@4.10.6: + resolution: {integrity: sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g==} + engines: {node: '>=16.9.0'} + + http-status-codes@2.3.0: + resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==} + husky@9.1.7: resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} engines: {node: '>=18'} hasBin: true + iconv-lite@0.7.1: + resolution: {integrity: sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==} + engines: {node: '>=0.10.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -705,10 +1297,21 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + ioredis@5.8.2: + resolution: {integrity: sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==} + engines: {node: '>=12.22.0'} + + ipaddr.js@2.3.0: + resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} + engines: {node: '>= 10'} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -777,6 +1380,9 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-property@1.0.2: + resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -819,6 +1425,14 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true @@ -826,9 +1440,15 @@ packages: json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -843,6 +1463,13 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + + lilconfig@2.1.0: + resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} + engines: {node: '>=10'} + lint-staged@16.2.7: resolution: {integrity: sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow==} engines: {node: '>=20.17'} @@ -856,17 +1483,33 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + log-update@6.1.0: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + lru-cache@11.2.4: resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} engines: {node: 20 || >=22} + lru.min@1.1.3: + resolution: {integrity: sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==} + engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -883,6 +1526,9 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + minimatch@10.1.1: resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} engines: {node: 20 || >=22} @@ -901,9 +1547,20 @@ packages: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} + mnemonist@0.40.3: + resolution: {integrity: sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mysql2@3.15.3: + resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==} + engines: {node: '>= 8.0'} + + named-placeholders@1.1.6: + resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} + engines: {node: '>=8.0.0'} + nano-spawn@2.0.0: resolution: {integrity: sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==} engines: {node: '>=20.17'} @@ -911,6 +1568,14 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + nypm@0.6.2: + resolution: {integrity: sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==} + engines: {node: ^14.16.0 || >=16.10.0} + hasBin: true + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -935,6 +1600,19 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + obliterator@2.0.5: + resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==} + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + onetime@7.0.0: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} @@ -977,6 +1655,46 @@ packages: resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} engines: {node: 20 || >=22} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + pg-cloudflare@1.2.7: + resolution: {integrity: sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==} + + pg-connection-string@2.9.1: + resolution: {integrity: sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.10.1: + resolution: {integrity: sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.10.3: + resolution: {integrity: sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.16.3: + resolution: {integrity: sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} @@ -990,10 +1708,54 @@ packages: engines: {node: '>=0.10'} hasBin: true + pino-abstract-transport@2.0.0: + resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-pretty@13.1.3: + resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==} + hasBin: true + + pino-std-serializers@7.0.0: + resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==} + + pino@10.1.0: + resolution: {integrity: sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w==} + hasBin: true + + pkg-types@2.3.0: + resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-array@3.0.4: + resolution: {integrity: sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==} + engines: {node: '>=12'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + postgres@3.4.7: + resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} + engines: {node: '>=12'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -1003,25 +1765,101 @@ packages: engines: {node: '>=14'} hasBin: true + prisma@7.2.0: + resolution: {integrity: sha512-jSdHWgWOgFF24+nRyyNRVBIgGDQEsMEF8KPHvhBBg3jWyR9fUAK0Nq9ThUmiGlNgq2FA7vSk/ZoCvefod+a8qg==} + engines: {node: ^20.19 || ^22.12 || >=24.0} + hasBin: true + peerDependencies: + better-sqlite3: '>=9.0.0' + typescript: '>=5.4.0' + peerDependenciesMeta: + better-sqlite3: + optional: true + typescript: + optional: true + + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + + pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + rc9@2.1.2: + resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + + react-dom@19.2.3: + resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} + peerDependencies: + react: ^19.2.3 + + react@19.2.3: + resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} + engines: {node: '>=0.10.0'} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} + regexp-to-ast@0.5.0: + resolution: {integrity: sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==} + regexp.prototype.flags@1.5.4: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + remeda@2.21.3: + resolution: {integrity: sha512-XXrZdLA10oEOQhLLzEJEiFFSKi21REGAkHdImIb4rt/XXy8ORGXh5HCcpUOsElfPNDb+X6TA/+wkh+p2KffYmg==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve@1.22.11: resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} engines: {node: '>= 0.4'} @@ -1031,6 +1869,14 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -1050,6 +1896,9 @@ packages: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-push-apply@1.0.0: resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} engines: {node: '>= 0.4'} @@ -1058,6 +1907,22 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + safe-regex2@5.0.0: + resolution: {integrity: sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -1067,6 +1932,12 @@ packages: engines: {node: '>=10'} hasBin: true + seq-queue@0.0.5: + resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -1103,6 +1974,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -1111,10 +1985,33 @@ packages: resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} engines: {node: '>=18'} + sonic-boom@4.2.0: + resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + sqlstring@2.3.3: + resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} + engines: {node: '>= 0.6'} + + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + + std-env@3.9.0: + resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} + + steed@1.1.3: + resolution: {integrity: sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + stream-shift@1.0.3: + resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} + string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} @@ -1139,6 +2036,9 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + strip-ansi@7.1.2: resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} engines: {node: '>=12'} @@ -1151,6 +2051,10 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -1159,6 +2063,13 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + thread-stream@3.1.0: + resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} + + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -1167,6 +2078,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toad-cache@3.7.0: + resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} + engines: {node: '>=12'} + ts-api-utils@2.3.0: resolution: {integrity: sha512-6eg3Y9SF7SsAvGzRHQvvc1skDAhwI4YQ32ui1scxD1Ccr0G5qIIbUBT3pFTKX8kmWIQClHobtUdNuaBgwdfdWg==} engines: {node: '>=18.12'} @@ -1176,6 +2091,11 @@ packages: tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + turbo-darwin-64@2.7.2: resolution: {integrity: sha512-dxY3X6ezcT5vm3coK6VGixbrhplbQMwgNsCsvZamS/+/6JiebqW9DKt4NwpgYXhDY2HdH00I7FWs3wkVuan4rA==} cpu: [x64] @@ -1214,6 +2134,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -1252,6 +2176,17 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + valibot@1.2.0: + resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -1281,6 +2216,25 @@ packages: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + yaml@2.8.2: resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} engines: {node: '>= 14.6'} @@ -1290,14 +2244,120 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - zod@4.3.4: - resolution: {integrity: sha512-Zw/uYiiyF6pUT1qmKbZziChgNPRu+ZRneAsMUDU6IwmXdWt5JwcUfy2bvLOCUtz5UniaN/Zx5aFttZYbYc7O/A==} + zeptomatch@2.0.2: + resolution: {integrity: sha512-H33jtSKf8Ijtb5BW6wua3G5DhnFjbFML36eFu+VdOoVY4HD9e7ggjqdM6639B+L87rjnR6Y+XeRzBXZdy52B/g==} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} snapshots: - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2)': + '@chevrotain/cst-dts-gen@10.5.0': + dependencies: + '@chevrotain/gast': 10.5.0 + '@chevrotain/types': 10.5.0 + lodash: 4.17.21 + + '@chevrotain/gast@10.5.0': + dependencies: + '@chevrotain/types': 10.5.0 + lodash: 4.17.21 + + '@chevrotain/types@10.5.0': {} + + '@chevrotain/utils@10.5.0': {} + + '@electric-sql/pglite-socket@0.0.6(@electric-sql/pglite@0.3.2)': + dependencies: + '@electric-sql/pglite': 0.3.2 + + '@electric-sql/pglite-tools@0.2.7(@electric-sql/pglite@0.3.2)': + dependencies: + '@electric-sql/pglite': 0.3.2 + + '@electric-sql/pglite@0.3.2': {} + + '@esbuild/aix-ppc64@0.27.2': + optional: true + + '@esbuild/android-arm64@0.27.2': + optional: true + + '@esbuild/android-arm@0.27.2': + optional: true + + '@esbuild/android-x64@0.27.2': + optional: true + + '@esbuild/darwin-arm64@0.27.2': + optional: true + + '@esbuild/darwin-x64@0.27.2': + optional: true + + '@esbuild/freebsd-arm64@0.27.2': + optional: true + + '@esbuild/freebsd-x64@0.27.2': + optional: true + + '@esbuild/linux-arm64@0.27.2': + optional: true + + '@esbuild/linux-arm@0.27.2': + optional: true + + '@esbuild/linux-ia32@0.27.2': + optional: true + + '@esbuild/linux-loong64@0.27.2': + optional: true + + '@esbuild/linux-mips64el@0.27.2': + optional: true + + '@esbuild/linux-ppc64@0.27.2': + optional: true + + '@esbuild/linux-riscv64@0.27.2': + optional: true + + '@esbuild/linux-s390x@0.27.2': + optional: true + + '@esbuild/linux-x64@0.27.2': + optional: true + + '@esbuild/netbsd-arm64@0.27.2': + optional: true + + '@esbuild/netbsd-x64@0.27.2': + optional: true + + '@esbuild/openbsd-arm64@0.27.2': + optional: true + + '@esbuild/openbsd-x64@0.27.2': + optional: true + + '@esbuild/openharmony-arm64@0.27.2': + optional: true + + '@esbuild/sunos-x64@0.27.2': + optional: true + + '@esbuild/win32-arm64@0.27.2': + optional: true + + '@esbuild/win32-ia32@0.27.2': + optional: true + + '@esbuild/win32-x64@0.27.2': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))': dependencies: - eslint: 9.39.2 + eslint: 9.39.2(jiti@2.6.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -1341,6 +2401,66 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@fastify/ajv-compiler@4.0.5': + dependencies: + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + fast-uri: 3.1.0 + + '@fastify/cors@11.2.0': + dependencies: + fastify-plugin: 5.1.0 + toad-cache: 3.7.0 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.0.3': + dependencies: + fast-json-stringify: 6.1.1 + + '@fastify/forwarded@3.0.1': {} + + '@fastify/helmet@13.0.2': + dependencies: + fastify-plugin: 5.1.0 + helmet: 8.1.0 + + '@fastify/jwt@10.0.0': + dependencies: + '@fastify/error': 4.2.0 + '@lukeed/ms': 2.0.2 + fast-jwt: 6.1.0 + fastify-plugin: 5.1.0 + steed: 1.1.3 + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.1 + ipaddr.js: 2.3.0 + + '@fastify/rate-limit@10.3.0': + dependencies: + '@lukeed/ms': 2.0.2 + fastify-plugin: 5.1.0 + toad-cache: 3.7.0 + + '@fastify/websocket@11.2.0': + dependencies: + duplexify: 4.1.3 + fastify-plugin: 5.1.0 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@hono/node-server@1.19.6(hono@4.10.6)': + dependencies: + hono: 4.10.6 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -1352,30 +2472,130 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@isaacs/balanced-match@4.0.1': {} + '@ioredis/commands@1.4.0': {} + + '@isaacs/balanced-match@4.0.1': {} + + '@isaacs/brace-expansion@5.0.0': + dependencies: + '@isaacs/balanced-match': 4.0.1 + + '@lukeed/ms@2.0.2': {} + + '@mrleebo/prisma-ast@0.12.1': + dependencies: + chevrotain: 10.5.0 + lilconfig: 2.1.0 + + '@next/eslint-plugin-next@16.1.1': + dependencies: + fast-glob: 3.3.1 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@pinojs/redact@0.4.0': {} + + '@prisma/adapter-pg@7.2.0': + dependencies: + '@prisma/driver-adapter-utils': 7.2.0 + pg: 8.16.3 + postgres-array: 3.0.4 + transitivePeerDependencies: + - pg-native + + '@prisma/client-runtime-utils@7.2.0': {} + + '@prisma/client@7.2.0(prisma@7.2.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3)': + dependencies: + '@prisma/client-runtime-utils': 7.2.0 + optionalDependencies: + prisma: 7.2.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) + typescript: 5.9.3 + + '@prisma/config@7.2.0': + dependencies: + c12: 3.1.0 + deepmerge-ts: 7.1.5 + effect: 3.18.4 + empathic: 2.0.0 + transitivePeerDependencies: + - magicast + + '@prisma/debug@6.8.2': {} + + '@prisma/debug@7.2.0': {} + + '@prisma/dev@0.17.0(typescript@5.9.3)': + dependencies: + '@electric-sql/pglite': 0.3.2 + '@electric-sql/pglite-socket': 0.0.6(@electric-sql/pglite@0.3.2) + '@electric-sql/pglite-tools': 0.2.7(@electric-sql/pglite@0.3.2) + '@hono/node-server': 1.19.6(hono@4.10.6) + '@mrleebo/prisma-ast': 0.12.1 + '@prisma/get-platform': 6.8.2 + '@prisma/query-plan-executor': 6.18.0 + foreground-child: 3.3.1 + get-port-please: 3.1.2 + hono: 4.10.6 + http-status-codes: 2.3.0 + pathe: 2.0.3 + proper-lockfile: 4.1.2 + remeda: 2.21.3 + std-env: 3.9.0 + valibot: 1.2.0(typescript@5.9.3) + zeptomatch: 2.0.2 + transitivePeerDependencies: + - typescript + + '@prisma/driver-adapter-utils@7.2.0': + dependencies: + '@prisma/debug': 7.2.0 + + '@prisma/engines-version@7.2.0-4.0c8ef2ce45c83248ab3df073180d5eda9e8be7a3': {} + + '@prisma/engines@7.2.0': + dependencies: + '@prisma/debug': 7.2.0 + '@prisma/engines-version': 7.2.0-4.0c8ef2ce45c83248ab3df073180d5eda9e8be7a3 + '@prisma/fetch-engine': 7.2.0 + '@prisma/get-platform': 7.2.0 - '@isaacs/brace-expansion@5.0.0': + '@prisma/fetch-engine@7.2.0': dependencies: - '@isaacs/balanced-match': 4.0.1 + '@prisma/debug': 7.2.0 + '@prisma/engines-version': 7.2.0-4.0c8ef2ce45c83248ab3df073180d5eda9e8be7a3 + '@prisma/get-platform': 7.2.0 - '@next/eslint-plugin-next@16.1.1': + '@prisma/get-platform@6.8.2': dependencies: - fast-glob: 3.3.1 + '@prisma/debug': 6.8.2 - '@nodelib/fs.scandir@2.1.5': + '@prisma/get-platform@7.2.0': dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 + '@prisma/debug': 7.2.0 - '@nodelib/fs.stat@2.0.5': {} + '@prisma/query-plan-executor@6.18.0': {} - '@nodelib/fs.walk@1.2.8': + '@prisma/studio-core@0.9.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.1 + '@types/react': 19.2.7 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) '@rtsao/scc@1.1.0': {} + '@standard-schema/spec@1.1.0': {} + '@types/estree@1.0.8': {} '@types/json-schema@7.0.15': {} @@ -1386,15 +2606,29 @@ snapshots: dependencies: undici-types: 7.16.0 - '@typescript-eslint/eslint-plugin@8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3)': + '@types/pg@8.16.0': + dependencies: + '@types/node': 25.0.3 + pg-protocol: 1.10.3 + pg-types: 2.2.0 + + '@types/react@19.2.7': + dependencies: + csstype: 3.2.3 + + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.0.3 + + '@typescript-eslint/eslint-plugin@8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.51.0(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/parser': 8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.51.0 - '@typescript-eslint/type-utils': 8.51.0(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/utils': 8.51.0(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.51.0 - eslint: 9.39.2 + eslint: 9.39.2(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.3.0(typescript@5.9.3) @@ -1402,14 +2636,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.51.0(eslint@9.39.2)(typescript@5.9.3)': + '@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.51.0 '@typescript-eslint/types': 8.51.0 '@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.51.0 debug: 4.4.3 - eslint: 9.39.2 + eslint: 9.39.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -1432,13 +2666,13 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.51.0(eslint@9.39.2)(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.51.0 '@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.51.0(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/utils': 8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 - eslint: 9.39.2 + eslint: 9.39.2(jiti@2.6.1) ts-api-utils: 2.3.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -1461,13 +2695,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.51.0(eslint@9.39.2)(typescript@5.9.3)': + '@typescript-eslint/utils@8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) '@typescript-eslint/scope-manager': 8.51.0 '@typescript-eslint/types': 8.51.0 '@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3) - eslint: 9.39.2 + eslint: 9.39.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -1477,12 +2711,18 @@ snapshots: '@typescript-eslint/types': 8.51.0 eslint-visitor-keys: 4.2.1 + abstract-logging@2.0.1: {} + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 acorn@8.15.0: {} + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -1490,6 +2730,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-escapes@7.2.0: dependencies: environment: 1.1.0 @@ -1554,14 +2801,32 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + asn1.js@5.4.1: + dependencies: + bn.js: 4.12.2 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + safer-buffer: 2.1.2 + async-function@1.0.0: {} + atomic-sleep@1.0.0: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 + avvio@9.1.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.20.1 + + aws-ssl-profiles@1.1.2: {} + balanced-match@1.0.2: {} + bn.js@4.12.2: {} + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -1575,6 +2840,21 @@ snapshots: dependencies: fill-range: 7.1.1 + c12@3.1.0: + dependencies: + chokidar: 4.0.3 + confbox: 0.2.2 + defu: 6.1.4 + dotenv: 16.6.1 + exsolve: 1.0.8 + giget: 2.0.0 + jiti: 2.6.1 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 1.0.0 + pkg-types: 2.3.0 + rc9: 2.1.2 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -1599,6 +2879,23 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chevrotain@10.5.0: + dependencies: + '@chevrotain/cst-dts-gen': 10.5.0 + '@chevrotain/gast': 10.5.0 + '@chevrotain/types': 10.5.0 + '@chevrotain/utils': 10.5.0 + lodash: 4.17.21 + regexp-to-ast: 0.5.0 + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + citty@0.1.6: + dependencies: + consola: 3.4.2 + cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 @@ -1608,6 +2905,8 @@ snapshots: slice-ansi: 7.1.2 string-width: 8.1.0 + cluster-key-slot@1.1.2: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -1620,12 +2919,20 @@ snapshots: concat-map@0.0.1: {} + confbox@0.2.2: {} + + consola@3.4.2: {} + + cookie@1.1.1: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 + csstype@3.2.3: {} + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -1644,6 +2951,8 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + dateformat@4.6.3: {} + debug@3.2.7: dependencies: ms: 2.1.3 @@ -1654,6 +2963,8 @@ snapshots: deep-is@0.1.4: {} + deepmerge-ts@7.1.5: {} + define-data-property@1.1.4: dependencies: es-define-property: 1.0.1 @@ -1666,18 +2977,50 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + defu@6.1.4: {} + + denque@2.1.0: {} + + dequal@2.0.3: {} + + destr@2.0.5: {} + doctrine@2.1.0: dependencies: esutils: 2.0.3 + dotenv@16.6.1: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 es-errors: 1.3.0 gopd: 1.2.0 + duplexify@4.1.3: + dependencies: + end-of-stream: 1.4.5 + inherits: 2.0.4 + readable-stream: 3.6.2 + stream-shift: 1.0.3 + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + effect@3.18.4: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.2 + emoji-regex@10.6.0: {} + empathic@2.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + environment@1.1.0: {} es-abstract@1.24.1: @@ -1762,11 +3105,40 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + esbuild@0.27.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.2 + '@esbuild/android-arm': 0.27.2 + '@esbuild/android-arm64': 0.27.2 + '@esbuild/android-x64': 0.27.2 + '@esbuild/darwin-arm64': 0.27.2 + '@esbuild/darwin-x64': 0.27.2 + '@esbuild/freebsd-arm64': 0.27.2 + '@esbuild/freebsd-x64': 0.27.2 + '@esbuild/linux-arm': 0.27.2 + '@esbuild/linux-arm64': 0.27.2 + '@esbuild/linux-ia32': 0.27.2 + '@esbuild/linux-loong64': 0.27.2 + '@esbuild/linux-mips64el': 0.27.2 + '@esbuild/linux-ppc64': 0.27.2 + '@esbuild/linux-riscv64': 0.27.2 + '@esbuild/linux-s390x': 0.27.2 + '@esbuild/linux-x64': 0.27.2 + '@esbuild/netbsd-arm64': 0.27.2 + '@esbuild/netbsd-x64': 0.27.2 + '@esbuild/openbsd-arm64': 0.27.2 + '@esbuild/openbsd-x64': 0.27.2 + '@esbuild/openharmony-arm64': 0.27.2 + '@esbuild/sunos-x64': 0.27.2 + '@esbuild/win32-arm64': 0.27.2 + '@esbuild/win32-ia32': 0.27.2 + '@esbuild/win32-x64': 0.27.2 + escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@9.39.2): + eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)): dependencies: - eslint: 9.39.2 + eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node@0.3.9: dependencies: @@ -1776,17 +3148,17 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.51.0(eslint@9.39.2)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.51.0(eslint@9.39.2)(typescript@5.9.3) - eslint: 9.39.2 + '@typescript-eslint/parser': 8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -1795,9 +3167,9 @@ snapshots: array.prototype.flatmap: 1.3.3 debug: 3.2.7 doctrine: 2.1.0 - eslint: 9.39.2 + eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.51.0(eslint@9.39.2)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -1809,7 +3181,7 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.51.0(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/parser': 8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -1826,9 +3198,9 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint@9.39.2: + eslint@9.39.2(jiti@2.6.1): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.21.1 '@eslint/config-helpers': 0.4.2 @@ -1862,6 +3234,8 @@ snapshots: minimatch: 3.1.2 natural-compare: 1.4.0 optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 transitivePeerDependencies: - supports-color @@ -1885,6 +3259,16 @@ snapshots: eventemitter3@5.0.1: {} + exsolve@1.0.8: {} + + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + + fast-copy@4.0.2: {} + + fast-decode-uri-component@1.0.1: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.1: @@ -1897,12 +3281,70 @@ snapshots: fast-json-stable-stringify@2.1.0: {} + fast-json-stringify@6.1.1: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + fast-uri: 3.1.0 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + + fast-jwt@6.1.0: + dependencies: + '@lukeed/ms': 2.0.2 + asn1.js: 5.4.1 + ecdsa-sig-formatter: 1.0.11 + mnemonist: 0.40.3 + fast-levenshtein@2.0.6: {} + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-safe-stringify@2.1.1: {} + + fast-uri@3.1.0: {} + + fastfall@1.5.1: + dependencies: + reusify: 1.1.0 + + fastify-plugin@5.1.0: {} + + fastify@5.6.2: + dependencies: + '@fastify/ajv-compiler': 4.0.5 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.0.3 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.1.0 + fast-json-stringify: 6.1.1 + find-my-way: 9.3.0 + light-my-request: 6.6.0 + pino: 10.1.0 + process-warning: 5.0.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.7.3 + toad-cache: 3.7.0 + + fastparallel@2.4.1: + dependencies: + reusify: 1.1.0 + xtend: 4.0.2 + fastq@1.20.1: dependencies: reusify: 1.1.0 + fastseries@1.7.2: + dependencies: + reusify: 1.1.0 + xtend: 4.0.2 + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -1915,6 +3357,12 @@ snapshots: dependencies: to-regex-range: 5.0.1 + find-my-way@9.3.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -1931,6 +3379,14 @@ snapshots: dependencies: is-callable: 1.2.7 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + fsevents@2.3.3: + optional: true + function-bind@1.1.2: {} function.prototype.name@1.1.8: @@ -1944,6 +3400,10 @@ snapshots: functions-have-names@1.2.3: {} + generate-function@2.3.1: + dependencies: + is-property: 1.0.2 + generator-function@2.0.1: {} get-east-asian-width@1.4.0: {} @@ -1961,6 +3421,8 @@ snapshots: hasown: 2.0.2 math-intrinsics: 1.1.0 + get-port-please@3.1.2: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -1972,6 +3434,19 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 + get-tsconfig@4.13.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + giget@2.0.0: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + defu: 6.1.4 + node-fetch-native: 1.6.7 + nypm: 0.6.2 + pathe: 2.0.3 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -1997,6 +3472,10 @@ snapshots: gopd@1.2.0: {} + graceful-fs@4.2.11: {} + + grammex@3.1.12: {} + has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -2019,8 +3498,20 @@ snapshots: dependencies: function-bind: 1.1.2 + helmet@8.1.0: {} + + help-me@5.0.0: {} + + hono@4.10.6: {} + + http-status-codes@2.3.0: {} + husky@9.1.7: {} + iconv-lite@0.7.1: + dependencies: + safer-buffer: 2.1.2 + ignore@5.3.2: {} ignore@7.0.5: {} @@ -2032,12 +3523,30 @@ snapshots: imurmurhash@0.1.4: {} + inherits@2.0.4: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 hasown: 2.0.2 side-channel: 1.1.0 + ioredis@5.8.2: + dependencies: + '@ioredis/commands': 1.4.0 + cluster-key-slot: 1.1.2 + debug: 4.4.3 + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + + ipaddr.js@2.3.0: {} + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.8 @@ -2111,6 +3620,8 @@ snapshots: is-number@7.0.0: {} + is-property@1.0.2: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -2154,14 +3665,24 @@ snapshots: isexe@2.0.0: {} + jiti@2.6.1: {} + + joycon@3.1.1: {} + js-yaml@4.1.1: dependencies: argparse: 2.0.1 json-buffer@3.0.1: {} + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@1.0.2: @@ -2177,6 +3698,14 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + + lilconfig@2.1.0: {} + lint-staged@16.2.7: dependencies: commander: 14.0.2 @@ -2200,8 +3729,14 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.defaults@4.2.0: {} + + lodash.isarguments@3.1.0: {} + lodash.merge@4.6.2: {} + lodash@4.17.21: {} + log-update@6.1.0: dependencies: ansi-escapes: 7.2.0 @@ -2210,8 +3745,12 @@ snapshots: strip-ansi: 7.1.2 wrap-ansi: 9.0.2 + long@5.3.2: {} + lru-cache@11.2.4: {} + lru.min@1.1.3: {} + math-intrinsics@1.1.0: {} merge2@1.4.1: {} @@ -2223,6 +3762,8 @@ snapshots: mimic-function@5.0.1: {} + minimalistic-assert@1.0.1: {} + minimatch@10.1.1: dependencies: '@isaacs/brace-expansion': 5.0.0 @@ -2239,12 +3780,42 @@ snapshots: minipass@7.1.2: {} + mnemonist@0.40.3: + dependencies: + obliterator: 2.0.5 + ms@2.1.3: {} + mysql2@3.15.3: + dependencies: + aws-ssl-profiles: 1.1.2 + denque: 2.1.0 + generate-function: 2.3.1 + iconv-lite: 0.7.1 + long: 5.3.2 + lru.min: 1.1.3 + named-placeholders: 1.1.6 + seq-queue: 0.0.5 + sqlstring: 2.3.3 + + named-placeholders@1.1.6: + dependencies: + lru.min: 1.1.3 + nano-spawn@2.0.0: {} natural-compare@1.4.0: {} + node-fetch-native@1.6.7: {} + + nypm@0.6.2: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + pathe: 2.0.3 + pkg-types: 2.3.0 + tinyexec: 1.0.2 + object-inspect@1.13.4: {} object-keys@1.1.1: {} @@ -2278,6 +3849,16 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + obliterator@2.0.5: {} + + ohash@2.0.11: {} + + on-exit-leak-free@2.1.2: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + onetime@7.0.0: dependencies: mimic-function: 5.0.1 @@ -2322,22 +3903,184 @@ snapshots: lru-cache: 11.2.4 minipass: 7.1.2 + pathe@2.0.3: {} + + perfect-debounce@1.0.0: {} + + pg-cloudflare@1.2.7: + optional: true + + pg-connection-string@2.9.1: {} + + pg-int8@1.0.1: {} + + pg-pool@3.10.1(pg@8.16.3): + dependencies: + pg: 8.16.3 + + pg-protocol@1.10.3: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.16.3: + dependencies: + pg-connection-string: 2.9.1 + pg-pool: 3.10.1(pg@8.16.3) + pg-protocol: 1.10.3 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.2.7 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picomatch@2.3.1: {} picomatch@4.0.3: {} pidtree@0.6.0: {} + pino-abstract-transport@2.0.0: + dependencies: + split2: 4.2.0 + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-pretty@13.1.3: + dependencies: + colorette: 2.0.20 + dateformat: 4.6.3 + fast-copy: 4.0.2 + fast-safe-stringify: 2.1.1 + help-me: 5.0.0 + joycon: 3.1.1 + minimist: 1.2.8 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pump: 3.0.3 + secure-json-parse: 4.1.0 + sonic-boom: 4.2.0 + strip-json-comments: 5.0.3 + + pino-std-serializers@7.0.0: {} + + pino@10.1.0: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 2.0.0 + pino-std-serializers: 7.0.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.0 + thread-stream: 3.1.0 + + pkg-types@2.3.0: + dependencies: + confbox: 0.2.2 + exsolve: 1.0.8 + pathe: 2.0.3 + possible-typed-array-names@1.1.0: {} + postgres-array@2.0.0: {} + + postgres-array@3.0.4: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + postgres@3.4.7: {} + prelude-ls@1.2.1: {} prettier@3.7.4: {} + prisma@7.2.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3): + dependencies: + '@prisma/config': 7.2.0 + '@prisma/dev': 0.17.0(typescript@5.9.3) + '@prisma/engines': 7.2.0 + '@prisma/studio-core': 0.9.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + mysql2: 3.15.3 + postgres: 3.4.7 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/react' + - magicast + - react + - react-dom + + process-warning@4.0.1: {} + + process-warning@5.0.0: {} + + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + + pump@3.0.3: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode@2.3.1: {} + pure-rand@6.1.0: {} + queue-microtask@1.2.3: {} + quick-format-unescaped@4.0.4: {} + + rc9@2.1.2: + dependencies: + defu: 6.1.4 + destr: 2.0.5 + + react-dom@19.2.3(react@19.2.3): + dependencies: + react: 19.2.3 + scheduler: 0.27.0 + + react@19.2.3: {} + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@4.1.2: {} + + real-require@0.2.0: {} + + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -2349,6 +4092,8 @@ snapshots: get-proto: 1.0.1 which-builtin-type: 1.2.1 + regexp-to-ast@0.5.0: {} + regexp.prototype.flags@1.5.4: dependencies: call-bind: 1.0.8 @@ -2358,8 +4103,16 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + remeda@2.21.3: + dependencies: + type-fest: 4.41.0 + + require-from-string@2.0.2: {} + resolve-from@4.0.0: {} + resolve-pkg-maps@1.0.0: {} + resolve@1.22.11: dependencies: is-core-module: 2.16.1 @@ -2371,6 +4124,10 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 + ret@0.5.0: {} + + retry@0.12.0: {} + reusify@1.1.0: {} rfdc@1.4.1: {} @@ -2392,6 +4149,8 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 + safe-buffer@5.2.1: {} + safe-push-apply@1.0.0: dependencies: es-errors: 1.3.0 @@ -2403,10 +4162,26 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + safe-regex2@5.0.0: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + + safer-buffer@2.1.2: {} + + scheduler@0.27.0: {} + + secure-json-parse@4.1.0: {} + semver@6.3.1: {} semver@7.7.3: {} + seq-queue@0.0.5: {} + + set-cookie-parser@2.7.2: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -2463,6 +4238,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + signal-exit@3.0.7: {} + signal-exit@4.1.0: {} slice-ansi@7.1.2: @@ -2470,11 +4247,33 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + sonic-boom@4.2.0: + dependencies: + atomic-sleep: 1.0.0 + + split2@4.2.0: {} + + sqlstring@2.3.3: {} + + standard-as-callback@2.1.0: {} + + std-env@3.9.0: {} + + steed@1.1.3: + dependencies: + fastfall: 1.5.1 + fastparallel: 2.4.1 + fastq: 1.20.1 + fastseries: 1.7.2 + reusify: 1.1.0 + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 internal-slot: 1.1.0 + stream-shift@1.0.3: {} + string-argv@0.3.2: {} string-width@7.2.0: @@ -2511,6 +4310,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + strip-ansi@7.1.2: dependencies: ansi-regex: 6.2.2 @@ -2519,12 +4322,20 @@ snapshots: strip-json-comments@3.1.1: {} + strip-json-comments@5.0.3: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 supports-preserve-symlinks-flag@1.0.0: {} + thread-stream@3.1.0: + dependencies: + real-require: 0.2.0 + + tinyexec@1.0.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -2534,6 +4345,8 @@ snapshots: dependencies: is-number: 7.0.0 + toad-cache@3.7.0: {} + ts-api-utils@2.3.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -2545,6 +4358,13 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 + tsx@4.21.0: + dependencies: + esbuild: 0.27.2 + get-tsconfig: 4.13.0 + optionalDependencies: + fsevents: 2.3.3 + turbo-darwin-64@2.7.2: optional: true @@ -2576,6 +4396,8 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@4.41.0: {} + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -2609,13 +4431,13 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.51.0(eslint@9.39.2)(typescript@5.9.3): + typescript-eslint@8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/parser': 8.51.0(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.51.0(eslint@9.39.2)(typescript@5.9.3) - eslint: 9.39.2 + '@typescript-eslint/utils': 8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -2635,6 +4457,12 @@ snapshots: dependencies: punycode: 2.3.1 + util-deprecate@1.0.2: {} + + valibot@1.2.0(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -2688,8 +4516,18 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.1.2 + wrappy@1.0.2: {} + + ws@8.18.3: {} + + xtend@4.0.2: {} + yaml@2.8.2: {} yocto-queue@0.1.0: {} - zod@4.3.4: {} + zeptomatch@2.0.2: + dependencies: + grammex: 3.1.12 + + zod@3.25.76: {} From 7a64485c2192a81daefbe6e84ad53e28cc903e92 Mon Sep 17 00:00:00 2001 From: Utpal Date: Fri, 2 Jan 2026 12:34:49 +0530 Subject: [PATCH 2/4] fix(server): address code review issues, security fixes, and CI pipeline - Add CSRF protection, token blacklisting, and rate limiting - Optimize Redis presence updates and mutual friends query - Fix Prisma 7 schema config and add generate step to CI - Resolve ESLint errors and add proper type validations --- .github/workflows/ci.yml | 3 + apps/server/package.json | 1 + apps/server/prisma.config.ts | 1 + apps/server/prisma/schema.prisma | 12 +- apps/server/src/lib/errors.ts | 32 +++- apps/server/src/lib/logger.ts | 3 + apps/server/src/routes/auth.ts | 172 ++++++++++++------ apps/server/src/routes/friends.ts | 36 ++-- apps/server/src/routes/users.ts | 26 ++- apps/server/src/server.ts | 75 +++++--- apps/server/src/services/db.ts | 4 +- apps/server/src/services/github.ts | 118 ++++++------ apps/server/src/services/redis.ts | 88 ++++++++- apps/server/src/ws/handler.ts | 273 ++++++++++++++++++---------- apps/server/src/ws/types.ts | 3 + packages/eslint-config/package.json | 2 +- pnpm-lock.yaml | 21 ++- 17 files changed, 596 insertions(+), 274 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e83e97..fc50fbe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,9 @@ jobs: - name: Check formatting run: pnpm format:check + - name: Generate Prisma Client + run: pnpm --filter @devradar/server db:generate + - name: Lint run: pnpm lint diff --git a/apps/server/package.json b/apps/server/package.json index d687d6c..5d45bf3 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -20,6 +20,7 @@ }, "dependencies": { "@devradar/shared": "workspace:*", + "@fastify/cookie": "^11.0.2", "@fastify/cors": "^11.2.0", "@fastify/helmet": "^13.0.2", "@fastify/jwt": "^10.0.0", diff --git a/apps/server/prisma.config.ts b/apps/server/prisma.config.ts index 3c180d3..7c27717 100644 --- a/apps/server/prisma.config.ts +++ b/apps/server/prisma.config.ts @@ -22,5 +22,6 @@ export default defineConfig({ // Database connection for Prisma CLI (migrate, db push, studio) datasource: { url: env('DATABASE_URL'), + shadowDatabaseUrl: env('SHADOW_DATABASE_URL'), }, }); diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma index 7206c55..9904de2 100644 --- a/apps/server/prisma/schema.prisma +++ b/apps/server/prisma/schema.prisma @@ -9,6 +9,7 @@ generator client { datasource db { provider = "postgresql" + // Note: URL configured in prisma.config.ts for Prisma 7 } // =================== @@ -28,9 +29,10 @@ model User { updatedAt DateTime @updatedAt // Relations - following Follow[] @relation("Following") - followers Follow[] @relation("Followers") - teams TeamMember[] + following Follow[] @relation("Following") + followers Follow[] @relation("Followers") + teams TeamMember[] + ownedTeams Team[] @relation("TeamOwner") @@index([username]) @@index([githubId]) @@ -41,7 +43,6 @@ model User { // =================== model Follow { - id String @id @default(cuid()) followerId String followingId String createdAt DateTime @default(now()) @@ -50,7 +51,7 @@ model Follow { follower User @relation("Following", fields: [followerId], references: [id], onDelete: Cascade) following User @relation("Followers", fields: [followingId], references: [id], onDelete: Cascade) - @@unique([followerId, followingId]) + @@id([followerId, followingId]) @@index([followerId]) @@index([followingId]) } @@ -69,6 +70,7 @@ model Team { updatedAt DateTime @updatedAt // Relations + owner User @relation("TeamOwner", fields: [ownerId], references: [id], onDelete: Cascade) members TeamMember[] @@index([slug]) diff --git a/apps/server/src/lib/errors.ts b/apps/server/src/lib/errors.ts index fd8ce67..f275b36 100644 --- a/apps/server/src/lib/errors.ts +++ b/apps/server/src/lib/errors.ts @@ -35,7 +35,19 @@ export abstract class AppError extends Error { if (options?.details) { this.details = options.details; } - Error.captureStackTrace(this, this.constructor); + // V8-specific: guard against environments without captureStackTrace + if ( + typeof (Error as unknown as { captureStackTrace?: unknown }).captureStackTrace === 'function' + ) { + ( + Error as unknown as { captureStackTrace: (target: Error, constructor: unknown) => void } + ).captureStackTrace(this, this.constructor); + } else { + const fallbackStack = new Error().stack; + if (fallbackStack) { + this.stack = fallbackStack; + } + } } /** @@ -131,6 +143,13 @@ export class ConflictError extends AppError { readonly code = 'CONFLICT'; readonly statusCode = 409; readonly isOperational = true; + + constructor( + message = 'Conflict', + options?: { cause?: Error; details?: Record } + ) { + super(message, options); + } } /** @@ -150,6 +169,17 @@ export class RateLimitError extends AppError { this.retryAfter = retryAfter; } } + + /** + * Override toJSON to include retryAfter in API responses. + */ + override toJSON(): Record { + const json = super.toJSON(); + if (this.retryAfter !== undefined) { + json.retryAfter = this.retryAfter; + } + return json; + } } // =================== diff --git a/apps/server/src/lib/logger.ts b/apps/server/src/lib/logger.ts index 55173fb..9e0a704 100644 --- a/apps/server/src/lib/logger.ts +++ b/apps/server/src/lib/logger.ts @@ -24,9 +24,12 @@ const REDACT_PATHS = [ 'token', 'secret', 'apiKey', + 'email', '*.password', '*.token', '*.secret', + '*.email', + 'user.email', 'headers.authorization', 'headers.cookie', ]; diff --git a/apps/server/src/routes/auth.ts b/apps/server/src/routes/auth.ts index b446081..eb2206a 100644 --- a/apps/server/src/routes/auth.ts +++ b/apps/server/src/routes/auth.ts @@ -10,6 +10,7 @@ import { z } from 'zod'; import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { isProduction } from '@/config'; import { ValidationError } from '@/lib/errors'; import { logger } from '@/lib/logger'; import { getGitHubAuthUrl, authenticateWithGitHub } from '@/services/github'; @@ -36,8 +37,13 @@ export function authRoutes(app: FastifyInstance): void { // Generate state for CSRF protection const state = crypto.randomUUID(); - // Store state in session/cookie for validation (simplified for now) - // In production, use secure cookies or session storage + // Store state in secure cookie for validation + reply.setCookie('oauth_state', state, { + httpOnly: true, + secure: isProduction, + sameSite: 'lax', + maxAge: 600, // 10 minutes + }); const authUrl = getGitHubAuthUrl(state); @@ -51,58 +57,84 @@ export function authRoutes(app: FastifyInstance): void { * Handle GitHub OAuth callback. * Returns JWT token on success. */ - app.get('/auth/callback', async (request: FastifyRequest, reply: FastifyReply) => { - const result = CallbackQuerySchema.safeParse(request.query); - - if (!result.success) { - throw new ValidationError('Invalid callback parameters', { - details: { errors: result.error.issues }, - }); - } - - const { code, error, error_description } = result.data; - - // Handle OAuth errors from GitHub - if (error) { - logger.warn({ error, error_description }, 'GitHub OAuth error'); - return reply.status(400).send({ - error: { - code: 'OAUTH_ERROR', - message: error_description ?? error, + app.get( + '/auth/callback', + { + config: { + rateLimit: { + max: 10, + timeWindow: '1 minute', }, - }); - } - - // Authenticate with GitHub - const user = await authenticateWithGitHub(code); - - // Generate JWT - const token = app.jwt.sign( - { - userId: user.id, - username: user.username, - tier: user.tier, }, - { expiresIn: '7d' } - ); - - logger.info({ userId: user.id }, 'User authenticated successfully'); - - // Return token and user info - // In production, the extension would intercept this or use a custom URI scheme - return reply.send({ - data: { - token, - user: { - id: user.id, + }, + async (request: FastifyRequest, reply: FastifyReply) => { + const result = CallbackQuerySchema.safeParse(request.query); + + if (!result.success) { + throw new ValidationError('Invalid callback parameters', { + details: { errors: result.error.issues }, + }); + } + + const { code, state, error, error_description } = result.data; + + // Validate CSRF state + const storedState = request.cookies.oauth_state; + if (!state || !storedState || state !== storedState) { + logger.warn({ providedState: state }, 'OAuth state mismatch'); + return reply.status(400).send({ + error: { + code: 'INVALID_STATE', + message: 'Invalid OAuth state parameter', + }, + }); + } + + // Clear the state cookie + reply.clearCookie('oauth_state'); + + // Handle OAuth errors from GitHub + if (error) { + logger.warn({ error, error_description }, 'GitHub OAuth error'); + return reply.status(400).send({ + error: { + code: 'OAUTH_ERROR', + message: error_description ?? error, + }, + }); + } + + // Authenticate with GitHub + const user = await authenticateWithGitHub(code); + + // Generate JWT + const token = app.jwt.sign( + { + userId: user.id, username: user.username, - displayName: user.displayName, - avatarUrl: user.avatarUrl, tier: user.tier, }, - }, - }); - }); + { expiresIn: '7d' } + ); + + logger.info({ userId: user.id }, 'User authenticated successfully'); + + // Return token and user info + // In production, the extension would intercept this or use a custom URI scheme + return reply.send({ + data: { + token, + user: { + id: user.id, + username: user.username, + displayName: user.displayName, + avatarUrl: user.avatarUrl, + tier: user.tier, + }, + }, + }); + } + ); /** * POST /auth/refresh @@ -131,14 +163,38 @@ export function authRoutes(app: FastifyInstance): void { /** * POST /auth/logout - * Logout (client-side token removal). - * Server-side we could invalidate refresh tokens if we had them. + * Logout and blacklist the current token. */ - app.post('/auth/logout', async (_request: FastifyRequest, reply: FastifyReply) => { - // With JWT, logout is primarily client-side - // We could add token blacklisting for enhanced security - return reply.send({ - data: { message: 'Logged out successfully' }, - }); - }); + app.post( + '/auth/logout', + { onRequest: [app.authenticate] }, + async (request: FastifyRequest, reply: FastifyReply) => { + try { + // Extract token from Authorization header + const authHeader = request.headers.authorization; + if (authHeader?.startsWith('Bearer ')) { + const token = authHeader.slice(7); + + // Decode token to get expiry (without verification since we already verified) + const decoded = app.jwt.decode(token) as { exp?: number } | null; + if (decoded?.exp) { + const ttlSeconds = decoded.exp - Math.floor(Date.now() / 1000); + + // Import dynamically to avoid circular dependency + const { blacklistToken } = await import('@/services/redis'); + await blacklistToken(token, ttlSeconds); + + logger.info('Token blacklisted on logout'); + } + } + } catch (error) { + // Log but don't fail the logout + logger.warn({ error }, 'Failed to blacklist token during logout'); + } + + return reply.send({ + data: { message: 'Logged out successfully' }, + }); + } + ); } diff --git a/apps/server/src/routes/friends.ts b/apps/server/src/routes/friends.ts index 153a5d8..c1393f2 100644 --- a/apps/server/src/routes/friends.ts +++ b/apps/server/src/routes/friends.ts @@ -247,7 +247,7 @@ export function friendRoutes(app: FastifyInstance): void { return reply.status(201).send({ data: { - id: follow.id, + followerId: userId, followingId: targetUserId, username: targetUser.username, createdAt: follow.createdAt.toISOString(), @@ -288,7 +288,12 @@ export function friendRoutes(app: FastifyInstance): void { } await db.follow.delete({ - where: { id: follow.id }, + where: { + followerId_followingId: { + followerId: userId, + followingId: targetUserId, + }, + }, }); logger.info({ userId, targetUserId }, 'User unfollowed'); @@ -314,27 +319,14 @@ export function friendRoutes(app: FastifyInstance): void { const { id: targetUserId } = paramsResult.data; - // Get users both are following - const myFollowing = await db.follow.findMany({ - where: { followerId: userId }, - select: { followingId: true }, - }); - - const theirFollowing = await db.follow.findMany({ - where: { followerId: targetUserId }, - select: { followingId: true }, - }); - - const myFollowingIds = new Set( - myFollowing.map((f: { followingId: string }) => f.followingId) - ); - const mutualIds = theirFollowing - .filter((f: { followingId: string }) => myFollowingIds.has(f.followingId)) - .map((f: { followingId: string }) => f.followingId); - - // Get user details for mutual friends + // Single efficient query: find users followed by BOTH userId AND targetUserId const mutualFriends = await db.user.findMany({ - where: { id: { in: mutualIds } }, + where: { + AND: [ + { followers: { some: { followerId: userId } } }, + { followers: { some: { followerId: targetUserId } } }, + ], + }, select: { id: true, username: true, diff --git a/apps/server/src/routes/users.ts b/apps/server/src/routes/users.ts index 92d8766..1fad502 100644 --- a/apps/server/src/routes/users.ts +++ b/apps/server/src/routes/users.ts @@ -24,6 +24,19 @@ const UserIdParamsSchema = z.object({ id: z.string().min(1, 'User ID is required'), }); +/** + * Search query schema. + */ +const SearchQuerySchema = z.object({ + q: z.string().min(2, 'Search query must be at least 2 characters'), +}); + +/** + * Valid tier values. + */ +const VALID_TIERS = ['FREE', 'PRO', 'TEAM'] as const; +type ValidTier = (typeof VALID_TIERS)[number]; + /** * Transform Prisma user to DTO. */ @@ -37,13 +50,18 @@ function toUserDTO(user: { privacyMode: boolean; createdAt: Date; }): UserDTO { + // Runtime validation of tier value + const tier = VALID_TIERS.includes(user.tier as ValidTier) + ? (user.tier as UserDTO['tier']) + : 'FREE'; // Safe default for invalid DB values + return { id: user.id, githubId: user.githubId, username: user.username, displayName: user.displayName, avatarUrl: user.avatarUrl, - tier: user.tier as UserDTO['tier'], + tier, privacyMode: user.privacyMode, createdAt: user.createdAt.toISOString(), }; @@ -203,12 +221,14 @@ export function userRoutes(app: FastifyInstance): void { '/users/search', { onRequest: [app.authenticate] }, async (request: FastifyRequest, reply: FastifyReply) => { - const query = (request.query as Record).q; + const queryResult = SearchQuerySchema.safeParse(request.query); - if (!query || query.length < 2) { + if (!queryResult.success) { throw new ValidationError('Search query must be at least 2 characters'); } + const { q: query } = queryResult.data; + const users = await db.user.findMany({ where: { OR: [ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e8b0744..e34c416 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -11,6 +11,7 @@ * - Pino for structured logging */ +import fastifyCookie from '@fastify/cookie'; import fastifyCors from '@fastify/cors'; import fastifyHelmet from '@fastify/helmet'; import fastifyJwt from '@fastify/jwt'; @@ -51,6 +52,9 @@ async function buildServer() { methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], }); + // Cookie support (for OAuth CSRF state) + await app.register(fastifyCookie); + // Security headers await app.register(fastifyHelmet, { contentSecurityPolicy: isProduction, @@ -97,7 +101,21 @@ async function buildServer() { app.decorate('authenticate', async (request: FastifyRequest, _reply: FastifyReply) => { try { await request.jwtVerify(); - } catch { + + // Check if token is blacklisted (for logout support) + const authHeader = request.headers.authorization; + if (authHeader?.startsWith('Bearer ')) { + const token = authHeader.slice(7); + const { isTokenBlacklisted } = await import('@/services/redis'); + const isBlacklisted = await isTokenBlacklisted(token); + if (isBlacklisted) { + throw new AuthenticationError('Token has been revoked'); + } + } + } catch (error) { + if (error instanceof AuthenticationError) { + throw error; + } throw new AuthenticationError('Invalid or expired token'); } }); @@ -213,7 +231,6 @@ async function buildServer() { // Prefix all API routes with /api/v1 app.register( (api, _opts, done) => { - api.register(authRoutes, { prefix: '/auth' }); api.register(userRoutes, { prefix: '/users' }); api.register(friendRoutes, { prefix: '/friends' }); done(); @@ -221,7 +238,7 @@ async function buildServer() { { prefix: '/api/v1' } ); - // Also register auth at root for OAuth redirects + // Auth routes at root for OAuth redirects (GITHUB_CALLBACK_URL should use /auth/callback) app.register(authRoutes, { prefix: '/auth' }); // =================== @@ -267,49 +284,55 @@ async function start(): Promise { // Graceful Shutdown // =================== - const shutdown = (signal: string): void => { + const shutdown = async (signal: string): Promise => { logger.info({ signal }, 'Shutdown signal received'); // Set a timeout for graceful shutdown const shutdownTimeout = setTimeout(() => { logger.error('Graceful shutdown timed out, forcing exit'); - throw new Error('Shutdown timeout'); + // eslint-disable-next-line no-process-exit + process.exit(1); }, 30_000); - Promise.all([ - app?.close().then(() => { - logger.info('HTTP server closed'); - }), - disconnectDb(), - disconnectRedis(), - ]) - .then(() => { - clearTimeout(shutdownTimeout); - logger.info('Graceful shutdown complete'); - }) - .catch((error: unknown) => { - logger.error({ error }, 'Error during shutdown'); - clearTimeout(shutdownTimeout); - throw error; - }); + try { + await Promise.all([ + app?.close().then(() => { + logger.info('HTTP server closed'); + }), + disconnectDb(), + disconnectRedis(), + ]); + + clearTimeout(shutdownTimeout); + logger.info('Graceful shutdown complete'); + // eslint-disable-next-line no-process-exit + process.exit(0); + } catch (error: unknown) { + logger.error({ error }, 'Error during shutdown'); + clearTimeout(shutdownTimeout); + // eslint-disable-next-line no-process-exit + process.exit(1); + } }; process.on('SIGTERM', () => { - shutdown('SIGTERM'); + void shutdown('SIGTERM'); }); process.on('SIGINT', () => { - shutdown('SIGINT'); + void shutdown('SIGINT'); }); - // Handle uncaught errors + // Handle uncaught errors - exit immediately, don't attempt graceful shutdown process.on('uncaughtException', (error) => { logger.fatal({ error }, 'Uncaught exception'); - shutdown('uncaughtException'); + // eslint-disable-next-line no-process-exit + process.exit(1); }); process.on('unhandledRejection', (reason) => { logger.fatal({ reason }, 'Unhandled rejection'); - shutdown('unhandledRejection'); + // eslint-disable-next-line no-process-exit + process.exit(1); }); } catch (error) { logger.fatal({ error }, 'Failed to start server'); diff --git a/apps/server/src/services/db.ts b/apps/server/src/services/db.ts index d71ab7d..619f4f2 100644 --- a/apps/server/src/services/db.ts +++ b/apps/server/src/services/db.ts @@ -81,5 +81,5 @@ export async function isDbHealthy(): Promise { } } -// Export db as convenient alias -export const db = getDb(); +// Note: Use getDb() instead of a direct export to allow test mocking +// and prevent side effects on import diff --git a/apps/server/src/services/github.ts b/apps/server/src/services/github.ts index f801c39..9d3547e 100644 --- a/apps/server/src/services/github.ts +++ b/apps/server/src/services/github.ts @@ -5,27 +5,13 @@ * Creates or updates user records on successful authentication. */ +import type { User } from '@/generated/prisma/client'; + import { env } from '@/config'; import { AuthenticationError, InternalError } from '@/lib/errors'; import { logger } from '@/lib/logger'; import { getDb } from '@/services/db'; -/** - * User type from database. - */ -interface User { - id: string; - githubId: string; - username: string; - displayName: string | null; - avatarUrl: string | null; - email: string | null; - tier: string; - privacyMode: boolean; - createdAt: Date; - updatedAt: Date; -} - /** * GitHub OAuth endpoints. */ @@ -55,6 +41,11 @@ interface TokenResponse { error_description?: string; } +/** + * GitHub API request timeout in milliseconds. + */ +const GITHUB_API_TIMEOUT_MS = 10000; + /** * Generate the GitHub OAuth authorization URL. * @@ -84,33 +75,47 @@ export function getGitHubAuthUrl(state?: string): string { * @throws AuthenticationError if exchange fails */ async function exchangeCodeForToken(code: string): Promise { - const response = await fetch(GITHUB_TOKEN_URL, { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - client_id: env.GITHUB_CLIENT_ID, - client_secret: env.GITHUB_CLIENT_SECRET, - code, - redirect_uri: env.GITHUB_CALLBACK_URL, - }), - }); + try { + const response = await fetch(GITHUB_TOKEN_URL, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + client_id: env.GITHUB_CLIENT_ID, + client_secret: env.GITHUB_CLIENT_SECRET, + code, + redirect_uri: env.GITHUB_CALLBACK_URL, + }), + signal: AbortSignal.timeout(GITHUB_API_TIMEOUT_MS), + }); - if (!response.ok) { - logger.error({ status: response.status }, 'GitHub token exchange failed'); - throw new AuthenticationError('Failed to authenticate with GitHub'); - } + if (!response.ok) { + logger.error({ status: response.status }, 'GitHub token exchange failed'); + throw new AuthenticationError('Failed to authenticate with GitHub'); + } - const data = (await response.json()) as TokenResponse; + const data = (await response.json()) as TokenResponse; - if (data.error) { - logger.error({ error: data.error, description: data.error_description }, 'GitHub OAuth error'); - throw new AuthenticationError(data.error_description ?? 'GitHub authentication failed'); - } + if (data.error) { + logger.error( + { error: data.error, description: data.error_description }, + 'GitHub OAuth error' + ); + throw new AuthenticationError(data.error_description ?? 'GitHub authentication failed'); + } - return data.access_token; + return data.access_token; + } catch (error) { + if (error instanceof AuthenticationError) { + throw error; + } + if (error instanceof Error && error.name === 'TimeoutError') { + throw new AuthenticationError('GitHub authentication timed out'); + } + throw error; + } } /** @@ -121,19 +126,30 @@ async function exchangeCodeForToken(code: string): Promise { * @throws AuthenticationError if fetch fails */ async function fetchGitHubUser(accessToken: string): Promise { - const response = await fetch(GITHUB_USER_URL, { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/vnd.github.v3+json', - }, - }); + try { + const response = await fetch(GITHUB_USER_URL, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github.v3+json', + }, + signal: AbortSignal.timeout(GITHUB_API_TIMEOUT_MS), + }); - if (!response.ok) { - logger.error({ status: response.status }, 'Failed to fetch GitHub user'); - throw new AuthenticationError('Failed to fetch GitHub user profile'); - } + if (!response.ok) { + logger.error({ status: response.status }, 'Failed to fetch GitHub user'); + throw new AuthenticationError('Failed to fetch GitHub user profile'); + } - return (await response.json()) as GitHubUser; + return (await response.json()) as GitHubUser; + } catch (error) { + if (error instanceof AuthenticationError) { + throw error; + } + if (error instanceof Error && error.name === 'TimeoutError') { + throw new AuthenticationError('GitHub authentication timed out'); + } + throw error; + } } /** @@ -173,7 +189,7 @@ export async function authenticateWithGitHub(code: string): Promise { logger.info({ userId: user.id, username: user.username }, 'User authenticated via GitHub'); - return user as User; + return user; } catch (error) { if (error instanceof AuthenticationError) { throw error; diff --git a/apps/server/src/services/redis.ts b/apps/server/src/services/redis.ts index 4c7b804..911276d 100644 --- a/apps/server/src/services/redis.ts +++ b/apps/server/src/services/redis.ts @@ -115,15 +115,36 @@ export async function connectRedis(): Promise { * Call this during graceful shutdown. */ export async function disconnectRedis(): Promise { - const clients = [commandClient, subscribeClient, publishClient].filter(Boolean) as Redis[]; - - await Promise.all(clients.map((client) => client.quit())); + const clients = [ + { name: 'command', client: commandClient }, + { name: 'subscribe', client: subscribeClient }, + { name: 'publish', client: publishClient }, + ].filter((c) => c.client !== null); + + const results = await Promise.allSettled( + clients.map(async ({ name, client }) => { + try { + if (client) { + await client.quit(); + } + } catch (error: unknown) { + logger.warn({ error, client: name }, 'Error closing Redis client'); + throw error; + } + }) + ); + // Always nullify clients regardless of quit success commandClient = null; subscribeClient = null; publishClient = null; - logger.info('Redis connections closed'); + const failures = results.filter((r) => r.status === 'rejected'); + if (failures.length > 0) { + logger.warn({ failureCount: failures.length }, 'Redis disconnected with some errors'); + } else { + logger.info('Redis connections closed'); + } } /** @@ -152,6 +173,7 @@ interface PresenceData { /** * Set user presence with TTL. + * Only publishes to Redis if presence data has actually changed (throttling). * * @param userId - User ID * @param data - Presence data @@ -160,11 +182,27 @@ export async function setPresence(userId: string, data: PresenceData): Promise { + logger.warn({ error, userId }, 'Failed to publish presence update'); + }); + } } /** @@ -231,3 +269,39 @@ export async function getPresences(userIds: string[]): Promise { + if (ttlSeconds <= 0) return; // Token already expired + + const redis = getRedis(); + const key = `${TOKEN_BLACKLIST_PREFIX}${token}`; + + await redis.setex(key, ttlSeconds, '1'); + logger.debug({ ttlSeconds }, 'Token blacklisted'); +} + +/** + * Check if a token is blacklisted. + * + * @param token - JWT token or its jti to check + * @returns true if token is blacklisted + */ +export async function isTokenBlacklisted(token: string): Promise { + const redis = getRedis(); + const key = `${TOKEN_BLACKLIST_PREFIX}${token}`; + + const exists = await redis.exists(key); + return exists === 1; +} diff --git a/apps/server/src/ws/handler.ts b/apps/server/src/ws/handler.ts index 75003b2..455fab1 100644 --- a/apps/server/src/ws/handler.ts +++ b/apps/server/src/ws/handler.ts @@ -39,7 +39,7 @@ const InboundMessageSchema = z.object({ type: z.enum(['HEARTBEAT', 'POKE', 'SUBSCRIBE', 'UNSUBSCRIBE']), payload: z.unknown(), timestamp: z.number().int().positive(), - correlationId: z.string().optional(), + correlationId: z.string().uuid().optional(), }); /** @@ -55,6 +55,16 @@ const HeartbeatMessageSchema = z.object({ */ const connections = new Map(); +/** + * Channel subscription tracking: channel -> Set of userIds subscribed. + */ +const channelSubscriptions = new Map>(); + +/** + * Flag to track if global message handler is initialized. + */ +let globalHandlerInitialized = false; + /** * Send a message to a WebSocket client. */ @@ -101,30 +111,89 @@ async function getUserFriendIds(userId: string): Promise { return follows.map((f: FollowResult) => f.followingId); } +/** + * Initialize global Redis message handler (called once). + */ +function initializeGlobalMessageHandler(): void { + if (globalHandlerInitialized) return; + + const subscriber = getRedisSubscriber(); + + subscriber.on('message', (channel: string, messageData: string) => { + try { + const data = JSON.parse(messageData) as FriendStatusPayload; + const subscribedUserIds = channelSubscriptions.get(channel); + + if (!subscribedUserIds) return; + + // Route message to all subscribed connections + for (const userId of subscribedUserIds) { + const ws = connections.get(userId); + if (ws && ws.readyState === ws.OPEN) { + send(ws, 'FRIEND_STATUS', data); + } + } + } catch (error) { + logger.error({ error, channel }, 'Failed to parse presence message'); + } + }); + + globalHandlerInitialized = true; + logger.debug('Global Redis message handler initialized'); +} + /** * Subscribe to friend presence updates via Redis pub/sub. */ async function subscribeToFriends(ws: AuthenticatedWebSocket): Promise { const subscriber = getRedisSubscriber(); + // Ensure global handler is set up + initializeGlobalMessageHandler(); + + // Subscribe to friend channels for (const friendId of ws.friendIds) { const channel = REDIS_KEYS.presenceChannel(friendId); - // Subscribe to channel - await subscriber.subscribe(channel); - - logger.debug({ userId: ws.userId, friendId, channel }, 'Subscribed to friend presence'); + // Track subscription + let subscribers = channelSubscriptions.get(channel); + if (!subscribers) { + subscribers = new Set(); + channelSubscriptions.set(channel, subscribers); + // First subscriber - actually subscribe to Redis + await subscriber.subscribe(channel); + logger.debug({ channel }, 'Subscribed to Redis channel'); + } + subscribers.add(ws.userId); } - // Handle incoming presence updates - subscriber.on('message', (channel: string, messageData: string) => { - try { - const data = JSON.parse(messageData) as FriendStatusPayload; - send(ws, 'FRIEND_STATUS', data); - } catch (error) { - logger.error({ error, channel }, 'Failed to parse presence message'); + logger.debug( + { userId: ws.userId, friendCount: ws.friendIds.length }, + 'User subscribed to friend presence' + ); +} + +/** + * Unsubscribe from friend presence channels. + */ +async function unsubscribeFromFriends(ws: AuthenticatedWebSocket): Promise { + const subscriber = getRedisSubscriber(); + + for (const friendId of ws.friendIds) { + const channel = REDIS_KEYS.presenceChannel(friendId); + const subscribers = channelSubscriptions.get(channel); + + if (subscribers) { + subscribers.delete(ws.userId); + + // Last subscriber - unsubscribe from Redis + if (subscribers.size === 0) { + channelSubscriptions.delete(channel); + await subscriber.unsubscribe(channel); + logger.debug({ channel }, 'Unsubscribed from Redis channel'); + } } - }); + } } /** @@ -226,8 +295,19 @@ async function handleMessage(ws: AuthenticatedWebSocket, data: string): Promise< handlePoke(ws, payload, correlationId); break; + case 'SUBSCRIBE': + case 'UNSUBSCRIBE': + // These message types are handled at connection level, not per-message + sendError( + ws, + 'NOT_IMPLEMENTED', + 'Dynamic subscription changes are not yet supported', + correlationId + ); + break; + default: - sendError(ws, 'UNKNOWN_TYPE', `Unknown message type: ${type}`, correlationId); + sendError(ws, 'UNKNOWN_TYPE', `Unknown message type: ${String(type)}`, correlationId); } } catch (error) { log.error({ error }, 'Error handling WebSocket message'); @@ -258,110 +338,117 @@ async function handleClose(ws: AuthenticatedWebSocket, code: number): Promise { - const ws = socket as AuthenticatedWebSocket; + app.get( + '/ws', + { + websocket: true, + config: { + rateLimit: { + max: 50, + timeWindow: '1 minute', + }, + }, + }, + async (socket: WsSocket, request: FastifyRequest) => { + const ws = socket as AuthenticatedWebSocket; - try { - // Extract token from query string - const token = (request.query as Record).token; + try { + // Extract token from query string + const token = (request.query as Record).token; - if (!token) { - ws.close(WsCloseCodes.UNAUTHORIZED, 'Token required'); - return; - } + if (!token) { + ws.close(WsCloseCodes.UNAUTHORIZED, 'Token required'); + return; + } - // Verify JWT - let decoded: { userId: string }; - try { - decoded = await request.jwtVerify<{ userId: string }>({ onlyCookie: false }); - } catch { - // Try from query token as header might not be available + // Verify JWT from query parameter + // (WebSocket connections from browsers can't set Authorization headers) + let decoded: { userId: string }; try { decoded = app.jwt.verify<{ userId: string }>(token); } catch { ws.close(WsCloseCodes.INVALID_TOKEN, 'Invalid token'); return; } - } - const { userId } = decoded; + const { userId } = decoded; - // Set up authenticated connection - ws.userId = userId; - ws.isAuthenticated = true; - ws.connectedAt = Date.now(); - ws.lastHeartbeat = Date.now(); + // Set up authenticated connection + ws.userId = userId; + ws.isAuthenticated = true; + ws.connectedAt = Date.now(); + ws.lastHeartbeat = Date.now(); - // Get user's friends - ws.friendIds = await getUserFriendIds(userId); + // Get user's friends + ws.friendIds = await getUserFriendIds(userId); - // Store connection - const existingConnection = connections.get(userId); - if (existingConnection) { - existingConnection.close(WsCloseCodes.GOING_AWAY, 'New connection established'); - } - connections.set(userId, ws); - - // Subscribe to friend presence updates - await subscribeToFriends(ws); - - // Get initial friend presences - const friendPresences = await getPresences(ws.friendIds); - - // Send connected message with initial friend statuses - const connectedPayload: ConnectedPayload = { - userId, - friendCount: ws.friendIds.length, - }; - send(ws, 'CONNECTED', connectedPayload); - - // Send initial friend statuses - const presenceEntries = Array.from(friendPresences.entries()); - for (const [friendId, presence] of presenceEntries) { - const payload: FriendStatusPayload = { - userId: friendId, - status: presence.status as FriendStatusPayload['status'], - updatedAt: presence.updatedAt, - }; - if (presence.activity) { - payload.activity = presence.activity as unknown as FriendStatusPayload['activity']; + // Store connection + const existingConnection = connections.get(userId); + if (existingConnection) { + existingConnection.close(WsCloseCodes.GOING_AWAY, 'New connection established'); } - send(ws, 'FRIEND_STATUS', payload); - } + connections.set(userId, ws); - logger.info({ userId, friendCount: ws.friendIds.length }, 'WebSocket connection established'); + // Subscribe to friend presence updates + await subscribeToFriends(ws); - // Handle messages - ws.on('message', (data: Buffer) => { - void handleMessage(ws, data.toString()); - }); + // Get initial friend presences + const friendPresences = await getPresences(ws.friendIds); - // Handle close - ws.on('close', (closeCode: number) => { - void handleClose(ws, closeCode); - }); + // Send connected message with initial friend statuses + const connectedPayload: ConnectedPayload = { + userId, + friendCount: ws.friendIds.length, + }; + send(ws, 'CONNECTED', connectedPayload); + + // Send initial friend statuses + const presenceEntries = Array.from(friendPresences.entries()); + for (const [friendId, presence] of presenceEntries) { + const payload: FriendStatusPayload = { + userId: friendId, + status: presence.status as FriendStatusPayload['status'], + updatedAt: presence.updatedAt, + }; + if (presence.activity) { + payload.activity = presence.activity as unknown as FriendStatusPayload['activity']; + } + send(ws, 'FRIEND_STATUS', payload); + } - // Handle errors - ws.on('error', (error: Error) => { - logger.error({ error, userId }, 'WebSocket error'); - }); - } catch (error) { - logger.error({ error }, 'WebSocket connection error'); - ws.close(WsCloseCodes.SERVER_ERROR, 'Internal server error'); + logger.info( + { userId, friendCount: ws.friendIds.length }, + 'WebSocket connection established' + ); + + // Handle messages + ws.on('message', (data: Buffer) => { + void handleMessage(ws, data.toString()); + }); + + // Handle close + ws.on('close', (closeCode: number) => { + void handleClose(ws, closeCode); + }); + + // Handle errors + ws.on('error', (error: Error) => { + logger.error({ error, userId }, 'WebSocket error'); + }); + } catch (error) { + logger.error({ error }, 'WebSocket connection error'); + ws.close(WsCloseCodes.SERVER_ERROR, 'Internal server error'); + } } - }); + ); logger.info('WebSocket handler registered at /ws'); } diff --git a/apps/server/src/ws/types.ts b/apps/server/src/ws/types.ts index c2e9799..10ea7ff 100644 --- a/apps/server/src/ws/types.ts +++ b/apps/server/src/ws/types.ts @@ -25,6 +25,9 @@ export interface AuthenticatedWebSocket extends WebSocket { /** Connection established timestamp */ connectedAt: number; + + /** Redis presence message handler for cleanup */ + presenceMessageHandler?: (channel: string, message: string) => void; } /** diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index 745801c..f32296e 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -21,7 +21,7 @@ "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", "eslint-plugin-only-warn": "^1.1.0", - "globals": "^16.5.0", + "globals": "^17.0.0", "typescript-eslint": "^8.51.0" }, "peerDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 612c769..d8daafb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@devradar/shared': specifier: workspace:* version: link:../../packages/shared + '@fastify/cookie': + specifier: ^11.0.2 + version: 11.0.2 '@fastify/cors': specifier: ^11.2.0 version: 11.2.0 @@ -142,8 +145,8 @@ importers: specifier: ^1.1.0 version: 1.1.0 globals: - specifier: ^16.5.0 - version: 16.5.0 + specifier: ^17.0.0 + version: 17.0.0 typescript: specifier: ^5.7.0 version: 5.9.3 @@ -400,6 +403,9 @@ packages: '@fastify/ajv-compiler@4.0.5': resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + '@fastify/cookie@11.0.2': + resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==} + '@fastify/cors@11.2.0': resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==} @@ -1213,8 +1219,8 @@ packages: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} - globals@16.5.0: - resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} + globals@17.0.0: + resolution: {integrity: sha512-gv5BeD2EssA793rlFWVPMMCqefTlpusw6/2TbAVMy0FzcG8wKJn4O+NqJ4+XWmmwrayJgw5TzrmWjFgmz1XPqw==} engines: {node: '>=18'} globalthis@1.0.4: @@ -2407,6 +2413,11 @@ snapshots: ajv-formats: 3.0.1(ajv@8.17.1) fast-uri: 3.1.0 + '@fastify/cookie@11.0.2': + dependencies: + cookie: 1.1.1 + fastify-plugin: 5.1.0 + '@fastify/cors@11.2.0': dependencies: fastify-plugin: 5.1.0 @@ -3463,7 +3474,7 @@ snapshots: globals@14.0.0: {} - globals@16.5.0: {} + globals@17.0.0: {} globalthis@1.0.4: dependencies: From add8b14c91f56196d21d119b61a328f1b0b32c24 Mon Sep 17 00:00:00 2001 From: Utpal Date: Fri, 2 Jan 2026 12:51:10 +0530 Subject: [PATCH 3/4] fix(ci): add placeholder env vars for Prisma generate and fix lint - Add DATABASE_URL and SHADOW_DATABASE_URL placeholders for CI - Fix unnecessary type assertion in auth.ts token decoding - Prisma 7's config loader requires env vars even for generate --- .github/workflows/ci.yml | 3 +++ apps/server/src/routes/auth.ts | 7 ++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc50fbe..aca460f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,9 @@ jobs: - name: Generate Prisma Client run: pnpm --filter @devradar/server db:generate + env: + DATABASE_URL: 'postgresql://user:pass@localhost:5432/db' + SHADOW_DATABASE_URL: 'postgresql://user:pass@localhost:5432/shadow' - name: Lint run: pnpm lint diff --git a/apps/server/src/routes/auth.ts b/apps/server/src/routes/auth.ts index eb2206a..f68b981 100644 --- a/apps/server/src/routes/auth.ts +++ b/apps/server/src/routes/auth.ts @@ -61,6 +61,7 @@ export function authRoutes(app: FastifyInstance): void { '/auth/callback', { config: { + // Rate limiting: 10 requests per minute per IP (CodeQL: rateLimit via @fastify/rate-limit) rateLimit: { max: 10, timeWindow: '1 minute', @@ -176,9 +177,9 @@ export function authRoutes(app: FastifyInstance): void { const token = authHeader.slice(7); // Decode token to get expiry (without verification since we already verified) - const decoded = app.jwt.decode(token) as { exp?: number } | null; - if (decoded?.exp) { - const ttlSeconds = decoded.exp - Math.floor(Date.now() / 1000); + const decoded = app.jwt.decode(token); + if (decoded && typeof decoded === 'object' && 'exp' in decoded) { + const ttlSeconds = (decoded.exp as number) - Math.floor(Date.now() / 1000); // Import dynamically to avoid circular dependency const { blacklistToken } = await import('@/services/redis'); From d555a07084c7080400a8040ce6b75640acfc3b5c Mon Sep 17 00:00:00 2001 From: Utpal Date: Fri, 2 Jan 2026 12:54:42 +0530 Subject: [PATCH 4/4] fix(turbo): build dependencies before lint and check-types Changed dependsOn from "topo" to "^build" so that workspace packages like @devradar/shared are compiled before linting packages that depend on them. --- turbo.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/turbo.json b/turbo.json index fc435a3..7876fcd 100644 --- a/turbo.json +++ b/turbo.json @@ -13,13 +13,13 @@ "env": ["NODE_ENV"] }, "check-types": { - "dependsOn": ["topo"] + "dependsOn": ["^build"] }, "lint": { - "dependsOn": ["topo"] + "dependsOn": ["^build"] }, "lint:fix": { - "dependsOn": ["topo"] + "dependsOn": ["^build"] }, "test": { "dependsOn": ["build"],