From a8f10a25d7a11eac3e6cc75b1698d60e86fc9495 Mon Sep 17 00:00:00 2001 From: rgrgogu Date: Tue, 5 May 2026 23:18:47 +0800 Subject: [PATCH] initial --- .gitignore | 5 + .sequelizerc | 10 + README.md | 759 ++++++- config/db.config.js | 41 + config/passport.config.js | 70 + controllers/admin/users.controller.js | 446 ++++ controllers/auth.controller.js | 318 +++ controllers/client/profile.controller.js | 92 + controllers/staff/users.controller.js | 105 + data/email_body.data.js | 81 + middleware/auth.middleware.js | 48 + middleware/csrf.middleware.js | 53 + middleware/rateLimiter.middleware.js | 65 + middleware/rbac.middleware.js | 77 + middleware/validate.middleware.js | 25 + models/users/user_groups.attributes.js | 22 + models/users/user_groups.mdl.js | 70 + models/users/user_sessions.mdl.js | 60 + models/users/users.attributes.js | 47 + models/users/users.mdl.js | 59 + package-lock.json | 2399 ++++++++++++++++++++++ package.json | 35 + routes/admin/admin.routes.js | 63 + routes/auth.routes.js | 61 + routes/client/client.routes.js | 34 + routes/staff/staff.routes.js | 33 + server.js | 115 ++ services/email.service.js | 99 + utils/buildQuery.util.js | 81 + utils/excludeJSONBPaths.js | 73 + utils/modelToAttributes.js | 162 ++ utils/otp.util.js | 44 + utils/paginate.util.js | 120 ++ utils/personalInfo.util.js | 59 + utils/response.util.js | 28 + utils/token.util.js | 70 + validators/auth.validator.js | 46 + validators/profile.validator.js | 19 + 38 files changed, 5992 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 .sequelizerc create mode 100644 config/db.config.js create mode 100644 config/passport.config.js create mode 100644 controllers/admin/users.controller.js create mode 100644 controllers/auth.controller.js create mode 100644 controllers/client/profile.controller.js create mode 100644 controllers/staff/users.controller.js create mode 100644 data/email_body.data.js create mode 100644 middleware/auth.middleware.js create mode 100644 middleware/csrf.middleware.js create mode 100644 middleware/rateLimiter.middleware.js create mode 100644 middleware/rbac.middleware.js create mode 100644 middleware/validate.middleware.js create mode 100644 models/users/user_groups.attributes.js create mode 100644 models/users/user_groups.mdl.js create mode 100644 models/users/user_sessions.mdl.js create mode 100644 models/users/users.attributes.js create mode 100644 models/users/users.mdl.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 routes/admin/admin.routes.js create mode 100644 routes/auth.routes.js create mode 100644 routes/client/client.routes.js create mode 100644 routes/staff/staff.routes.js create mode 100644 server.js create mode 100644 services/email.service.js create mode 100644 utils/buildQuery.util.js create mode 100644 utils/excludeJSONBPaths.js create mode 100644 utils/modelToAttributes.js create mode 100644 utils/otp.util.js create mode 100644 utils/paginate.util.js create mode 100644 utils/personalInfo.util.js create mode 100644 utils/response.util.js create mode 100644 utils/token.util.js create mode 100644 validators/auth.validator.js create mode 100644 validators/profile.validator.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d945ee1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.env* +*.log +dist/ +coverage/ diff --git a/.sequelizerc b/.sequelizerc new file mode 100644 index 0000000..4f1b88b --- /dev/null +++ b/.sequelizerc @@ -0,0 +1,10 @@ +'use strict'; + +const path = require('path'); + +module.exports = { + 'config': path.resolve('config', 'database.cjs'), + 'models-path': path.resolve('database', 'models'), + 'migrations-path': path.resolve('database', 'migrations'), + 'seeders-path': path.resolve('database', 'seeders'), +}; diff --git a/README.md b/README.md index dc1f92d..9bf56cc 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,757 @@ -# new_starr -New STARR +# STAR Phase 1 — Tier 1 Auth System +## Technical Documentation + +**Version:** 1.0.0 +**Author:** rgrgogu +**Date:** October 6, 2025 +**Stack:** Node.js · Express · Sequelize (PostgreSQL) · JWT · Google OAuth 2.0 · Nodemailer + +--- + +## Table of Contents + +1. [Architecture Overview](#1-architecture-overview) +2. [Project Structure](#2-project-structure) +3. [Setup & Installation](#3-setup--installation) +4. [Environment Variables](#4-environment-variables) +5. [Database Models](#5-database-models) +6. [Security Layers](#6-security-layers) +7. [Authentication Flows](#7-authentication-flows) +8. [API Reference](#8-api-reference) +9. [RBAC Permission Matrix](#9-rbac-permission-matrix) +10. [File-by-File Documentation](#10-file-by-file-documentation) +11. [Error Codes Reference](#11-error-codes-reference) +12. [Extending the System](#12-extending-the-system) + +--- + +## 1. Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Client (SPA / Mobile / Browser) │ +└────────────────────────────┬────────────────────────────────────┘ + │ HTTPS + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Express.js App (server.js) │ +│ │ +│ ┌──────────┐ ┌───────────┐ ┌────────────┐ ┌────────────┐ │ +│ │ CORS │ │ Rate Limit│ │ Cookie │ │ Session │ │ +│ │ Guard │ │ (global) │ │ Parser │ │ (csurf) │ │ +│ └──────────┘ └───────────┘ └────────────┘ └────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Routes │ │ +│ │ /api/auth/* → auth.routes.js │ │ +│ │ /api/client/* → client.routes.js │ │ +│ │ /api/staff/users/*→ staff.routes.js │ │ +│ │ /api/admin/* → admin.routes.js │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Middleware Chain (per-route) │ │ +│ │ authLimiter → csrfProtection → validate → authenticate │ │ +│ │ → requireRole() → Controller │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Services │ Utils │ │ +│ │ email.service.js │ token.util.js │ │ +│ │ │ otp.util.js │ │ +│ │ │ response.util.js │ │ +│ └──────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────┬───────────────────────┘ + │ Sequelize ORM + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ PostgreSQL │ +│ ┌──────────┐ ┌─────────────────┐ ┌──────────────────────┐ │ +│ │ users │ │ user_sessions │ │ user_groups / │ │ +│ │ │ │ │ │ user_group_members │ │ +│ └──────────┘ └─────────────────┘ └──────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. Project Structure + +``` +new_starr/ +├── server.js ← App entry point +├── package.json +├── .env.example ← Copy to .env and fill values +│ +├── config/ +│ ├── db.config.js ← Sequelize PostgreSQL instance +│ └── passport.config.js ← Google OAuth 2.0 strategy +│ +├── models/ +│ ├── users.mdl.js ← Users table (with OTP fields) +│ ├── user_sessions.mdl.js ← Session audit table +│ └── user_groups.mdl.js ← Groups + Junction table +│ +├── middleware/ +│ ├── auth.middleware.js ← JWT Bearer token verification +│ ├── rbac.middleware.js ← Role-based access guards +│ ├── csrf.middleware.js ← CSRF protection (csurf) +│ ├── rateLimiter.middleware.js ← Per-route rate limits +│ └── validate.middleware.js ← express-validator result handler +│ +├── validators/ +│ ├── auth.validator.js ← register / login / OTP / password rules +│ └── profile.validator.js ← profile update rules +│ +├── controllers/ +│ ├── auth.controller.js ← Shared: register, OTP, login, refresh, logout +│ ├── client/ +│ │ └── profile.controller.js ← Own profile + own sessions +│ ├── staff/ +│ │ └── users.controller.js ← View/toggle non-admin users +│ └── admin/ +│ └── users.controller.js ← Full CRUD + groups +│ +├── routes/ +│ ├── auth.routes.js ← /api/auth/* +│ ├── client/ +│ │ └── client.routes.js ← /api/client/* +│ ├── staff/ +│ │ └── staff.routes.js ← /api/staff/users/* +│ └── admin/ +│ └── admin.routes.js ← /api/admin/* +│ +├── services/ +│ └── email.service.js ← Nodemailer OTP + welcome emails +│ +└── utils/ + ├── token.util.js ← JWT generate / verify / hash + ├── otp.util.js ← OTP generate / expiry / check + └── response.util.js ← Standardised JSON responses +``` + +--- + +## 3. Setup & Installation + +```bash +# 1. Clone / copy the project +cd star-auth-system + +# 2. Install dependencies +npm install + +# 3. Configure environment +cp .env.example .env +# Edit .env with your DB, SMTP, Google OAuth, and JWT credentials + +# 4. Ensure PostgreSQL is running and the DB exists +createdb star_db + +# 5. Start development server (auto-syncs models) +npm run dev + +# 6. Production +npm start +``` + +> **Note:** On first boot, Sequelize will create all tables automatically via `sequelize.sync({ alter: true })`. + +--- + +## 4. Environment Variables + +| Variable | Description | Example | +|---|---|---| +| `NODE_ENV` | `development` or `production` | `development` | +| `PORT` | HTTP port | `3000` | +| `APP_URL` | Frontend/app URL (used for CORS) | `http://localhost:3000` | +| `DB_HOST` | PostgreSQL host | `localhost` | +| `DB_PORT` | PostgreSQL port | `5432` | +| `DB_NAME` | Database name | `star_db` | +| `DB_USER` | DB user | `postgres` | +| `DB_PASSWORD` | DB password | `yourpassword` | +| `JWT_SECRET` | Access token signing key (≥32 chars) | `your_super_secret_…` | +| `JWT_EXPIRES_IN` | Access token TTL | `1d` | +| `JWT_REFRESH_SECRET` | Refresh token signing key | `your_refresh_secret_…` | +| `JWT_REFRESH_EXPIRES_IN` | Refresh token TTL | `7d` | +| `SESSION_SECRET` | Express session secret (used by csurf) | `your_session_secret_…` | +| `GOOGLE_CLIENT_ID` | Google OAuth client ID | from GCP Console | +| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | from GCP Console | +| `GOOGLE_CALLBACK_URL` | OAuth redirect URI | `/api/auth/google/callback` | +| `SMTP_HOST` | Mail server host | `smtp.gmail.com` | +| `SMTP_PORT` | Mail server port | `587` | +| `SMTP_USER` | Mail username | `you@gmail.com` | +| `SMTP_PASS` | Mail password / App Password | `xxxx xxxx xxxx xxxx` | +| `EMAIL_FROM` | Sender address | `no-reply@star-system.com` | +| `OTP_EXPIRY_MINUTES` | OTP validity in minutes | `10` | + +--- + +## 5. Database Models + +### 5.1 `users` Table + +| Column | Type | Notes | +|---|---|---| +| `user_id` | BIGINT PK | Auto-increment | +| `email` | VARCHAR(255) UNIQUE | Validated email | +| `password` | TEXT | bcrypt hash (null for Google users) | +| `is_active` | BOOLEAN | Default `true` | +| `is_verified` | BOOLEAN | Default `false`; set `true` on OTP verify | +| `reg_type` | ENUM | `system` or `google` | +| `acc_type` | ENUM | `user` / `staff` / `admin` | +| `personal_info` | JSONB | Nested name, addresses, phone, avatar, album | +| `otp_code` | VARCHAR(6) | Cleared after verification | +| `otp_expires_at` | TIMESTAMP | Cleared after verification | +| `createdAt` | TIMESTAMP | Auto | +| `updatedAt` | TIMESTAMP | Auto | + +### 5.2 `user_sessions` Table + +| Column | Type | Notes | +|---|---|---| +| `session_id` | BIGINT PK | Auto-increment | +| `user_id` | BIGINT FK | → users.user_id | +| `login_info` | JSONB | `{ date, ip_address, device_info }` | +| `logout_info` | JSONB | Populated on logout | +| `refresh_token_hash` | TEXT | SHA-256 of refresh token | +| `is_active` | BOOLEAN | `false` after logout | + +### 5.3 `user_groups` + `user_group_members` Tables + +Groups are named permission bundles. A user can belong to multiple groups. + +``` +users ──< user_group_members >── user_groups + (user_id, group_id, + joined_at) +``` + +--- + +## 6. Security Layers + +### 6.1 Rate Limiting + +| Limiter | Routes | Window | Max | +|---|---|---|---| +| `globalLimiter` | All routes | 15 min | 1 000 | +| `authLimiter` | `/register`, `/login` | 15 min | 20 | +| `otpLimiter` | `/verify-otp`, `/resend-otp` | 15 min | 5 | +| `sensitiveOpsLimiter` | `/change-password` | 60 min | 10 | + +### 6.2 CSRF Protection + +CSRF is provided via **csurf** (Double Submit Cookie pattern). + +- Retrieve token: `GET /api/auth/csrf-token` +- Send token: `X-CSRF-Token` header OR `_csrf` body field +- Applied automatically to cookie-based flows + +> **SPA / REST API clients using `Authorization: Bearer` are inherently CSRF-safe** because cross-site requests cannot set custom headers. CSRF is included for hybrid/SSR scenarios. + +### 6.3 JWT Authentication + +- **Access Token:** Short-lived (1 day). Sent as `Authorization: Bearer `. +- **Refresh Token:** Long-lived (7 days). SHA-256 hash stored in `user_sessions`. Rotated on every `/refresh` call. +- **Session invalidation:** Logout, password change, and admin force-terminate all flip `is_active = false`. + +### 6.4 Password Security + +- bcryptjs with **cost factor 12** +- Minimum 8 chars, must contain uppercase + digit +- Current password required before change +- All sessions invalidated after password change + +### 6.5 RBAC Hierarchy + +``` +admin ──► can do everything + │ +staff ──► can view/toggle client+staff users; cannot touch admin accounts + │ +user ──► can only manage own profile and sessions +(client) +``` + +--- + +## 7. Authentication Flows + +### 7.1 System Registration → OTP Verify → Auto-Login + +``` +Client Server Email + │ │ │ + │── POST /api/auth/register ──► │ │ + │ { email, password } │ │ + │ │── create user (unverified)│ + │ │── generate OTP ──────────►│ + │◄── 201 { email } ─────────── │ │ + │ │ │ + │── POST /api/auth/verify-otp ►│ │ + │ { email, otp } │ │ + │ │── validate OTP │ + │ │── mark is_verified=true │ + │ │── create session record │ + │ │── send welcome email ────►│ + │◄── 200 { accessToken, │ │ + │ refreshToken, │ │ + │ user } ─────────── │ │ +``` + +### 7.2 System Login + +``` +Client Server + │── POST /api/auth/login ─────►│ + │ { email, password } │ + │ │── lookup user by email + │ │── bcrypt.compare(password, hash) + │ │── create session record + │◄── 200 { accessToken, │ + │ refreshToken, │ + │ session_id, user } ──│ +``` + +### 7.3 Google OAuth + +``` +Client Google Server + │── GET /api/auth/google ──────────────►│ + │ │── redirect to Google + │◄─────── Google consent screen ───────│ + │── approve ──►[Google] ───callback──► │ + │ │── find or create user + │ │── create session + │◄── 200 { accessToken, refreshToken } ─│ +``` + +### 7.4 Token Refresh + +``` +Client Server + │── POST /api/auth/refresh ───►│ + │ { refreshToken } │ + │ │── verify JWT signature + │ │── lookup session by token hash + │ │── rotate refresh token + │◄── 200 { accessToken, │ + │ refreshToken } ─────│ +``` + +### 7.5 Logout + +``` +Client Server + │── POST /api/auth/logout ────►│ + │ Authorization: Bearer ... │ + │ { session_id } │ + │ │── authenticate JWT + │ │── set session.is_active=false + │ │── save logout_info + │◄── 200 "Logged out" ─────── │ +``` + +--- + +## 8. API Reference + +> **Base URL:** `http://localhost:3000/api` +> **Auth header:** `Authorization: Bearer ` + +--- + +### 8.1 Auth Endpoints (Public) + +#### `POST /auth/register` +Register a new client account. + +**Request body:** +```json +{ + "email": "jane@example.com", + "password": "Password1" +} +``` + +**Response 201:** +```json +{ + "status": "success", + "message": "Registration successful. Please check your email for the OTP.", + "data": { "email": "jane@example.com" } +} +``` + +--- + +#### `POST /auth/verify-otp` +Verify email with OTP. Returns tokens (auto-login). + +**Request body:** +```json +{ "email": "jane@example.com", "otp": "048291" } +``` + +**Response 200:** +```json +{ + "status": "success", + "message": "Email verified successfully. You are now logged in.", + "data": { + "accessToken": "eyJ...", + "refreshToken": "eyJ...", + "session_id": 1, + "user": { "user_id": 1, "email": "jane@example.com", "acc_type": "user", ... } + } +} +``` + +--- + +#### `POST /auth/resend-otp` +Resend OTP email (5 requests / 15 min limit). + +**Request body:** `{ "email": "jane@example.com" }` + +--- + +#### `POST /auth/login` +System (email + password) login. + +**Request body:** +```json +{ "email": "jane@example.com", "password": "Password1" } +``` + +**Response 200:** Same shape as verify-otp. + +--- + +#### `POST /auth/refresh` +Rotate access + refresh tokens. + +**Request body:** `{ "refreshToken": "eyJ..." }` + +--- + +#### `GET /auth/google` +Redirect to Google consent screen. No body needed. + +--- + +#### `GET /auth/google/callback` +Google OAuth redirect target. Returns tokens on success. + +--- + +#### `POST /auth/logout` *(requires Bearer token)* +Invalidates the specified session. + +**Request body:** `{ "session_id": 1 }` + +--- + +#### `POST /auth/change-password` *(requires Bearer token)* +Change password. Invalidates all sessions. + +**Request body:** +```json +{ "current_password": "OldPass1", "new_password": "NewPass2" } +``` + +--- + +### 8.2 Client Endpoints *(requires Bearer — any acc_type)* + +| Method | Path | Description | +|---|---|---| +| GET | `/client/profile` | Own profile | +| PUT | `/client/profile` | Update own `personal_info` | +| GET | `/client/sessions` | Own active sessions | +| DELETE | `/client/sessions/:id` | Revoke one of own sessions | + +**PUT /client/profile — Example body:** +```json +{ + "personal_info": { + "name": { + "given_name": "Jane", + "last_name": "Doe" + }, + "occupation": "Engineer", + "date_of_birth": "1995-06-15" + } +} +``` + +--- + +### 8.3 Staff Endpoints *(requires Bearer — staff or admin)* + +| Method | Path | Description | +|---|---|---| +| GET | `/staff/users` | Paginated list (non-admins) | +| GET | `/staff/users/:id` | Single non-admin user | +| PUT | `/staff/users/:id/status` | Toggle `is_active` | +| GET | `/staff/users/:id/sessions` | User's sessions | + +**Query params for GET list:** `?page=1&limit=20` + +**PUT /staff/users/:id/status — Body:** `{ "is_active": false }` + +--- + +### 8.4 Admin Endpoints *(requires Bearer — admin only)* + +| Method | Path | Description | +|---|---|---| +| GET | `/admin/users` | All users (paginated) | +| GET | `/admin/users/:id` | Any user with groups | +| PUT | `/admin/users/:id` | Update `acc_type`, `is_active`, `personal_info` | +| DELETE | `/admin/users/:id` | Hard delete user | +| GET | `/admin/users/:id/sessions` | All sessions | +| DELETE | `/admin/users/:id/sessions/:sid` | Force-terminate session | +| GET | `/admin/groups` | List all groups | +| POST | `/admin/groups` | Create group `{ name, description }` | +| DELETE | `/admin/groups/:gid` | Delete group | +| POST | `/admin/users/:id/groups/:gid` | Add user to group | +| DELETE | `/admin/users/:id/groups/:gid` | Remove user from group | + +**PUT /admin/users/:id — Allowed fields:** +```json +{ + "acc_type": "staff", + "is_active": true, + "personal_info": { ... } +} +``` + +--- + +## 9. RBAC Permission Matrix + +| Action | Client (`user`) | Staff (`staff`) | Admin (`admin`) | +|---|:---:|:---:|:---:| +| Register | ✅ | ✅ | ✅ | +| Login | ✅ | ✅ | ✅ | +| View own profile | ✅ | ✅ | ✅ | +| Edit own profile | ✅ | ✅ | ✅ | +| View own sessions | ✅ | ✅ | ✅ | +| Revoke own session | ✅ | ✅ | ✅ | +| Change own password | ✅ | ✅ | ✅ | +| List all users | ❌ | ✅ (no admins) | ✅ | +| View any user | ❌ | ✅ (no admins) | ✅ | +| Toggle user status | ❌ | ✅ (no admins) | ✅ | +| View user sessions | ❌ | ✅ (no admins) | ✅ | +| Change user `acc_type` | ❌ | ❌ | ✅ | +| View admin accounts | ❌ | ❌ | ✅ | +| Hard delete users | ❌ | ❌ | ✅ | +| Force-terminate session | ❌ | ❌ | ✅ | +| Manage groups | ❌ | ❌ | ✅ | + +--- + +## 10. File-by-File Documentation + +### `server.js` +**Purpose:** Application entry point. Bootstraps Express, registers global middleware (CORS, rate limiter, cookie-parser, session, Passport), mounts all routes, syncs DB models. +**Key behaviors:** +- Runs `sequelize.sync({ alter: true })` on startup (development) — safe column updates. +- Registers a global 404 handler and error handler. +- Enables `trust proxy` for correct IP detection behind load balancers. + +--- + +### `config/db.config.js` +**Purpose:** Creates and exports a single authenticated Sequelize instance. +**Usage:** `const sequelize = require('./config/db.config');` + +--- + +### `config/passport.config.js` +**Purpose:** Registers the Google OAuth 2.0 strategy. Auto-creates user on first Google login with `reg_type='google'` and `is_verified=true`. +**Usage:** Imported in `server.js`; used in `routes/auth.routes.js` via `passport.authenticate('google', ...)`. + +--- + +### `models/users.mdl.js` +**Purpose:** Defines the `users` table including OTP fields (`otp_code`, `otp_expires_at`) added for the verification flow. +**Exported as:** `mdl_Users` (default) + +--- + +### `models/user_sessions.mdl.js` +**Purpose:** Audit log for logins/logouts. Stores `refresh_token_hash` for secure token rotation and session invalidation. Sets up `belongsTo/hasMany` with Users. +**Exported as:** `mdl_UserSessions` (default) + +--- + +### `models/user_groups.mdl.js` +**Purpose:** `user_groups` and `user_group_members` junction table. Implements `belongsToMany` associations between Users and Groups. +**Exported as:** `{ mdl_UserGroups, mdl_UserGroupMembers }` + +--- + +### `middleware/auth.middleware.js` +**Purpose:** Reads `Authorization: Bearer `, calls `verifyAccessToken()`, loads the user from DB, and attaches it to `req.user`. +**Exported:** `authenticate` + +--- + +### `middleware/rbac.middleware.js` +**Purpose:** Role guards that inspect `req.user.acc_type`. All guards must be used **after** `authenticate`. +**Exported:** `requireClient()`, `requireStaff()`, `requireAdmin()`, `requireOwnerOrStaff()`, `requireOwnerOrAdmin()` + +--- + +### `middleware/csrf.middleware.js` +**Purpose:** Wraps `csurf` with a token endpoint and a dedicated error handler. +**Exported:** `csrfProtection`, `getCsrfToken`, `csrfErrorHandler` + +--- + +### `middleware/rateLimiter.middleware.js` +**Purpose:** Four rate-limiter configurations for different route tiers. +**Exported:** `globalLimiter`, `authLimiter`, `otpLimiter`, `sensitiveOpsLimiter` + +--- + +### `middleware/validate.middleware.js` +**Purpose:** Reads the `validationResult` from express-validator and returns 422 if any rule failed. +**Exported:** `validate` + +--- + +### `validators/auth.validator.js` +**Purpose:** express-validator chains for `register`, `login`, `verify-otp`, `resend-otp`, `change-password`. +**Usage:** Spread into route definitions: `router.post('/login', ...loginValidator, validate, handler)` + +--- + +### `validators/profile.validator.js` +**Purpose:** Validates `personal_info` fields on profile update requests. + +--- + +### `utils/token.util.js` +**Purpose:** JWT helpers. +**Functions:** +- `generateTokens(user)` → `{ accessToken, refreshToken }` +- `verifyAccessToken(token)` → decoded payload (throws on expiry/invalid) +- `verifyRefreshToken(token)` → decoded payload +- `hashToken(token)` → SHA-256 hex string for DB storage + +--- + +### `utils/otp.util.js` +**Purpose:** Cryptographically secure OTP helpers. +**Functions:** +- `generateOTP()` → `"048291"` (6 digits, padded) +- `getOTPExpiry(minutes)` → `Date` N minutes from now +- `isOTPExpired(expiresAt)` → `boolean` + +--- + +### `utils/response.util.js` +**Purpose:** Standardised response envelope `{ status, message, data?, errors? }`. +**Functions:** `success(res, msg, data, statusCode)`, `error(res, msg, statusCode, errors)`, `validationError(res, errors)` + +--- + +### `services/email.service.js` +**Purpose:** Nodemailer transporter with two email templates. +**Functions:** +- `sendOTPEmail(to, otp, expiryMinutes)` — styled HTML OTP email +- `sendWelcomeEmail(to, name)` — post-verification welcome + +--- + +### `controllers/auth.controller.js` +**Purpose:** Shared auth logic: `register`, `verifyOTP`, `resendOTP`, `login`, `googleCallback`, `refreshToken`, `logout`, `changePassword`. + +--- + +### `controllers/client/profile.controller.js` +**Purpose:** Self-service profile actions for any authenticated user. +**Functions:** `getProfile`, `updateProfile` (deep-merge), `getSessions`, `revokeSession` + +--- + +### `controllers/staff/users.controller.js` +**Purpose:** Staff-level user management (read-only + status toggle; no admin data). +**Functions:** `getUsers`, `getUser`, `setUserStatus`, `getUserSessions` + +--- + +### `controllers/admin/users.controller.js` +**Purpose:** Full admin control over users, sessions, and groups. +**Functions:** `getUsers`, `getUser`, `updateUser`, `deleteUser`, `getUserSessions`, `terminateSession`, `getGroups`, `createGroup`, `deleteGroup`, `addUserToGroup`, `removeUserFromGroup` + +--- + +## 11. Error Codes Reference + +| HTTP | Scenario | +|---|---| +| 400 | Bad request (missing field, wrong type, already verified) | +| 401 | Missing/invalid/expired token, wrong credentials | +| 403 | Account deactivated, wrong role, CSRF error | +| 404 | Resource not found | +| 409 | Email already registered | +| 422 | Validation failed (see `errors[]` array) | +| 429 | Rate limit exceeded | +| 500 | Internal server error | + +All error responses follow: +```json +{ + "status": "error", + "message": "Human-readable reason", + "errors": [ ... ] // only on 422 +} +``` + +--- + +## 12. Extending the System + +### Add a New Endpoint + +1. Create validator rules in `validators/` +2. Create or extend a controller in `controllers//` +3. Add the route in `routes//.routes.js` with appropriate middleware chain +4. No changes to `server.js` needed if mounting the same base path + +### Add a New Role + +1. Add the ENUM value to `acc_type` in `users.mdl.js` +2. Add a guard in `rbac.middleware.js` (e.g., `requireSuperAdmin()`) +3. Create a dedicated route + controller folder + +### Add Redis Session Store + +Install `connect-redis` and update `server.js`: +```js +const RedisStore = require('connect-redis').default; +const { createClient } = require('redis'); +const redisClient = createClient({ url: process.env.REDIS_URL }); +await redisClient.connect(); +app.use(session({ store: new RedisStore({ client: redisClient }), ... })); +``` + +### Production Checklist + +- [ ] Set `NODE_ENV=production` +- [ ] Use strong `JWT_SECRET` (≥ 64 random characters) +- [ ] Enable HTTPS / TLS (set `cookie.secure: true`) +- [ ] Point `APP_URL` to your actual domain for CORS +- [ ] Replace in-memory rate limiter with Redis store +- [ ] Set up log rotation (replace `console.log` with Winston/Pino) +- [ ] Use `sequelize.sync({ force: false, alter: false })` in production (run migrations instead) + +--- + +*End of Documentation — STAR Phase 1 Auth System v1.0.0* \ No newline at end of file diff --git a/config/db.config.js b/config/db.config.js new file mode 100644 index 0000000..48c9c82 --- /dev/null +++ b/config/db.config.js @@ -0,0 +1,41 @@ +/*********************************************************************************************************************************************************************** + * File Name: db.config.js + * Type of Program: Configuration + * Description: Sequelize PostgreSQL connection instance. + * Reads credentials from environment variables and exposes a single + * authenticated Sequelize instance used across all models. + * Author: rgrgogu + * Date Created: Oct. 6, 2025 + *********************************************************************************************************************************************************************** + * HOW TO USE: + * const sequelize = require('./config/db.config'); + * // Then use sequelize.define() or import your models directly. + ***********************************************************************************************************************************************************************/ +require('dotenv').config(); +const { Sequelize } = require('sequelize'); + +const sequelize = new Sequelize( + process.env.DB_NAME, + process.env.DB_USER, + process.env.DB_PASSWORD, + { + host: process.env.DB_HOST, + port: process.env.DB_PORT || 5432, + dialect: 'postgres', + dialectOptions: { + ssl: { + require: true, + rejectUnauthorized: false, // or provide CA cert if strict + }, + }, + logging: process.env.NODE_ENV === 'development' ? console.log : false, + pool: { + max: 10, + min: 0, + acquire: 30000, + idle: 10000, + }, + } +); + +module.exports = sequelize; \ No newline at end of file diff --git a/config/passport.config.js b/config/passport.config.js new file mode 100644 index 0000000..a7d0bef --- /dev/null +++ b/config/passport.config.js @@ -0,0 +1,70 @@ +/*********************************************************************************************************************************************************************** + * File Name: passport.config.js + * Type of Program: Configuration + * Description: Passport.js strategy configuration. + * Registers the Google OAuth 2.0 strategy. On first login via Google + * a new user record is auto-created with reg_type="google". + * Returns a signed JWT payload after successful authentication. + * Author: rgrgogu + * Date Created: Oct. 6, 2025 + *********************************************************************************************************************************************************************** + * HOW TO USE: + * In server.js: + * require('./config/passport.config'); + * app.use(passport.initialize()); + * In routes: + * router.get('/google', passport.authenticate('google', { scope: ['profile', 'email'] })); + * router.get('/google/callback', passport.authenticate('google', { session: false }), handler); + ***********************************************************************************************************************************************************************/ +const passport = require('passport'); +const { Strategy: GoogleStrategy } = require('passport-google-oauth20'); +const mdl_Users = require('../models/users/users.mdl'); + +passport.use( + new GoogleStrategy( + { + clientID: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + callbackURL: process.env.GOOGLE_CALLBACK_URL, + }, + async (accessToken, refreshToken, profile, done) => { + try { + const email = profile.emails?.[0]?.value; + if (!email) return done(new Error('No email returned from Google'), null); + + let user = await mdl_Users.findOne({ where: { email } }); + + if (!user) { + // Auto-register Google users as 'user' acc_type + user = await mdl_Users.create({ + email, + reg_type: 'google', + acc_type: 'user', + is_active: true, + is_verified: true, // Google accounts are pre-verified + personal_info: { + name: { + given_name: profile.name?.givenName || '', + last_name: profile.name?.familyName || '', + full_name: profile.displayName || '', + }, + avatar: { + url: profile.photos?.[0]?.value || null, + }, + }, + }); + } + + if (!user.is_active) { + return done(null, false, { message: 'Account is deactivated.' }); + } + + return done(null, user); + } catch (err) { + return done(err, null); + } + } + ) +); + +module.exports = passport; \ No newline at end of file diff --git a/controllers/admin/users.controller.js b/controllers/admin/users.controller.js new file mode 100644 index 0000000..640e1c9 --- /dev/null +++ b/controllers/admin/users.controller.js @@ -0,0 +1,446 @@ +/*********************************************************************************************************************************************************************** + * File Name: users.controller.js (admin) + * Type of Program: Controller + * Description: Admin-level user management — full CRUD on any user. + * Admins can: list all users, view any user, update acc_type, + * activate/deactivate, hard-delete, manage groups. + * + * Endpoints (require authenticate → requireAdmin()): + * GET /api/admin/users → all users (paginated) + * GET /api/admin/users/:id → any user + * PUT /api/admin/users/:id → update acc_type, is_active, personal_info + * DELETE /api/admin/users/:id → hard delete + * POST /api/admin/users/:id/groups/:gid → add user to group + * DELETE /api/admin/users/:id/groups/:gid → remove user from group + * GET /api/admin/groups → list groups + * POST /api/admin/groups → create group + * DELETE /api/admin/groups/:gid → delete group + * + * Author: rgrgogu + * Date Created: Oct. 6, 2025 + ***********************************************************************************************************************************************************************/ +const { Op, Sequelize } = require('sequelize') +const mdl_Users = require('../../models/users/users.mdl'); +const mdl_UserSessions = require('../../models/users/user_sessions.mdl'); +const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl'); + +const R = require('../../utils/response.util'); +const { paginate, auditInclude } = require('../../utils/paginate.util'); +const { enrichPersonalInfo } = require('../../utils/personalInfo.util'); + +const { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas } = require('../../models/users/users.attributes'); +const { excludeAttributes: groupExclude, jsonbSchemas: groupSchemas } = require('../../models/users/user_groups.attributes'); + +const EXCLUDED = ['password', 'otp_code', 'otp_expires_at']; + +exports.getUsers = async (req, res) => { + try { + const result = await paginate(mdl_Users, req, { + excludeAttributes: usersExclude, + jsonbSchemas: usersSchemas, + jsonbColumn: 'personal_info', + auditOptions: { mdl_Users, parentAlias: 'User' }, + }); + + return R.success(res, 'Users retrieved.', result); + } catch (err) { + console.error('[ADMIN][GET ALL USERS]', err); + return R.error(res, 'Could not retrieve users.', 500); + } +}; + +// ─── GET single user ─────────────────────────────────────────────────────────── +exports.getUser = async (req, res) => { + try { + const user = await mdl_Users.findByPk(req.params.id, { + attributes: { exclude: EXCLUDED }, + include: [{ + model: mdl_UserGroups, + as: 'groups', // ← matches the association alias + through: { attributes: [] } + }], + }); + if (!user) return R.error(res, 'User not found.', 404); + return R.success(res, 'User retrieved.', user); + } catch (err) { + console.error("[ADMIN][GET USER]", err); + return R.error(res, 'Could not retrieve user.', 500); + } +}; + +// ─── PUT update any user ─────────────────────────────────────────────────────── +exports.updateUser = async (req, res) => { + try { + const user = await mdl_Users.findByPk(req.params.id); + if (!user) return R.error(res, 'User not found.', 404); + + const allowed = ['acc_type', 'is_active', 'personal_info']; + const updates = {}; + allowed.forEach((k) => { if (req.body[k] !== undefined) updates[k] = req.body[k]; }); + + if (Number(req.params.id) === req.user.user_id && updates.acc_type !== undefined) + return R.error(res, 'Admins cannot change their own role.', 400); + + // Enrich personal_info before saving + if (updates.personal_info) { + updates.personal_info = enrichPersonalInfo(updates.personal_info); + } + + updates.updatedBy = req.user.user_id; // ← who updated + + await user.update(updates); + const updated = await mdl_Users.findByPk(req.params.id, { attributes: { exclude: EXCLUDED } }); + return R.success(res, 'User updated.', updated); + } catch (err) { + console.error("[ADMIN][UPDATE USER]", err); + return R.error(res, 'Could not update user.', 500); + } +}; + +// ─── DEACTIVATE user (soft delete) ──────────────────────────────────────────── +exports.deactivateUser = async (req, res) => { + try { + if (Number(req.params.id) === req.user.user_id) + return R.error(res, 'You cannot deactivate your own account.', 400); + + const user = await mdl_Users.findByPk(req.params.id); + if (!user) return R.error(res, 'User not found.', 404); + if (!user.is_active && user.deletedAt) + return R.error(res, 'User is already deactivated.', 400); + + await user.update({ + is_active: false, + deletedBy: req.user.user_id, // ← who deactivated + }); + + await user.destroy(); + + await mdl_UserSessions.update( + { is_active: false, logout_info: { date: new Date().toISOString(), forced_by: req.user.user_id } }, + { where: { user_id: req.params.id } } + ); + + return R.success(res, 'User deactivated successfully.'); + } catch (err) { + console.error("[ADMIN][DEACTIVATE USER]", err); + return R.error(res, 'Could not deactivate user.', 500); + } +}; + +// ─── RESTORE user ────────────────────────────────────────────────────────────── +exports.restoreUser = async (req, res) => { + try { + const user = await mdl_Users.findOne({ + where: { user_id: req.params.id }, + paranoid: false, + }); + if (!user) return R.error(res, 'User not found.', 404); + if (!user.deletedAt) return R.error(res, 'User is not deactivated.', 400); + + await user.restore(); + await user.update({ + is_active: true, + updatedBy: req.user.user_id, // ← who restored + }); + + return R.success(res, 'User restored successfully.'); + } catch (err) { + console.error("[ADMIN][RESTORE USER]", err); + return R.error(res, 'Could not restore user.', 500); + } +}; + +exports.getGroups = async (req, res) => { + try { + const result = await paginate(mdl_UserGroups, req, { + excludeAttributes: groupExclude, + jsonbSchemas: groupSchemas, + auditOptions: { mdl_Users, parentAlias: 'UserGroup' }, + findOptions: { + attributes: { + include: [ + [ + Sequelize.literal(`( + SELECT CAST(COUNT(*) AS INTEGER) + FROM "user_group_members" + WHERE "user_group_members"."group_id" = "UserGroup"."group_id" + )`), + 'memberCount', + ], + ], + }, + }, + }); + + return R.success(res, 'Groups retrieved.', result); + } catch (err) { + console.error('[ADMIN][GET GROUPS]', err); + return R.error(res, 'Could not retrieve groups.', 500); + } +}; + +exports.getGroup = async (req, res) => { + try { + const group = await mdl_UserGroups.findByPk(req.params.gid); + if (!group) return R.error(res, 'Group not found.', 404); + + const members = await paginate(mdl_Users, req, { + excludeAttributes: usersExclude, + jsonbSchemas: usersSchemas, + jsonbColumn: 'personal_info', + auditOptions: { mdl_Users, parentAlias: 'User' }, + findOptions: { + include: [ + { + model: mdl_UserGroupMembers, + where: { group_id: req.params.gid }, + attributes: [], + required: true, + }, + ], + }, + }); + + return R.success(res, 'Group retrieved.', { group, members }); + } catch (err) { + console.error('[ADMIN][GET GROUP]', err); + return R.error(res, 'Could not retrieve group.', 500); + } +}; + +exports.createGroup = async (req, res) => { + try { + const { name, description } = req.body; + if (!name) return R.error(res, 'Group name is required.', 400); + + const group = await mdl_UserGroups.create({ + name, + description, + createdBy: req.user.user_id, + }); + + return R.success(res, 'Group created.', group, 201); + } catch (err) { + console.error('[ADMIN][CREATE GROUP]', err); + return R.error(res, 'Could not create group.', 500); + } +}; + +exports.updateGroup = async (req, res) => { + try { + const { name, description } = req.body; + + const group = await mdl_UserGroups.findByPk(req.params.gid); + if (!group) return R.error(res, 'Group not found.', 404); + + if (name !== undefined) group.name = name; + if (description !== undefined) group.description = description; + group.updatedBy = req.user.user_id; + await group.save(); + + return R.success(res, 'Group updated.', group); + } catch (err) { + console.error('[ADMIN][UPDATE GROUP]', err); + return R.error(res, 'Could not update group.', 500); + } +}; + +exports.deactivateGroup = async (req, res) => { + try { + const group = await mdl_UserGroups.findByPk(req.params.gid); + if (!group) return R.error(res, 'Group not found.', 404); + if (!group.is_active) return R.error(res, 'Group is already deactivated.', 400); + + await group.update({ + is_active: false, + updatedBy: req.user.user_id, + deletedBy: req.user.user_id, + }); + await group.destroy(); + + return R.success(res, 'Group deactivated.'); + } catch (err) { + console.error('[ADMIN][DEACTIVATE GROUP]', err); + return R.error(res, 'Could not deactivate group.', 500); + } +}; + +exports.restoreGroup = async (req, res) => { + try { + const group = await mdl_UserGroups.findOne({ + where: { group_id: req.params.gid }, + paranoid: false, // ← needed to find soft-deleted rows + }); + if (!group) return R.error(res, 'Group not found.', 404); + if (group.is_active) return R.error(res, 'Group is already active.', 400); + + await group.restore(); + await group.update({ + is_active: true, + updatedBy: req.user.user_id, + deletedBy: null, + }); + + return R.success(res, 'Group restored.'); + } catch (err) { + console.error('[ADMIN][RESTORE GROUP]', err); + return R.error(res, 'Could not restore group.', 500); + } +}; + +// ─── Group membership ────────────────────────────────────────────────────────── +// Get List of Members in a Group, to be added +exports.getUsersNotInGroup = async (req, res) => { + try { + const { gid: group_id } = req.params; + + const members = await mdl_UserGroupMembers.findAll({ + where: { group_id }, + attributes: ['user_id'], + }); + + const memberIds = members.map(m => m.user_id); + + const users = await mdl_Users.findAll({ + where: { + user_id: { [Op.notIn]: memberIds.length ? memberIds : [0] }, + }, + attributes: [ + 'user_id', + [Sequelize.literal(`("User"."personal_info"->'name'->>'full_name')`), 'full_name'], + ], + }); + + return R.success(res, 'Users fetched.', users); + } catch (err) { + console.error('[ADMIN][GET USERS NOT IN GROUP]', err); + return R.error(res, 'Could not fetch users.', 500); + } +}; + +// Get List of Members in a Group, to be removed +exports.getUsersInGroup = async (req, res) => { + try { + const { gid: group_id } = req.params; + + const group = await mdl_UserGroups.findByPk(group_id, { + include: [{ + model: mdl_Users, + as: 'members', + through: { attributes: [] }, + attributes: [ + 'user_id', + [Sequelize.literal(`("members"."personal_info"->'name'->>'full_name')`), 'full_name'], + ], + }], + }); + + if (!group) return R.error(res, 'Group not found.', 404); + + return R.success(res, 'Group members fetched.', group.members); + } catch (err) { + console.error('[ADMIN][GET USERS IN GROUP]', err); + return R.error(res, 'Could not fetch group members.', 500); + } +}; + +exports.addUserToGroup = async (req, res) => { + try { + const { gid: group_id } = req.params; + const { user_ids } = req.body; + + if (!Array.isArray(user_ids) || user_ids.length === 0) + return R.error(res, 'No users provided.', 400); + + const existingUsers = await mdl_Users.findAll({ + where: { user_id: user_ids }, + attributes: ['user_id'], + }); + + const existingUserIds = existingUsers.map(u => u.user_id); + const notFound = user_ids.filter(id => !existingUserIds.includes(id)); + + if (notFound.length > 0) + return R.error(res, `Users not found: ${notFound.join(', ')}`, 404); + + // Restore soft-deleted rows + await mdl_UserGroupMembers.restore({ + where: { user_id: user_ids, group_id }, + }); + + await mdl_UserGroupMembers.update( + { deletedBy: null, updatedBy: req.user.user_id }, + { where: { user_id: user_ids, group_id }, paranoid: false } + ); + + // Insert any that didn't exist at all + await mdl_UserGroupMembers.bulkCreate( + user_ids.map(user_id => ({ user_id, group_id, createdBy: req.user.user_id })), + { ignoreDuplicates: true } + ); + + return R.success(res, 'Users added to group.'); + } catch (err) { + console.error('[ADMIN][ADD USERS TO GROUP]', err); + return R.error(res, 'Could not add users to group.', 500); + } +}; + +exports.removeUserFromGroup = async (req, res) => { + try { + const { gid: group_id } = req.params; + const { user_ids } = req.body; + + if (!Array.isArray(user_ids) || user_ids.length === 0) + return R.error(res, 'No users provided.', 400); + + const existingMembers = await mdl_UserGroupMembers.findAll({ + where: { user_id: user_ids, group_id }, + attributes: ['user_id'], + }); + + const existingMemberIds = existingMembers.map(m => m.user_id); + const notFound = user_ids.filter(id => !existingMemberIds.includes(id)); + + if (notFound.length > 0) + return R.error(res, `Memberships not found for users: ${notFound.join(', ')}`, 404); + + await mdl_UserGroupMembers.update( + { deletedBy: req.user.user_id }, + { where: { user_id: user_ids, group_id } } + ); + await mdl_UserGroupMembers.destroy({ + where: { user_id: user_ids, group_id }, + }); + + return R.success(res, 'Users removed from group.'); + } catch (err) { + console.error('[ADMIN][REMOVE USERS FROM GROUP]', err); + return R.error(res, 'Could not remove users from group.', 500); + } +}; + +// ─── Admin sessions management ───────────────────────────────────────────────── +exports.getUserSessions = async (req, res) => { + try { + const sessions = await mdl_UserSessions.findAll({ + where: { user_id: req.params.id }, + attributes: { exclude: ['refresh_token_hash'] }, + order: [['createdAt', 'DESC']], + }); + return R.success(res, 'Sessions retrieved.', sessions); + } catch (err) { + return R.error(res, 'Could not retrieve sessions.', 500); + } +}; + +exports.terminateSession = async (req, res) => { + try { + const session = await mdl_UserSessions.findByPk(req.params.sid); + if (!session) return R.error(res, 'Session not found.', 404); + await session.update({ is_active: false, logout_info: { date: new Date().toISOString(), forced_by: req.user.user_id } }); + return R.success(res, 'Session terminated.'); + } catch (err) { + return R.error(res, 'Could not terminate session.', 500); + } +}; \ No newline at end of file diff --git a/controllers/auth.controller.js b/controllers/auth.controller.js new file mode 100644 index 0000000..9fc0124 --- /dev/null +++ b/controllers/auth.controller.js @@ -0,0 +1,318 @@ +/*********************************************************************************************************************************************************************** + * File Name: auth.controller.js + * Type of Program: Controller + * Description: Handles all authentication flows: + * 1. System Registration → OTP email → OTP Verify → Auto-Login + * 2. System Login (verified users) + * 3. Google OAuth callback + * 4. Token Refresh + * 5. Logout (invalidates session) + * 6. OTP Resend + * 7. Change Password + * + * Author: rgrgogu + * Date Created: Oct. 6, 2025 + *********************************************************************************************************************************************************************** + * HOW TO USE: + * Mount via routes/auth.routes.js + * POST /api/auth/register + * POST /api/auth/verify-otp + * POST /api/auth/resend-otp + * POST /api/auth/login + * POST /api/auth/refresh + * POST /api/auth/logout + * GET /api/auth/google + * GET /api/auth/google/callback + ***********************************************************************************************************************************************************************/ +const bcrypt = require('bcryptjs'); +const sequelize = require('../config/db.config') +const mdl_Users = require('../models/users/users.mdl'); +const mdl_UserSessions = require('../models/users/user_sessions.mdl'); +const { generateTokens, verifyRefreshToken, hashToken } = require('../utils/token.util'); +const { generateOTP, getOTPExpiry, isOTPExpired } = require('../utils/otp.util'); +const sendEmail = require('../services/email.service'); +const R = require('../utils/response.util'); + +const EXCLUDED = ['password', 'otp_code', 'otp_expires_at', 'createdAt', 'updatedAt', 'deletedAt', 'createdBy', 'updatedBy', 'deletedBy']; + +// ─── Helpers ─────────────────────────────────────────────────────────────────── +const buildLoginInfo = (req) => ({ + date: new Date().toISOString(), + ip_address: req.ip, + device_info: req.headers['user-agent'] || 'unknown', +}); + +const safeUser = (user, extraExclude = []) => { + const u = user.toJSON ? user.toJSON() : { ...user }; + + [...EXCLUDED, ...extraExclude].forEach((key) => delete u[key]); + + return u; +}; + +// ─── Register ────────────────────────────────────────────────────────────────── +exports.register = async (req, res) => { + const transaction = await sequelize.transaction(); + + try { + const { email, password } = req.body; + + const existing = await mdl_Users.findOne({ where: { email } }); + if (existing) return R.error(res, 'Email is already registered.', 409); + + const hashed = await bcrypt.hash(password, 12); + const otp = generateOTP(); + + const user = await mdl_Users.create({ + email, + password: hashed, + otp_code: otp, + otp_expires_at: getOTPExpiry(), + is_active: true, + is_verified: false, + reg_type: 'system', + acc_type: 'user', + createdBy: null, + }, { transaction }); + + await sendEmail({ to: email, type: "OTP", data: { otp } }); + + await transaction.commit(); + + return R.success(res, 'Registration successful. Please check your email for the OTP.', { + email: user.email, + }, 201); + } catch (err) { + await transaction.rollback(); + console.error('[AUTH] register error:', err); + return R.error(res, 'Registration failed.', 500); + } +}; + +// ─── Verify OTP ──────────────────────────────────────────────────────────────── +exports.verifyOTP = async (req, res) => { + const transaction = await sequelize.transaction(); + + try { + const { email, otp } = req.body; + + const user = await mdl_Users.findOne({ where: { email } }); + if (!user) return R.error(res, 'User not found.', 404); + if (user.is_verified) return R.error(res, 'Account already verified.', 400); + + if (user.otp_code !== otp) return R.error(res, 'Invalid OTP.', 400); + if (isOTPExpired(user.otp_expires_at)) return R.error(res, 'OTP has expired. Please request a new one.', 400); + + await user.update({ is_verified: true, otp_code: null, otp_expires_at: null }, { transaction }); + + // Auto-login after verification + const { accessToken, refreshToken } = generateTokens(user); + const session = await mdl_UserSessions.create({ + user_id: user.user_id, + login_info: buildLoginInfo(req), + refresh_token_hash: hashToken(refreshToken), + is_active: true, + }, { transaction }); + + await sendEmail({ to: email, type: "WELCOME", data: { name: email } }); + + await transaction.commit(); + + res.cookie('refreshToken', refreshToken, { + httpOnly: true, // ← JS cannot read this + secure: process.env.NODE_ENV === 'production', + sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax', // ← CSRF protection + maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days + }); + + return R.success(res, 'Email verified successfully. You are now logged in.', { + accessToken, + refreshToken, + session_id: session.session_id, + user: safeUser(user), + }); + } catch (err) { + await transaction.rollback(); + console.error('[AUTH] verifyOTP error:', err); + return R.error(res, 'OTP verification failed.', 500); + } +}; + +// ─── Resend OTP ──────────────────────────────────────────────────────────────── +exports.resendOTP = async (req, res) => { + const transaction = await sequelize.transaction(); + + try { + const { email } = req.body; + const user = await mdl_Users.findOne({ where: { email } }); + if (!user) return R.error(res, 'User not found.', 404); + if (user.is_verified) return R.error(res, 'Account is already verified.', 400); + + const otp = generateOTP(); + await user.update({ otp_code: otp, otp_expires_at: getOTPExpiry() }, { transaction }); + + await sendEmail({ to: email, type: "OTP", data: { otp } }); + + await transaction.commit(); + + return R.success(res, 'A new OTP has been sent to your email.'); + } catch (err) { + await transaction.rollback(); + console.error('[AUTH] resendOTP error:', err); + return R.error(res, 'Could not resend OTP.', 500); + } +}; + +// ─── System Login ────────────────────────────────────────────────────────────── +exports.login = async (req, res) => { + try { + const { email, password } = req.body; + + const user = await mdl_Users.findOne({ where: { email } }); + if (!user) return R.error(res, 'Invalid credentials.', 401); + if (user.reg_type === 'google') return R.error(res, 'Please log in with Google.', 400); + if (!user.is_active) return R.error(res, 'Account is deactivated.', 403); + if (!user.is_verified) return R.error(res, 'Please verify your email first.', 403); + + const match = await bcrypt.compare(password, user.password); + if (!match) return R.error(res, 'Invalid credentials.', 401); + + const { accessToken, refreshToken } = generateTokens(user); + const session = await mdl_UserSessions.create({ + user_id: user.user_id, + login_info: buildLoginInfo(req), + refresh_token_hash: hashToken(refreshToken), + is_active: true, + }); + + res.cookie('refreshToken', refreshToken, { + httpOnly: true, // ← JS cannot read this + secure: process.env.NODE_ENV === 'production', + sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax', // ← CSRF protection + maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days + }); + + return R.success(res, 'Login successful.', { + accessToken, + session_id: session.session_id, + user: safeUser(user), + }); + } catch (err) { + console.error('[AUTH] login error:', err); + return R.error(res, 'Login failed.', 500); + } +}; + +// ─── Google OAuth Callback ───────────────────────────────────────────────────── +exports.googleCallback = async (req, res) => { + try { + const user = req.user; // set by passport + const { accessToken, refreshToken } = generateTokens(user); + + await mdl_UserSessions.create({ + user_id: user.user_id, + login_info: buildLoginInfo(req), + refresh_token_hash: hashToken(refreshToken), + is_active: true, + }); + + // In a real SPA: redirect with tokens in query or set httpOnly cookie + return R.success(res, 'Google login successful.', { + accessToken, + refreshToken, + user: safeUser(user), + }); + } catch (err) { + console.error('[AUTH] googleCallback error:', err); + return R.error(res, 'Google authentication failed.', 500); + } +}; + +// ─── Refresh Token ───────────────────────────────────────────────────────────── +exports.refreshToken = async (req, res) => { + try { + const refreshToken = req.cookies.refreshToken; + if (!refreshToken) return R.error(res, 'Refresh token is required.', 400); + + console.log('[REFRESH] called, token tail:', refreshToken?.slice(-10)) + console.log('[REFRESH] hash:', hashToken(refreshToken)) + + const decoded = verifyRefreshToken(refreshToken); + const tokenHash = hashToken(refreshToken); + + console.log({ user_id: decoded.user_id, refresh_token_hash: tokenHash, is_active: true }) + const session = await mdl_UserSessions.findOne({ + where: { user_id: decoded.user_id, refresh_token_hash: tokenHash, is_active: true }, + }); + if (!session) return R.error(res, 'Session is invalid or expired. Please log in again.', 401); + + const user = await mdl_Users.findByPk(decoded.user_id); + if (!user || !user.is_active) return R.error(res, 'User not found or deactivated.', 401); + + const tokens = generateTokens(user); + + // Rotate refresh token + await session.update({ refresh_token_hash: hashToken(tokens.refreshToken) }); + + console.log('[REFRESH] old token tail:', refreshToken?.slice(-10)) + console.log('[REFRESH] new token tail:', tokens.refreshToken?.slice(-10)) + console.log('[REFRESH] are they different:', refreshToken !== tokens.refreshToken) + + res.cookie('refreshToken', tokens.refreshToken, { + httpOnly: true, // ← JS cannot read this + secure: process.env.NODE_ENV === 'production', + sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax', // ← CSRF protection + maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days + }); + + return R.success(res, 'Token refreshed.', { ...tokens, user: safeUser(user) }); + } catch (err) { + return R.error(res, 'Invalid or expired refresh token.', 401); + } +}; + +// ─── Logout ──────────────────────────────────────────────────────────────────── +exports.logout = async (req, res) => { + try { + const { session_id } = req.body; + if (session_id) { + await mdl_UserSessions.update( + { is_active: false, logout_info: buildLoginInfo(req) }, + { where: { session_id, user_id: req.user.user_id } } + ); + } + + res.clearCookie('refreshToken') + res.clearCookie('_csrf') + + return R.success(res, 'Logged out successfully.'); + } catch (err) { + console.error('[AUTH] logout error:', err); + return R.error(res, 'Logout failed.', 500); + } +}; + +// ─── Change Password ─────────────────────────────────────────────────────────── +exports.changePassword = async (req, res) => { + try { + const { current_password, new_password } = req.body; + const user = await mdl_Users.findByPk(req.user.user_id); + + if (user.reg_type === 'google') + return R.error(res, 'Google accounts cannot change passwords here.', 400); + + const match = await bcrypt.compare(current_password, user.password); + if (!match) return R.error(res, 'Current password is incorrect.', 400); + + const hashed = await bcrypt.hash(new_password, 12); + await user.update({ password: hashed }); + + // Invalidate all sessions to force re-login + await mdl_UserSessions.update({ is_active: false }, { where: { user_id: user.user_id } }); + + return R.success(res, 'Password changed. All sessions have been invalidated. Please log in again.'); + } catch (err) { + console.error('[AUTH] changePassword error:', err); + return R.error(res, 'Password change failed.', 500); + } +}; \ No newline at end of file diff --git a/controllers/client/profile.controller.js b/controllers/client/profile.controller.js new file mode 100644 index 0000000..61f7960 --- /dev/null +++ b/controllers/client/profile.controller.js @@ -0,0 +1,92 @@ +/*********************************************************************************************************************************************************************** + * File Name: profile.controller.js (client) + * Type of Program: Controller + * Description: Self-service profile management for CLIENT users. + * All routes require: authenticate → requireClient() + * + * Endpoints: + * GET /api/client/profile → view own profile + * PUT /api/client/profile → update personal_info + * GET /api/client/sessions → view own active sessions + * DELETE /api/client/sessions/:id → revoke a specific session + * + * Author: rgrgogu + * Date Created: Oct. 6, 2025 + ***********************************************************************************************************************************************************************/ +const mdl_Users = require('../../models/users/users.mdl'); +const mdl_UserSessions = require('../../models/users/user_sessions.mdl'); +const R = require('../../utils/response.util'); + +// ─── GET own profile ─────────────────────────────────────────────────────────── +exports.getProfile = async (req, res) => { + try { + const user = await mdl_Users.findByPk(req.user.user_id, { + attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] }, + }); + return R.success(res, 'Profile retrieved.', user); + } catch (err) { + return R.error(res, 'Could not retrieve profile.', 500); + } +}; + +// ─── PUT update own profile ──────────────────────────────────────────────────── +exports.updateProfile = async (req, res) => { + try { + const user = await mdl_Users.findByPk(req.user.user_id); + const { personal_info } = req.body; + + // Deep-merge personal_info so partial updates don't wipe existing data + const merged = { + ...(user.personal_info || {}), + ...(personal_info || {}), + name: { + ...((user.personal_info?.name) || {}), + ...((personal_info?.name) || {}), + }, + }; + + await user.update({ personal_info: merged }); + + const updated = await mdl_Users.findByPk(req.user.user_id, { + attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] }, + }); + + return R.success(res, 'Profile updated.', updated); + } catch (err) { + console.error('[CLIENT] updateProfile error:', err); + return R.error(res, 'Profile update failed.', 500); + } +}; + +// ─── GET own sessions ────────────────────────────────────────────────────────── +exports.getSessions = async (req, res) => { + try { + const sessions = await mdl_UserSessions.findAll({ + where: { user_id: req.user.user_id, is_active: true }, + order: [['createdAt', 'DESC']], + attributes: { exclude: ['refresh_token_hash'] }, + }); + return R.success(res, 'Sessions retrieved.', sessions); + } catch (err) { + return R.error(res, 'Could not retrieve sessions.', 500); + } +}; + +// ─── DELETE revoke a session ─────────────────────────────────────────────────── +exports.revokeSession = async (req, res) => { + try { + const session = await mdl_UserSessions.findOne({ + where: { session_id: req.params.id, user_id: req.user.user_id }, + }); + if (!session) return R.error(res, 'Session not found.', 404); + + await session.update({ + is_active: false, + logout_info: { date: new Date().toISOString(), ip_address: req.ip }, + }); + + return R.success(res, 'Session revoked.'); + } catch (err) { + return R.error(res, 'Could not revoke session.', 500); + } +}; \ No newline at end of file diff --git a/controllers/staff/users.controller.js b/controllers/staff/users.controller.js new file mode 100644 index 0000000..84a4857 --- /dev/null +++ b/controllers/staff/users.controller.js @@ -0,0 +1,105 @@ +/*********************************************************************************************************************************************************************** + * File Name: users.controller.js (staff) + * Type of Program: Controller + * Description: Staff-level user management. + * Staff can VIEW any client/staff user and perform limited actions. + * Staff CANNOT modify admin accounts or assign/revoke admin roles. + * + * Endpoints (require authenticate → requireStaff()): + * GET /api/staff/users → paginated user list (non-admins) + * GET /api/staff/users/:id → view any non-admin user + * PUT /api/staff/users/:id/status → activate / deactivate user + * GET /api/staff/users/:id/sessions → view user sessions + * + * Author: rgrgogu + * Date Created: Oct. 6, 2025 + ***********************************************************************************************************************************************************************/ +const { Op } = require('sequelize'); +const mdl_Users = require('../../models/users/users.mdl'); +const mdl_UserSessions = require('../../models/users/user_sessions.mdl'); +const R = require('../../utils/response.util'); + +const EXCLUDED = ['password', 'otp_code', 'otp_expires_at']; + +// ─── GET paginated list (no admins) ──────────────────────────────────────────── +exports.getUsers = async (req, res) => { + try { + const page = Math.max(1, parseInt(req.query.page) || 1); + const limit = Math.min(100, parseInt(req.query.limit) || 20); + const offset = (page - 1) * limit; + + const { count, rows } = await mdl_Users.findAndCountAll({ + where: { acc_type: { [Op.ne]: 'admin' } }, + attributes: { exclude: EXCLUDED }, + limit, + offset, + order: [['createdAt', 'DESC']], + }); + + return R.success(res, 'Users retrieved.', { + total: count, + page, + totalPages: Math.ceil(count / limit), + users: rows, + }); + } catch (err) { + console.error('[STAFF] getUsers error:', err); + return R.error(res, 'Could not retrieve users.', 500); + } +}; + +// ─── GET single non-admin user ───────────────────────────────────────────────── +exports.getUser = async (req, res) => { + try { + const user = await mdl_Users.findOne({ + where: { user_id: req.params.id, acc_type: { [Op.ne]: 'admin' } }, + attributes: { exclude: EXCLUDED }, + }); + if (!user) return R.error(res, 'User not found.', 404); + return R.success(res, 'User retrieved.', user); + } catch (err) { + return R.error(res, 'Could not retrieve user.', 500); + } +}; + +// ─── PUT activate / deactivate ───────────────────────────────────────────────── +exports.setUserStatus = async (req, res) => { + try { + const { is_active } = req.body; + if (typeof is_active !== 'boolean') + return R.error(res, 'is_active must be a boolean.', 400); + + const user = await mdl_Users.findOne({ + where: { user_id: req.params.id, acc_type: { [Op.ne]: 'admin' } }, + }); + if (!user) return R.error(res, 'User not found or operation not permitted.', 404); + + await user.update({ is_active }); + return R.success(res, `User ${is_active ? 'activated' : 'deactivated'}.`); + } catch (err) { + return R.error(res, 'Could not update user status.', 500); + } +}; + +// ─── GET user sessions ───────────────────────────────────────────────────────── +exports.getUserSessions = async (req, res) => { + try { + const user = await mdl_Users.findOne({ + where: { user_id: req.params.id, acc_type: { [Op.ne]: 'admin' } }, + }); + if (!user) return R.error(res, 'User not found.', 404); + + const sessions = await mdl_UserSessions.findAll({ + where: { user_id: req.params.id }, + attributes: { exclude: ['refresh_token_hash'] }, + order: [['createdAt', 'DESC']], + }); + return R.success(res, 'Sessions retrieved.', sessions); + } catch (err) { + return R.error(res, 'Could not retrieve sessions.', 500); + } +}; + +// ─── Staff profile (re-use client controller) ────────────────────────────────── +// Staff also manage their own profile through the same client endpoints. +// No additional staff-specific profile endpoints needed. \ No newline at end of file diff --git a/data/email_body.data.js b/data/email_body.data.js new file mode 100644 index 0000000..71c0b7b --- /dev/null +++ b/data/email_body.data.js @@ -0,0 +1,81 @@ +export const emailTemplates = { + OTP: ({ otp, expiryMinutes = 10 }) => ({ + subject: "Email OTP Verification - STARR System", + title: "Email Verification", + body: ` +

Dear User,

+ +

Please use the One-Time Password (OTP) below to verify your email address. + This code is valid for ${expiryMinutes} minutes.

+ +
+ ${otp} +
+ +

+ For security reasons, please do not share this code with anyone. + If you did not request this, please contact the administrator. +

+ `, + }), + + WELCOME: ({ name }) => ({ + subject: "Welcome to STARR System", + title: `Welcome Aboard!`, + body: ` +

Dear ${name},

+ +

We are pleased to welcome you to the STARR System. Your account has been successfully created and is now ready for use.

+ +

You may now access your dashboard and begin using the available services.

+ +

We look forward to supporting you.

+ `, + }), + + PASSWORD_CHANGED: () => ({ + subject: "Password Update Confirmation - STARR System", + title: "Password Successfully Updated", + body: ` +

Dear User,

+ +

This is to confirm that your account password has been successfully changed.

+ +

If you did not perform this action, please reset your password immediately or contact support.

+ +

For your security, we recommend using a strong and unique password.

+ `, + }), + + ADDED_TO_GROUP: ({ groupName }) => ({ + subject: "Group Assignment Notification", + title: "Added to Group", + body: ` +

Dear User,

+ +

You have been assigned to the group ${groupName} in the STARR System.

+ +

This assignment grants you access to shared resources and collaboration tools within the group.

+ +

Please log in to your account to view group details.

+ `, + }), + + TASK_ASSIGNED: ({ taskTitle, dueDate }) => ({ + subject: "New Task Assignment", + title: "Task Assigned", + body: ` +

Dear User,

+ +

You have been assigned a new task in the STARR System.

+ +
+ Task: ${taskTitle} +
+ +

Due Date: ${dueDate}

+ +

Kindly ensure completion within the specified timeframe.

+ `, + }), +}; \ No newline at end of file diff --git a/middleware/auth.middleware.js b/middleware/auth.middleware.js new file mode 100644 index 0000000..38ee00c --- /dev/null +++ b/middleware/auth.middleware.js @@ -0,0 +1,48 @@ +/*********************************************************************************************************************************************************************** + * File Name: auth.middleware.js + * Type of Program: Middleware + * Description: JWT authentication guard. + * Reads the Bearer token from the Authorization header, + * verifies it, attaches decoded payload to req.user, + * and verifies the session is still active in the DB. + * Author: rgrgogu + * Date Created: Oct. 6, 2025 + *********************************************************************************************************************************************************************** + * HOW TO USE: + * const { authenticate } = require('../middleware/auth.middleware'); + * router.get('/profile', authenticate, handler); + ***********************************************************************************************************************************************************************/ +const { verifyAccessToken } = require('../utils/token.util'); +const mdl_Users = require('../models/users/users.mdl'); +const R = require('../utils/response.util'); + +/** + * Validates JWT and loads user from DB. + * Attaches the full user record to req.user. + */ +const authenticate = async (req, res, next) => { + try { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith('Bearer ')) + return R.error(res, 'No token provided.', 401); + + const token = authHeader.split(' ')[1]; + const decoded = verifyAccessToken(token); + + const user = await mdl_Users.findByPk(decoded.user_id, { + attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] }, + }); + + if (!user) return R.error(res, 'User not found.', 401); + if (!user.is_active) return R.error(res, 'Account is deactivated.', 403); + + req.user = user; + next(); + } catch (err) { + if (err.name === 'TokenExpiredError') + return R.error(res, 'Token expired. Please log in again.', 401); + return R.error(res, 'Invalid token.', 401); + } +}; + +module.exports = { authenticate }; \ No newline at end of file diff --git a/middleware/csrf.middleware.js b/middleware/csrf.middleware.js new file mode 100644 index 0000000..a0477da --- /dev/null +++ b/middleware/csrf.middleware.js @@ -0,0 +1,53 @@ +/*********************************************************************************************************************************************************************** + * File Name: csrf.middleware.js + * Type of Program: Middleware + * Description: CSRF protection using the `csurf` package (Double Submit Cookie pattern). + * - csrfProtection → the csurf middleware instance (attach to state-changing routes) + * - getCsrfToken → GET /csrf-token handler — sends the token to the client + * - csrfErrorHandler → catches EBADCSRFTOKEN and returns a 403 + * + * NOTE: Because we use stateless JWT (no server sessions), CSRF is only + * relevant for cookie-based flows (e.g., CSRF token embedded in form headers). + * For REST / SPA clients, the standard practice is to omit CSRF and rely on + * the Authorization Bearer header (which is already CSRF-safe by design). + * This file keeps CSRF available for SSR / hybrid flows. + * + * Author: rgrgogu + * Date Created: Oct. 6, 2025 + *********************************************************************************************************************************************************************** + * HOW TO USE: + * // 1. Mount the token endpoint (public): + * app.get('/api/csrf-token', getCsrfToken); + * + * // 2. Apply to state-mutating cookie-based routes: + * router.post('/login', csrfProtection, loginHandler); + * + * // 3. Register the error handler AFTER all routes: + * app.use(csrfErrorHandler); + ***********************************************************************************************************************************************************************/ +const csurf = require('csurf'); +const R = require('../utils/response.util'); + +/** csurf instance — stores token in a signed cookie */ +const csrfProtection = csurf({ cookie: { httpOnly: true, sameSite: 'strict' } }); + +/** + * GET /api/csrf-token + * Returns the CSRF token the client must echo back on state-changing requests + * via the `X-CSRF-Token` header or `_csrf` body field. + */ +const getCsrfToken = (req, res) => { + res.json({ csrfToken: req.csrfToken() }); +}; + +/** + * Error handler for invalid / missing CSRF tokens. + * Must be registered as Express error-handling middleware (4 args). + */ +const csrfErrorHandler = (err, req, res, next) => { + if (err.code === 'EBADCSRFTOKEN') + return R.error(res, 'Invalid or missing CSRF token.', 403); + next(err); +}; + +module.exports = { csrfProtection, getCsrfToken, csrfErrorHandler }; \ No newline at end of file diff --git a/middleware/rateLimiter.middleware.js b/middleware/rateLimiter.middleware.js new file mode 100644 index 0000000..6b6f35c --- /dev/null +++ b/middleware/rateLimiter.middleware.js @@ -0,0 +1,65 @@ +/*********************************************************************************************************************************************************************** + * File Name: rateLimiter.middleware.js + * Type of Program: Middleware + * Description: Express-rate-limit configurations for different route tiers. + * - globalLimiter → applied to ALL routes (1000 req / 15 min) + * - authLimiter → applied to login/register (20 req / 15 min) + * - otpLimiter → applied to OTP send/verify (5 req / 15 min) + * - sensitiveOpsLimiter → password change, account delete (10 req / hour) + * Author: rgrgogu + * Date Created: Oct. 6, 2025 + *********************************************************************************************************************************************************************** + * HOW TO USE: + * const { authLimiter } = require('../middleware/rateLimiter.middleware'); + * router.post('/login', authLimiter, loginHandler); + ***********************************************************************************************************************************************************************/ +const rateLimit = require('express-rate-limit'); + +const windowMs15 = 15 * 60 * 1000; // 15 minutes + +/** Applied globally in server.js */ +const globalLimiter = rateLimit({ + windowMs: windowMs15, + max: 1000, + standardHeaders: true, + legacyHeaders: false, + message: { status: 'error', message: 'Too many requests, please try again later.' }, +}); + +/** Login & register routes */ +const authLimiter = rateLimit({ + windowMs: windowMs15, + max: 20, + standardHeaders: true, + legacyHeaders: false, + message: { status: 'error', message: 'Too many auth attempts. Please wait 15 minutes.' }, +}); + +/** OTP send / verify */ +const otpLimiter = rateLimit({ + windowMs: windowMs15, + max: 5, + standardHeaders: true, + legacyHeaders: false, + message: { status: 'error', message: 'Too many OTP requests. Please wait 15 minutes.' }, +}); + +/** Password change, account delete */ +const sensitiveOpsLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, // 1 hour + max: 10, + standardHeaders: true, + legacyHeaders: false, + message: { status: 'error', message: 'Too many sensitive operations. Please wait 1 hour.' }, +}); + +/** Admin routes — tighter than global */ +const adminLimiter = rateLimit({ + windowMs: windowMs15, + max: 200, + standardHeaders: true, + legacyHeaders: false, + message: { status: 'error', message: 'Too many admin requests. Please wait 15 minutes.' }, +}); + +module.exports = { globalLimiter, authLimiter, otpLimiter, sensitiveOpsLimiter, adminLimiter }; \ No newline at end of file diff --git a/middleware/rbac.middleware.js b/middleware/rbac.middleware.js new file mode 100644 index 0000000..dca3d24 --- /dev/null +++ b/middleware/rbac.middleware.js @@ -0,0 +1,77 @@ +/*********************************************************************************************************************************************************************** + * File Name: rbac.middleware.js + * Type of Program: Middleware + * Description: Role-Based Access Control (RBAC) guards. + * Hierarchy: admin > staff > user (client) + * + * Exported guards: + * - requireClient() → acc_type in ['user', 'staff', 'admin'] + * - requireStaff() → acc_type in ['staff', 'admin'] + * - requireAdmin() → acc_type === 'admin' only + * - requireOwnerOrStaff() → owns the resource OR is staff/admin + * - requireOwnerOrAdmin() → owns the resource OR is admin + * + * Author: rgrgogu + * Date Created: Oct. 6, 2025 + *********************************************************************************************************************************************************************** + * HOW TO USE: + * const { requireAdmin, requireStaff } = require('../middleware/rbac.middleware'); + * // Must be used AFTER authenticate middleware + * router.get('/users', authenticate, requireStaff(), handler); + * router.delete('/users/:id', authenticate, requireAdmin(), handler); + ***********************************************************************************************************************************************************************/ +const R = require('../utils/response.util'); + +const ROLES = { user: 1, staff: 2, admin: 3 }; + +/** + * Generic role guard factory. + * @param {string[]} allowed - list of acc_type values that can pass + */ +const requireRole = (allowed) => (req, res, next) => { + if (!req.user) + return R.error(res, 'Authentication required.', 401); + + if (!allowed.includes(req.user.acc_type)) + return R.error(res, 'You do not have permission to access this resource.', 403); + + next(); +}; + +/** Any logged-in user (client, staff, admin) */ +const requireClient = () => requireRole(['user', 'staff', 'admin']); + +/** Staff or Admin */ +const requireStaff = () => requireRole(['staff', 'admin']); + +/** Admin only */ +const requireAdmin = () => requireRole(['admin']); + +/** + * Allows resource owner OR staff/admin. + * Reads the owner's user_id from req.params.user_id or req.params.id. + */ +const requireOwnerOrStaff = () => (req, res, next) => { + if (!req.user) return R.error(res, 'Authentication required.', 401); + const targetId = Number(req.params.user_id || req.params.id); + const isOwner = req.user.user_id === targetId; + const elevated = ['staff', 'admin'].includes(req.user.acc_type); + if (!isOwner && !elevated) + return R.error(res, 'You do not have permission.', 403); + next(); +}; + +/** + * Allows resource owner OR admin only. + */ +const requireOwnerOrAdmin = () => (req, res, next) => { + if (!req.user) return R.error(res, 'Authentication required.', 401); + const targetId = Number(req.params.user_id || req.params.id); + const isOwner = req.user.user_id === targetId; + const isAdmin = req.user.acc_type === 'admin'; + if (!isOwner && !isAdmin) + return R.error(res, 'You do not have permission.', 403); + next(); +}; + +module.exports = { requireClient, requireStaff, requireAdmin, requireOwnerOrStaff, requireOwnerOrAdmin }; \ No newline at end of file diff --git a/middleware/validate.middleware.js b/middleware/validate.middleware.js new file mode 100644 index 0000000..c9f0d1c --- /dev/null +++ b/middleware/validate.middleware.js @@ -0,0 +1,25 @@ +/*********************************************************************************************************************************************************************** + * File Name: validate.middleware.js + * Type of Program: Middleware + * Description: express-validator result checker. + * Pairs with validators/*.validator.js — those files define the rules, + * this file intercepts the request if any rule failed and returns 422. + * Author: rgrgogu + * Date Created: Oct. 6, 2025 + *********************************************************************************************************************************************************************** + * HOW TO USE: + * const { registerValidator } = require('../../validators/auth.validator'); + * const { validate } = require('../../middleware/validate.middleware'); + * router.post('/register', ...registerValidator, validate, handler); + ***********************************************************************************************************************************************************************/ +const { validationResult } = require('express-validator'); +const R = require('../utils/response.util'); + +const validate = (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) + return R.validationError(res, errors.array()); + next(); +}; + +module.exports = { validate }; \ No newline at end of file diff --git a/models/users/user_groups.attributes.js b/models/users/user_groups.attributes.js new file mode 100644 index 0000000..5feb805 --- /dev/null +++ b/models/users/user_groups.attributes.js @@ -0,0 +1,22 @@ +const excludeAttributes = [ + // Add here +]; + +const jsonbSchemas = { + // Add here +}; + +// Different exclude sets per role +const adminExclude = [ + ...excludeAttributes, + // admins can see audit fields, so nothing extra excluded +]; + +const userExclude = [ + ...excludeAttributes, + // regular users cannot see audit trails + "created_by", "updated_by", "deleted_by", + "deleted_at", +]; + +module.exports = { excludeAttributes, adminExclude, userExclude, jsonbSchemas }; \ No newline at end of file diff --git a/models/users/user_groups.mdl.js b/models/users/user_groups.mdl.js new file mode 100644 index 0000000..701814b --- /dev/null +++ b/models/users/user_groups.mdl.js @@ -0,0 +1,70 @@ +/*********************************************************************************************************************************************************************** + * File Name: user_groups.mdl.js + * Type of Program: Model + * Description: Sequelize models for `user_groups` and `user_group_members` tables. + * Implements a Many-to-Many self-contained group system. + * Groups are used as named permission bundles. + * Author: rgrgogu + * Date Created: Oct. 6, 2025 + *********************************************************************************************************************************************************************** + * HOW TO USE: + * const { mdl_UserGroups, mdl_UserGroupMembers } = require('./models/user_groups.mdl'); + ***********************************************************************************************************************************************************************/ +const { DataTypes } = require('sequelize'); +const sequelize = require('../../config/db.config'); +const mdl_Users = require('./users.mdl'); + +// ─── UserGroups ──────────────────────────────────────────────────────────────── +const mdl_UserGroups = sequelize.define('UserGroup', { + group_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + name: { type: DataTypes.STRING(50), allowNull: false }, + description: { type: DataTypes.TEXT }, + is_active: { type: DataTypes.BOOLEAN, defaultValue: true }, + + // ── Audit trails ──────────────────────────────────────────────────────────── + createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" }, + updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Modified By" }, + deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" }, +}, { + tableName: 'user_groups', + timestamps: true, + paranoid: true, +}); + +// ─── Junction: UserGroupMembers ──────────────────────────────────────────────── +const mdl_UserGroupMembers = sequelize.define('UserGroupMember', { + group_id: { type: DataTypes.BIGINT, primaryKey: true }, + user_id: { type: DataTypes.BIGINT, primaryKey: true }, + joined_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + + // ── Audit trails ──────────────────────────────────────────────────────────── + createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" }, + updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Modified By" }, + deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" }, +}, { + tableName: 'user_group_members', + timestamps: true, + paranoid: true, +}); + +// ─── Associations ────────────────────────────────────────────────────────────── +mdl_Users.belongsToMany(mdl_UserGroups, { through: mdl_UserGroupMembers, foreignKey: 'user_id', otherKey: 'group_id', as: 'groups' }); +mdl_UserGroups.belongsToMany(mdl_Users, { through: mdl_UserGroupMembers, foreignKey: 'group_id', otherKey: 'user_id', as: 'members', }); + +mdl_UserGroups.hasMany(mdl_UserGroupMembers, { foreignKey: 'group_id' }); +mdl_UserGroupMembers.belongsTo(mdl_UserGroups, { foreignKey: 'group_id' }); + +mdl_Users.hasMany(mdl_UserGroupMembers, { foreignKey: 'user_id' }); +mdl_UserGroupMembers.belongsTo(mdl_Users, { foreignKey: 'user_id' }); + +// models/users/user_groups.mdl.js — add at the bottom +mdl_UserGroups.belongsTo(mdl_Users, { as: 'creator', foreignKey: 'createdBy' }); +mdl_UserGroups.belongsTo(mdl_Users, { as: 'modifier', foreignKey: 'updatedBy' }); +mdl_UserGroups.belongsTo(mdl_Users, { as: 'deleter', foreignKey: 'deletedBy' }); + +// Self-referencing associations for audit fields +mdl_Users.belongsTo(mdl_Users, { as: 'creator', foreignKey: 'createdBy' }); +mdl_Users.belongsTo(mdl_Users, { as: 'modifier', foreignKey: 'updatedBy' }); +mdl_Users.belongsTo(mdl_Users, { as: 'deleter', foreignKey: 'deletedBy' }); + +module.exports = { mdl_UserGroups, mdl_UserGroupMembers }; \ No newline at end of file diff --git a/models/users/user_sessions.mdl.js b/models/users/user_sessions.mdl.js new file mode 100644 index 0000000..a55618e --- /dev/null +++ b/models/users/user_sessions.mdl.js @@ -0,0 +1,60 @@ +/*********************************************************************************************************************************************************************** + * File Name: user_sessions.mdl.js + * Type of Program: Model + * Description: Sequelize model for the `user_sessions` table. + * Captures login and logout audit data (IP, geo, device) as JSONB. + * Has a Many-to-One relationship with Users. + * Author: rgrgogu + * Date Created: Oct. 6, 2025 + *********************************************************************************************************************************************************************** + * Change History: + * DATE AUTHOR LOG DESCRIPTION + * Oct. 6, 2025 rgrgogu 001 Initial creation - STAR Phase 1 + ***********************************************************************************************************************************************************************/ +const { DataTypes } = require('sequelize'); +const sequelize = require('../../config/db.config'); +const mdl_Users = require('./users.mdl'); + +const mdl_UserSessions = sequelize.define('UserSessions', { + session_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + user_id: { + type: DataTypes.BIGINT, + allowNull: false, + references: { model: mdl_Users, key: 'user_id' }, + }, + /** + * login_info / logout_info JSONB: + * { + * date: ISO string, + * ip_address: string, + * country: string, + * region: string, + * city: string, + * lat: number, + * long: number, + * device_info: { ua, browser, os, device } + * forced_by: user_id + * } + */ + login_info: { type: DataTypes.JSONB, allowNull: true }, + logout_info: { type: DataTypes.JSONB, allowNull: true }, + + // Store the refresh-token hash so we can invalidate individual sessions + refresh_token_hash: { type: DataTypes.TEXT, allowNull: true }, + is_active: { type: DataTypes.BOOLEAN, defaultValue: true }, + + // ── Audit trails ──────────────────────────────────────────────────────────── + createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" }, + updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Modified By" }, + deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" }, +}, { + tableName: 'user_sessions', + timestamps: true, + paranoid: true, // enables soft delete — sets deleted_at instead of DELETE +}); + +// Associations +mdl_UserSessions.belongsTo(mdl_Users, { foreignKey: 'user_id' }); +mdl_Users.hasMany(mdl_UserSessions, { foreignKey: 'user_id' }); + +module.exports = mdl_UserSessions; \ No newline at end of file diff --git a/models/users/users.attributes.js b/models/users/users.attributes.js new file mode 100644 index 0000000..81814e9 --- /dev/null +++ b/models/users/users.attributes.js @@ -0,0 +1,47 @@ +const excludeAttributes = [ + "password", "otp_code", "otp_expires_at", + "personal_info.name.given_name", + "personal_info.name.middle_name", + "personal_info.name.last_name", + "personal_info.name.extension_name", + "personal_info.addresses[].city", + "personal_info.addresses[].country", + "personal_info.addresses[].street", + "personal_info.addresses[].zip", + "personal_info.addresses[].address_type", + "personal_info.addresses[].state", + "personal_info.phone_number[].country_code", + "personal_info.phone_number[].number", + "personal_info.phone_number[].phone_type", +]; + +const jsonbSchemas = { + personal_info: { + name: { + full_name: { type: "text", label: "Full Name" }, + }, + date_of_birth: { type: "date", label: "Date of Birth" }, + occupation: { type: "text", label: "Occupation" }, + addresses: { + full_address: { type: "text", label: "Full Address" }, + }, + phone_number: { + full_number: { type: "text", label: "Phone Number" }, + }, + }, +}; + +// Different exclude sets per role +const adminExclude = [ + ...excludeAttributes, + // admins can see audit fields, so nothing extra excluded +]; + +const userExclude = [ + ...excludeAttributes, + // regular users cannot see audit trails + "created_by", "updated_by", "deleted_by", + "deleted_at", +]; + +module.exports = { excludeAttributes, adminExclude, userExclude, jsonbSchemas }; \ No newline at end of file diff --git a/models/users/users.mdl.js b/models/users/users.mdl.js new file mode 100644 index 0000000..9103036 --- /dev/null +++ b/models/users/users.mdl.js @@ -0,0 +1,59 @@ +/*********************************************************************************************************************************************************************** + * File Name: users.mdl.js + * Type of Program: Model + * Description: Sequelize model for the `users` table. + * Stores authentication credentials, account flags, registration type, + * role/account type, and a flexible JSONB personal_info column. + * Author: rgrgogu + * Date Created: Oct. 6, 2025 + *********************************************************************************************************************************************************************** + * Change History: + * DATE AUTHOR LOG DESCRIPTION + * Oct. 6, 2025 rgrgogu 001 Initial creation - STAR Phase 1 + ***********************************************************************************************************************************************************************/ +const { DataTypes } = require('sequelize'); +const sequelize = require('../../config/db.config'); + +const mdl_Users = sequelize.define('User', { + user_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: "User ID" }, + email: { type: DataTypes.STRING(255), allowNull: false, unique: true, label: "Email Address", validate: { isEmail: true } }, + password: { type: DataTypes.TEXT, label: "Password" }, + is_active: { type: DataTypes.BOOLEAN, defaultValue: true, label: "Active" }, + is_verified: { type: DataTypes.BOOLEAN, defaultValue: false, label: "Verified" }, + reg_type: { type: DataTypes.ENUM('system', 'google'), defaultValue: 'system', label: "Registration Type" }, + /** + * acc_type drives RBAC: + * - "user" → Client endpoints only + * - "staff" → Client + Staff endpoints + * - "admin" → All endpoints + */ + acc_type: { type: DataTypes.ENUM('user', 'staff', 'admin'), defaultValue: 'user', label: "Account Type" }, + /** + * personal_info JSONB structure: + * { + * name: { given_name, middle_name, last_name, extension_name, full_name }, + * occupation: string, + * addresses: [{ street, city, state, zip, country, address_type, full_address }], + * phone_number: [{ number, country_code, phone_type, full_number }], + * date_of_birth: date, + * avatar: { mime_type, name, size, url, uuid }, + * album: [{ uuid, file_url, original_name, uploaded_at, order_index }] + * } + */ + personal_info: { type: DataTypes.JSONB, defaultValue: null, label: "Personal Info" }, + + // OTP fields (stored temporarily during verification flow) + otp_code: { type: DataTypes.STRING(6), allowNull: true, label: "OTP Code" }, + otp_expires_at: { type: DataTypes.DATE, allowNull: true, label: "OTP Expires At" }, + + // ── Audit trails ──────────────────────────────────────────────────────────── + createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" }, + updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Modified By" }, + deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" }, +}, { + tableName: 'users', + timestamps: true, + paranoid: true, // enables soft delete — sets deleted_at instead of DELETE +}); + +module.exports = mdl_Users; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..46d4a1f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2399 @@ +{ + "name": "star-auth-system", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "star-auth-system", + "version": "1.0.0", + "dependencies": { + "bcryptjs": "^2.4.3", + "cookie-parser": "^1.4.6", + "cors": "^2.8.5", + "csurf": "^1.11.0", + "dotenv": "^16.0.3", + "express": "^4.18.2", + "express-rate-limit": "^6.10.0", + "express-session": "^1.17.3", + "express-validator": "^7.0.1", + "google-auth-library": "^9.0.0", + "jsonwebtoken": "^9.0.1", + "nodemailer": "^6.9.5", + "passport": "^0.6.0", + "passport-google-oauth20": "^2.0.0", + "pg": "^8.11.3", + "pg-hstore": "^2.3.4", + "rate-limit-redis": "^4.0.0", + "redis": "^4.6.7", + "sequelize": "^6.32.1", + "uuid": "^9.0.0" + }, + "devDependencies": { + "nodemon": "^3.0.1" + } + }, + "node_modules/@redis/bloom": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", + "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/client": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", + "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", + "license": "MIT", + "peer": true, + "dependencies": { + "cluster-key-slot": "1.1.2", + "generic-pool": "3.9.0", + "yallist": "4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@redis/graph": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", + "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/json": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz", + "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/search": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz", + "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/time-series": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz", + "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/@types/validator": { + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", + "license": "MIT" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csrf": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/csrf/-/csrf-3.1.0.tgz", + "integrity": "sha512-uTqEnCvWRk042asU6JtapDTcJeeailFy4ydOQS28bj1hcLnYRiqi8SsD2jS412AY1I/4qdOwWZun774iqywf9w==", + "license": "MIT", + "dependencies": { + "rndm": "1.2.0", + "tsscmp": "1.0.6", + "uid-safe": "2.1.5" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csurf": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/csurf/-/csurf-1.11.0.tgz", + "integrity": "sha512-UCtehyEExKTxgiu8UHdGvHj4tnpE/Qctue03Giq5gPgMQ9cg/ciod5blZQ5a4uCEenNQjxyGuzygLdKUmee/bQ==", + "deprecated": "This package is archived and no longer maintained. For support, visit https://github.com/expressjs/express/discussions", + "license": "MIT", + "dependencies": { + "cookie": "0.4.0", + "cookie-signature": "1.0.6", + "csrf": "3.1.0", + "http-errors": "~1.7.3" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/csurf/node_modules/cookie": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", + "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/csurf/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/csurf/node_modules/http-errors": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz", + "integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==", + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.1.1", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/csurf/node_modules/setprototypeof": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", + "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==", + "license": "ISC" + }, + "node_modules/csurf/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/csurf/node_modules/toidentifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", + "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dottie": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.7.tgz", + "integrity": "sha512-7lAK2A0b3zZr3UC5aE69CPdCFR4RHW1o2Dr74TqFykxkUCBXSRJum/yPc7g8zRHJqWKomPLHwFLLoUnn8PXXRg==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "peer": true, + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "6.11.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-6.11.2.tgz", + "integrity": "sha512-a7uwwfNTh1U60ssiIkuLFWHt4hAC5yxlLGU2VP0X4YNlyEDZAqF4tK3GD3NSitVBrCQmQ0++0uOyFOgC2y4DDw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "express": "^4 || ^5" + } + }, + "node_modules/express-session": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz", + "integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==", + "license": "MIT", + "dependencies": { + "cookie": "~0.7.2", + "cookie-signature": "~1.0.7", + "debug": "~2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.1.0", + "parseurl": "~1.3.3", + "safe-buffer": "~5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-session/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/express-validator": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.2.tgz", + "integrity": "sha512-ctLw1Vl6dXVH62dIQMDdTAQkrh480mkFuG6/SGXOaVlwPNukhRAe7EgJIMJ2TSAni8iwHBRp530zAZE5ZPF2IA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.18.1", + "validator": "~13.15.23" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/generic-pool": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/inflection": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.13.4.tgz", + "integrity": "sha512-6I/HUDeYFfuNCVS3td055BaXBwKYuzw7K3ExVMStBowKo9oOAMJIXIHvdyR3iboTCp1b+1i5DSkIZTcwIktuDw==", + "engines": [ + "node >= 0.4.0" + ], + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/moment-timezone": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz", + "integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==", + "license": "MIT", + "dependencies": { + "moment": "^2.29.4" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/nodemailer": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", + "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/oauth": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", + "integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==", + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/passport": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.6.0.tgz", + "integrity": "sha512-0fe+p3ZnrWRW74fe8+SvCyf4a3Pb2/h7gFkQ8yTJpAO50gDzlfjZUZTO1k5Eg9kUct22OxHLqDZoKUWRHOh9ug==", + "license": "MIT", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-google-oauth20": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", + "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==", + "license": "MIT", + "dependencies": { + "passport-oauth2": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==", + "license": "MIT", + "dependencies": { + "base64url": "3.x.x", + "oauth": "0.10.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x", + "utils-merge": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "peer": true, + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-hstore": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/pg-hstore/-/pg-hstore-2.3.4.tgz", + "integrity": "sha512-N3SGs/Rf+xA1M2/n0JBiXFDVMzdekwLZLAO0g7mpDY9ouX+fDI7jS6kTq3JujmYbtNSJ53TJ0q4G98KVZSM4EA==", + "license": "MIT", + "dependencies": { + "underscore": "^1.13.1" + }, + "engines": { + "node": ">= 0.8.x" + } + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/rate-limit-redis": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/rate-limit-redis/-/rate-limit-redis-4.3.1.tgz", + "integrity": "sha512-+a1zU8+D7L8siDK9jb14refQXz60vq427VuiplgnaLk9B2LnvGe/APLTfhwb4uNIL7eWVknh8GnRp/unCj+lMA==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "peerDependencies": { + "express-rate-limit": ">= 6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/redis": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz", + "integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==", + "license": "MIT", + "workspaces": [ + "./packages/*" + ], + "dependencies": { + "@redis/bloom": "1.2.0", + "@redis/client": "1.6.1", + "@redis/graph": "1.1.1", + "@redis/json": "1.0.7", + "@redis/search": "1.2.0", + "@redis/time-series": "1.1.0" + } + }, + "node_modules/retry-as-promised": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.1.1.tgz", + "integrity": "sha512-hMD7odLOt3LkTjcif8aRZqi/hybjpLNgSk5oF5FCowfCjok6LukpN2bDX7R5wDmbgBQFn7YoBxSagmtXHaJYJw==", + "license": "MIT" + }, + "node_modules/rndm": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/rndm/-/rndm-1.2.0.tgz", + "integrity": "sha512-fJhQQI5tLrQvYIYFpOnFinzv9dwmR7hRnUz1XqP3OJ1jIweTNOd6aTO4jwQSgcBSFUB+/KHJxuGneime+FdzOw==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/sequelize": { + "version": "6.37.8", + "resolved": "https://registry.npmjs.org/sequelize/-/sequelize-6.37.8.tgz", + "integrity": "sha512-HJ0IQFqcTsTiqbEgiuioYFMSD00TP6Cz7zoTti+zVVBwVe9fEhev9cH6WnM3XU31+ABS356durAb99ZuOthnKw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/sequelize" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.8", + "@types/validator": "^13.7.17", + "debug": "^4.3.4", + "dottie": "^2.0.6", + "inflection": "^1.13.4", + "lodash": "^4.17.21", + "moment": "^2.29.4", + "moment-timezone": "^0.5.43", + "pg-connection-string": "^2.6.1", + "retry-as-promised": "^7.0.4", + "semver": "^7.5.4", + "sequelize-pool": "^7.1.0", + "toposort-class": "^1.0.1", + "uuid": "^8.3.2", + "validator": "^13.9.0", + "wkx": "^0.5.0" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependenciesMeta": { + "ibm_db": { + "optional": true + }, + "mariadb": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "oracledb": { + "optional": true + }, + "pg": { + "optional": true + }, + "pg-hstore": { + "optional": true + }, + "snowflake-sdk": { + "optional": true + }, + "sqlite3": { + "optional": true + }, + "tedious": { + "optional": true + } + } + }, + "node_modules/sequelize-pool": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/sequelize-pool/-/sequelize-pool-7.1.0.tgz", + "integrity": "sha512-G9c0qlIWQSK29pR/5U2JF5dDQeqqHRragoyahj/Nx4KOOQ3CPPfzxnfqFPCSB7x5UgjOgnZ61nSxz+fjDpRlJg==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/sequelize/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/sequelize/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/sequelize/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/toposort-class": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toposort-class/-/toposort-class-1.0.1.tgz", + "integrity": "sha512-OsLcGGbYF3rMjPUf8oKktyvCiUxSbqMMS39m33MAjLTC1DVIH6x3WSt63/M77ihI09+Sdfk1AXvfhCEeUmC7mg==", + "license": "MIT" + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "license": "MIT", + "engines": { + "node": ">=0.6.x" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "license": "MIT", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==", + "license": "MIT" + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/validator": { + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wkx": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/wkx/-/wkx-0.5.0.tgz", + "integrity": "sha512-Xng/d4Ichh8uN4l0FToV/258EjMGU9MGcA0HV2d9B/ZpZB3lqQm7nkOdZdm5GhKtLLhAE7PiVQwN4eN+2YJJUg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e889ebb --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "star-auth-system", + "version": "1.0.0", + "description": "STAR Phase 1 - Tier 1 Auth System with CSRF, Rate Limiting, RBAC, Google & System Auth", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js" + }, + "dependencies": { + "bcryptjs": "^2.4.3", + "cookie-parser": "^1.4.6", + "cors": "^2.8.5", + "csurf": "^1.11.0", + "dotenv": "^16.0.3", + "express": "^4.18.2", + "express-rate-limit": "^6.10.0", + "express-session": "^1.17.3", + "express-validator": "^7.0.1", + "google-auth-library": "^9.0.0", + "jsonwebtoken": "^9.0.1", + "nodemailer": "^6.9.5", + "passport": "^0.6.0", + "passport-google-oauth20": "^2.0.0", + "pg": "^8.11.3", + "pg-hstore": "^2.3.4", + "rate-limit-redis": "^4.0.0", + "redis": "^4.6.7", + "sequelize": "^6.32.1", + "uuid": "^9.0.0" + }, + "devDependencies": { + "nodemon": "^3.0.1" + } +} \ No newline at end of file diff --git a/routes/admin/admin.routes.js b/routes/admin/admin.routes.js new file mode 100644 index 0000000..449f26b --- /dev/null +++ b/routes/admin/admin.routes.js @@ -0,0 +1,63 @@ +/*********************************************************************************************************************************************************************** + * File Name: admin.routes.js + * Type of Program: Router + * Description: Protected routes accessible by admin only — full control. + * + * Route Map: + * Users: + * GET /api/admin/users → all users + * GET /api/admin/users/:id → single user with groups + * PUT /api/admin/users/:id → update user (acc_type, status, profile) + * DELETE /api/admin/users/:id → hard delete user + * GET /api/admin/users/:id/sessions → view all sessions + * DELETE /api/admin/users/:id/sessions/:sid → force-terminate a session + * + * Groups: + * GET /api/admin/groups → list groups + * POST /api/admin/groups → create group + * DELETE /api/admin/groups/:gid → delete group + * POST /api/admin/users/:id/groups/:gid → add user to group + * DELETE /api/admin/users/:id/groups/:gid → remove user from group + * + * Guards: authenticate → requireAdmin() + * + * Author: rgrgogu + * Date Created: Oct. 6, 2025 + ***********************************************************************************************************************************************************************/ +const express = require('express'); +const router = express.Router(); + +const usersCtrl = require('../../controllers/admin/users.controller'); +const { authenticate } = require('../../middleware/auth.middleware'); +const { requireAdmin } = require('../../middleware/rbac.middleware'); +const { adminLimiter, sensitiveOpsLimiter } = require('../../middleware/rateLimiter.middleware'); + +// Apply adminLimiter to ALL admin routes +router.use(authenticate, requireAdmin(), adminLimiter); + +// ── User management ──────────────────────────────────────────────────────────── +router.get('/users', usersCtrl.getUsers); +router.get('/users/:id', usersCtrl.getUser); +router.put('/users/:id', sensitiveOpsLimiter, usersCtrl.updateUser); +router.delete('/users/:id', sensitiveOpsLimiter, usersCtrl.deactivateUser); // soft delete +router.post('/users/:id/restore', sensitiveOpsLimiter, usersCtrl.restoreUser); // restore + +// ── Session management ───────────────────────────────────────────────────────── +router.get('/users/:id/sessions', usersCtrl.getUserSessions); +router.delete('/users/:id/sessions/:sid', sensitiveOpsLimiter, usersCtrl.terminateSession); + +// ─── Groups Management ─────────────────────────────────────────────────────────────── +router.get('/groups', usersCtrl.getGroups); +router.get('/groups/:gid', usersCtrl.getGroup); +router.post('/groups', sensitiveOpsLimiter, usersCtrl.createGroup); +router.put('/groups/:gid', sensitiveOpsLimiter, usersCtrl.updateGroup); +router.patch('/groups/:gid/deactivate', sensitiveOpsLimiter, usersCtrl.deactivateGroup); +router.patch('/groups/:gid/restore', sensitiveOpsLimiter, usersCtrl.restoreGroup); + +// ─── Group membership ────────────────────────────────────────────────────────── +router.get('/groups/:gid/users', usersCtrl.getUsersInGroup); +router.get('/groups/:gid/users/add', usersCtrl.getUsersNotInGroup); +router.post('/groups/:gid/users', sensitiveOpsLimiter, usersCtrl.addUserToGroup); +router.delete('/groups/:gid/users', sensitiveOpsLimiter, usersCtrl.removeUserFromGroup); + +module.exports = router; \ No newline at end of file diff --git a/routes/auth.routes.js b/routes/auth.routes.js new file mode 100644 index 0000000..3ce7094 --- /dev/null +++ b/routes/auth.routes.js @@ -0,0 +1,61 @@ +/*********************************************************************************************************************************************************************** + * File Name: auth.routes.js + * Type of Program: Router + * Description: Public authentication routes (no auth required). + * CSRF protection is applied to state-mutating endpoints. + * + * Route Map: + * GET /api/auth/csrf-token → get CSRF token (for cookie-based clients) + * POST /api/auth/register → system registration + * POST /api/auth/verify-otp → OTP verification + auto-login + * POST /api/auth/resend-otp → resend OTP email + * POST /api/auth/login → system login + * POST /api/auth/refresh → refresh access token + * POST /api/auth/logout → logout (requires authenticate) + * POST /api/auth/change-password → change password (requires authenticate) + * GET /api/auth/google → initiate Google OAuth + * GET /api/auth/google/callback → Google OAuth callback + * + * Author: rgrgogu + * Date Created: Oct. 6, 2025 + ***********************************************************************************************************************************************************************/ +const express = require('express'); +const passport = require('passport'); +const router = express.Router(); + +const authCtrl = require('../controllers/auth.controller'); +const { authenticate } = require('../middleware/auth.middleware'); +const { authLimiter, otpLimiter, sensitiveOpsLimiter } = require('../middleware/rateLimiter.middleware'); +const { csrfProtection, getCsrfToken } = require('../middleware/csrf.middleware'); +const { validate } = require('../middleware/validate.middleware'); +const { + registerValidator, loginValidator, + verifyOTPValidator, resendOTPValidator, changePassValidator, +} = require('../validators/auth.validator'); + +// ── CSRF token (GET — no CSRF needed on GETs) ────────────────────────────────── +router.get('/csrf-token', csrfProtection, getCsrfToken); + +// ── System auth ──────────────────────────────────────────────────────────────── +router.post('/register', ...registerValidator, validate, authCtrl.register); +router.post('/verify-otp', otpLimiter, ...verifyOTPValidator, validate, authCtrl.verifyOTP); +router.post('/resend-otp', otpLimiter, ...resendOTPValidator, validate, authCtrl.resendOTP); +router.post('/login', ...loginValidator, validate, authCtrl.login); +router.post('/refresh', authCtrl.refreshToken); +router.post('/logout', authenticate, authLimiter, authCtrl.logout); +router.post('/change-password', authenticate, sensitiveOpsLimiter, ...changePassValidator, validate, authCtrl.changePassword); + +// ── Google OAuth ─────────────────────────────────────────────────────────────── +router.get('/google', + authLimiter, + passport.authenticate('google', { scope: ['profile', 'email'], session: false }) +); +router.get('/google/callback', + passport.authenticate('google', { session: false, failureRedirect: '/api/auth/google/failed' }), + authCtrl.googleCallback +); +router.get('/google/failed', (req, res) => { + res.status(401).json({ status: 'error', message: 'Google authentication failed.' }); +}); + +module.exports = router; \ No newline at end of file diff --git a/routes/client/client.routes.js b/routes/client/client.routes.js new file mode 100644 index 0000000..2182c22 --- /dev/null +++ b/routes/client/client.routes.js @@ -0,0 +1,34 @@ +/*********************************************************************************************************************************************************************** + * File Name: client.routes.js + * Type of Program: Router + * Description: Protected routes accessible by any authenticated user (client, staff, admin). + * + * Route Map: + * GET /api/client/profile → view own profile + * PUT /api/client/profile → update own profile + * GET /api/client/sessions → view own active sessions + * DELETE /api/client/sessions/:id → revoke own session + * + * Guards: authenticate → requireClient() + * + * Author: rgrgogu + * Date Created: Oct. 6, 2025 + ***********************************************************************************************************************************************************************/ +const express = require('express'); +const router = express.Router(); + +const profileCtrl = require('../../controllers/client/profile.controller'); +const { authenticate } = require('../../middleware/auth.middleware'); +const { requireClient } = require('../../middleware/rbac.middleware'); +const { validate } = require('../../middleware/validate.middleware'); +const { updateProfileValidator } = require('../../validators/profile.validator'); + +// Apply auth + role to all routes in this file +router.use(authenticate, requireClient()); + +router.get('/profile', profileCtrl.getProfile); +router.put('/profile', ...updateProfileValidator, validate, profileCtrl.updateProfile); +router.get('/sessions', profileCtrl.getSessions); +router.delete('/sessions/:id', profileCtrl.revokeSession); + +module.exports = router; \ No newline at end of file diff --git a/routes/staff/staff.routes.js b/routes/staff/staff.routes.js new file mode 100644 index 0000000..2a5b596 --- /dev/null +++ b/routes/staff/staff.routes.js @@ -0,0 +1,33 @@ +/*********************************************************************************************************************************************************************** + * File Name: staff.routes.js + * Type of Program: Router + * Description: Protected routes accessible by staff and admin only. + * Staff can view non-admin users and toggle their status. + * Staff cannot view or modify admins. + * + * Route Map: + * GET /api/staff/users → paginated user list (non-admins) + * GET /api/staff/users/:id → view a specific non-admin user + * PUT /api/staff/users/:id/status → activate or deactivate a user + * GET /api/staff/users/:id/sessions → view user's sessions + * + * Guards: authenticate → requireStaff() + * + * Author: rgrgogu + * Date Created: Oct. 6, 2025 + ***********************************************************************************************************************************************************************/ +const express = require('express'); +const router = express.Router(); + +const usersCtrl = require('../../controllers/staff/users.controller'); +const { authenticate } = require('../../middleware/auth.middleware'); +const { requireStaff } = require('../../middleware/rbac.middleware'); + +router.use(authenticate, requireStaff()); + +router.get('/users/', usersCtrl.getUsers); +router.get('/users/:id', usersCtrl.getUser); +router.put('/users/:id/status', usersCtrl.setUserStatus); +router.get('/users/:id/sessions', usersCtrl.getUserSessions); + +module.exports = router; \ No newline at end of file diff --git a/server.js b/server.js new file mode 100644 index 0000000..617a02e --- /dev/null +++ b/server.js @@ -0,0 +1,115 @@ +/*********************************************************************************************************************************************************************** + * File Name: server.js + * Type of Program: Application Entry Point + * Description: Bootstraps the Express application. + * Registers global middleware (CORS, rate-limit, cookie-parser, + * session, Passport) and mounts all route groups. + * Syncs Sequelize models to the database on startup. + * + * Author: rgrgogu + * Date Created: Oct. 6, 2025 + *********************************************************************************************************************************************************************** + * HOW TO RUN: + * 1. cp .env.example .env (fill in your values) + * 2. npm install + * 3. npm run dev (development) + * npm start (production) + ***********************************************************************************************************************************************************************/ +require('dotenv').config(); + +const express = require('express'); +const cors = require('cors'); +const cookieParser = require('cookie-parser'); +const session = require('express-session'); +const passport = require('./config/passport.config'); +const sequelize = require('./config/db.config'); + +// ── Middleware ────────────────────────────────────────────────────────────────── +const { globalLimiter } = require('./middleware/rateLimiter.middleware'); +const { csrfErrorHandler } = require('./middleware/csrf.middleware'); + +// ── Routes ───────────────────────────────────────────────────────────────────── +const authRoutes = require('./routes/auth.routes'); +const clientRoutes = require('./routes/client/client.routes'); +const staffRoutes = require('./routes/staff/staff.routes'); +const adminRoutes = require('./routes/admin/admin.routes'); + +// ── Models (ensure associations are loaded) ──────────────────────────────────── +require('./models/users/users.mdl'); +require('./models/users/user_sessions.mdl'); +require('./models/users/user_groups.mdl'); + +const app = express(); +const PORT = process.env.PORT || 3000; + +// ────────────────────────────────────────────────────────────────────────────── +// Global Middleware Stack +// ────────────────────────────────────────────────────────────────────────────── +app.set('trust proxy', 1); // Required for rate-limiter behind proxies/load balancers + +app.use(cors({ + origin: process.env.APP_URL || '*', + credentials: true, +})); + +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); +app.use(cookieParser()); + +// Session — used only by csurf; JWT handles auth state +app.use(session({ + secret: process.env.SESSION_SECRET || 'change-me', + resave: false, + saveUninitialized: false, + cookie: { secure: process.env.NODE_ENV === 'production', httpOnly: true, sameSite: 'strict' }, +})); + +app.use(passport.initialize()); + +// Global rate limiter (1000 req / 15 min) +app.use(globalLimiter); + +// ────────────────────────────────────────────────────────────────────────────── +// Routes +// ────────────────────────────────────────────────────────────────────────────── +app.use('/api/auth', authRoutes); +app.use('/api/client', clientRoutes); +app.use('/api/staff', staffRoutes); +app.use('/api/admin', adminRoutes); + +// Health check +app.get('/api/health', (req, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString(), env: process.env.NODE_ENV }); +}); + +// 404 handler +app.use((req, res) => { + res.status(404).json({ status: 'error', message: 'Route not found.' }); +}); + +// CSRF error handler (must be after routes) +app.use(csrfErrorHandler); + +// Global error handler +app.use((err, req, res, next) => { + console.error('[UNHANDLED ERROR]', err); + res.status(500).json({ status: 'error', message: 'Internal server error.' }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// Database Sync + Listen +// ────────────────────────────────────────────────────────────────────────────── +(async () => { + try { + await sequelize.authenticate(); + console.log('✅ Database connected.'); + + app.listen(PORT, () => { + console.log(`🚀 STARR Server running on http://localhost:${PORT}`); + console.log(` Environment : ${process.env.NODE_ENV}`); + }); + } catch (err) { + console.error('❌ Failed to start server:', err); + process.exit(1); + } +})(); \ No newline at end of file diff --git a/services/email.service.js b/services/email.service.js new file mode 100644 index 0000000..2b7d141 --- /dev/null +++ b/services/email.service.js @@ -0,0 +1,99 @@ +/*********************************************************************************************************************************************************************** + * File Name: email.service.js + * Type of Program: Service + * Description: Nodemailer-based email service. + * Provides: + * - sendOTPEmail() → sends a 6-digit OTP verification email + * - sendWelcomeEmail() → sent after successful email verification + * Author: rgrgogu + * Date Created: Oct. 6, 2025 + *********************************************************************************************************************************************************************** + * HOW TO USE: + * const emailService = require('../services/email.service'); + * await emailService.sendOTPEmail(user.email, otp); + ***********************************************************************************************************************************************************************/ +const nodemailer = require('nodemailer'); +const { emailTemplates } = require('../data/email_body.data') + +const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: Number(process.env.SMTP_PORT), + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + }, +}); + +const buildEmailTemplate = ({ title, body }) => { + return ` +
+ + +
+ Philproperties +
+ + +
+

${title}

+ ${body} + + +
+

Regards,

+

+ Philproperties IT Team +

+
+
+ + +
+ This is an automated message from STARR System. Please do not reply. +
+ +
+ `; +}; + +/** + * Sends a 6-digit OTP to the given email address. + * @param {string} to - recipient email + * @param {string} otp - 6-digit code + * @param {number} expiryMinutes + */ +const sendEmail = async ({ to, type, data = {} }) => { + try { + const templateFn = emailTemplates[type]; + + if (!templateFn) { + throw new Error(`Email template "${type}" not found`); + } + + const { subject, title, body } = templateFn(data); + + const html = buildEmailTemplate({ title, body }); + + return await new Promise((resolve, reject) => { + transporter.sendMail( + { + from: { + name: "STARR System", + address: "do-not-reply@philproperties.com", + }, + to, + subject, + html, + }, + (err, info) => { + if (err) return reject(err); + resolve(info); + } + ); + }); + } catch (err) { + throw new Error(err.message); + } +}; + +module.exports = sendEmail; \ No newline at end of file diff --git a/utils/buildQuery.util.js b/utils/buildQuery.util.js new file mode 100644 index 0000000..3601245 --- /dev/null +++ b/utils/buildQuery.util.js @@ -0,0 +1,81 @@ +// utils/queryBuilder.js +const { Sequelize, Op } = require("sequelize"); + +/** + * Builds a Sequelize `where` clause from an array of filters. + * + * @param {Array<{ id: string, value: any }>} filters + * @returns {Object} Sequelize where clause + */ +function buildWhere(filters = [], allowedFields = new Set()) { + const where = []; + + for (const { id, value } of filters) { + if (!id || value === undefined || value === null || value === "") continue; + + // Reject fields not in whitelist (if whitelist is provided) + if (allowedFields.size && !allowedFields.has(id)) continue; + + const values = Array.isArray(value) ? value : [value]; + + const conditions = values.map((v) => + id.startsWith("personal_info.") + ? Sequelize.where( + Sequelize.json(`personal_info.${id.replace("personal_info.", "")}`), + { [Op.iLike]: `%${v}%` } + ) + : Sequelize.where( + Sequelize.cast(Sequelize.col(id), "TEXT"), + { [Op.iLike]: `%${v}%` } + ) + ); + + where.push({ [Op.or]: conditions }); + } + + return where.length ? { [Op.and]: where } : {}; +} + +/** + * Builds a Sequelize `order` clause from an array of sort descriptors. + * Falls back to [["createdAt", "DESC"]] if no valid sort entries. + * + * @param {Array<{ id: string, desc: boolean }>} sort + * @returns {Array} Sequelize order clause + */ +function buildOrder(sort = [], allowedFields = new Set()) { + const order = []; + + for (const { id, desc } of sort) { + if (!id) continue; + + // Reject fields not in whitelist (if whitelist is provided) + if (allowedFields.size && !allowedFields.has(id)) continue; + + order.push( + id.startsWith("personal_info.") + ? [Sequelize.json(id), desc ? "DESC" : "ASC"] + : [id, desc ? "DESC" : "ASC"] + ); + } + + return order.length ? order : [["createdAt", "DESC"]]; +} + +/** + * Convenience wrapper — returns both where and order in one call. + * + * @param {Array} filters + * @param {Array} sort + * @returns {{ where: Object, order: Array }} + */ +function buildQuery(filters = [], sort = [], allowedFields = []) { + const fieldSet = new Set(allowedFields); + + return { + where: buildWhere(filters, fieldSet), + order: buildOrder(sort, fieldSet), + }; +} + +module.exports = { buildWhere, buildOrder, buildQuery }; \ No newline at end of file diff --git a/utils/excludeJSONBPaths.js b/utils/excludeJSONBPaths.js new file mode 100644 index 0000000..ed8ef9b --- /dev/null +++ b/utils/excludeJSONBPaths.js @@ -0,0 +1,73 @@ +const { Sequelize } = require('sequelize') + +/** + * Strips a key from every element in a JSONB array using PostgreSQL's + * jsonb_agg + #- operator in a subquery. + * + * @param {string} column - JSONB column e.g. "personal_info" + * @param {string} arrayField - array field name e.g. "addresses" + * @param {string[]} keys - keys to strip from each array element e.g. ["street", "zip"] + * @returns {string} SQL fragment + */ +function buildArrayStrip(column, arrayField, keys) { + const keyRemovals = keys.reduce( + (acc, key) => `(${acc} #- '{${key}}')`, + "elem" + ); + + return `( + SELECT jsonb_agg(${keyRemovals}) + FROM jsonb_array_elements("${column}"->'${arrayField}') AS elem + )`; +} + +/** + * Builds a Sequelize literal that strips JSONB paths at the DB level. + * Supports: + * - nested keys: "personal_info.name.given_name" + * - array item keys: "personal_info.addresses[].street" + * + * @param {string} column - JSONB column name e.g. "personal_info" + * @param {string[]} excludePaths - dot-notation paths + * @returns {Array|null} Sequelize literal attribute tuple + */ +function excludeJsonbPaths(column, excludePaths = []) { + // Separate nested paths from array paths + const nestedPaths = excludePaths.filter( + (p) => p.startsWith(`${column}.`) && !p.includes("[]") + ); + const arrayPaths = excludePaths.filter( + (p) => p.startsWith(`${column}.`) && p.includes("[]") + ); + + // Group array paths by their field name + // e.g. { addresses: ["street", "zip"], phone_number: ["country_code"] } + const arrayGroups = {}; + for (const path of arrayPaths) { + const stripped = path.replace(`${column}.`, ""); // addresses[].street + const [arrayField, key] = stripped.split("[]."); // ["addresses", "street"] + if (!arrayGroups[arrayField]) arrayGroups[arrayField] = []; + arrayGroups[arrayField].push(key); + } + + if (!nestedPaths.length && !Object.keys(arrayGroups).length) return null; + + // Start with the column and chain #- for nested paths + let literal = `"${column}"`; + + // Strip nested keys first + for (const path of nestedPaths) { + const keys = path.replace(`${column}.`, "").split("."); + literal = `(${literal} #- '{${keys.join(",")}}')`; + } + + // Then rebuild array fields with stripped keys using jsonb_set + for (const [arrayField, keys] of Object.entries(arrayGroups)) { + const arrayStrip = buildArrayStrip(column, arrayField, keys); + literal = `jsonb_set(${literal}, '{${arrayField}}', COALESCE(${arrayStrip}, '[]'))`; + } + + return [Sequelize.literal(literal), column]; +} + +module.exports = { excludeJsonbPaths }; \ No newline at end of file diff --git a/utils/modelToAttributes.js b/utils/modelToAttributes.js new file mode 100644 index 0000000..4c118a1 --- /dev/null +++ b/utils/modelToAttributes.js @@ -0,0 +1,162 @@ +// utils/modelToAttributes.js +const { DataTypes } = require("sequelize"); + +/** + * Maps Sequelize DataType to a simple UI type string. + */ +function resolveType(dataType) { + if (!dataType) return "text"; + + const type = dataType.constructor?.key || dataType.key || ""; + + if (["BIGINT", "INTEGER", "FLOAT", "DOUBLE", "DECIMAL"].includes(type)) return "number"; + if (["DATE", "DATEONLY"].includes(type)) return "date"; + if (["BOOLEAN"].includes(type)) return "enum"; + if (["ENUM"].includes(type)) return "enum"; + if (["JSONB", "JSON"].includes(type)) return "jsonb"; + + return "text"; +} + +/** + * Resolves options (e.g. enum choices) from a Sequelize field definition. + */ +function resolveOptions(dataType) { + if (!dataType) return {}; + + const type = dataType.constructor?.key || dataType.key || ""; + + if (type === "ENUM") { + return { choices: dataType.values ?? [] }; + } + + if (type === "BOOLEAN") { + return { choices: ["true", "false"] }; + } + + return {}; +} + +/** + * Converts a camelCase or snake_case field name to a readable label. + */ +function toLabel(field) { + return field + .replace(/_/g, " ") + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/\b\w/g, (c) => c.toUpperCase()); +} + +/** + * Flattens JSONB field paths from a schema definition. + * + * @param {Object} jsonbSchema - e.g. { name: { given_name, full_name }, date_of_birth } + * @param {string} prefix - e.g. "personal_info" + * @returns {Array} flat attribute entries for each leaf path + */ +function flattenJsonb(jsonbSchema = {}, prefix = "", exclude = []) { + const result = []; + + for (const [key, value] of Object.entries(jsonbSchema)) { + const path = prefix ? `${prefix}.${key}` : key; + + if (exclude.includes(path)) continue; + + // Nested object (no `type` key = it's a group, not a leaf) + if (value && typeof value === "object" && !value.type) { + result.push(...flattenJsonb(value, path, exclude)); + } else { + result.push({ + name: value?.label || toLabel(key), // <-- prefer label + type: value?.type ?? "text", + field: path, + options: {}, + }); + } + } + + return result; +} + +/** + * Generates an attributes array from a Sequelize model + optional JSONB schema map. + * + * @param {Object} model - Sequelize model (e.g. mdl_Users) + * @param {Object} jsonbSchemas - map of JSONB field names to their schema definition + * e.g. { personal_info: { name: { full_name: "text", given_name: "text" }, date_of_birth: "date" } } + * @param {string[]} exclude - field names to exclude (e.g. ["password", "otp_code"]) + * @returns {Array} attributes array + */ +function modelToAttributes(model, { jsonbSchemas = {}, exclude = [], timestampLabels = {} } = {}) { + const rawAttrs = model.rawAttributes || model.tableAttributes; + const attributes = []; + + const defaultTimestampLabels = { + createdAt: "Created", + modifiedAt: "Modified", + updatedAt: "Modified", + deletedAt: "Deleted", + createdBy: "Created By", + updatedBy: "Modified By", + deletedBy: "Deleted By", + ...timestampLabels, + }; + + // Audit fields excluded from normal loop — handled separately at the end + const auditFields = [ + 'createdAt', 'updatedAt', 'modifiedAt', + 'deletedAt', + 'createdBy', 'updatedBy', 'deletedBy', + ]; + + // Ordered audit sequence + const auditSequence = [ + { field: 'updatedAt', type: 'date' }, + { field: 'modifiedAt', type: 'date' }, + { field: 'updatedBy', type: 'text' }, + { field: 'createdAt', type: 'date' }, + { field: 'createdBy', type: 'text' }, + { field: 'deletedAt', type: 'date' }, + { field: 'deletedBy', type: 'text' }, + ]; + + // ── Normal fields (excluding audit) ───────────────────────────────────────── + for (const [field, def] of Object.entries(rawAttrs)) { + if (exclude.includes(field)) continue; + if (auditFields.includes(field)) continue; // skip audit — added later in order + + const dataType = def.type; + const type = resolveType(def); + + if (type === "jsonb" && jsonbSchemas[field]) { + attributes.push(...flattenJsonb(jsonbSchemas[field], field, exclude)); + continue; + } + + if (type === "jsonb") continue; + + attributes.push({ + name: defaultTimestampLabels[field] ?? def.label ?? toLabel(field), + type, + field, + options: resolveOptions(def), + }); + } + + // ── Audit fields in correct sequence ──────────────────────────────────────── + for (const { field, type } of auditSequence) { + if (exclude.includes(field)) continue; + if (!rawAttrs[field]) continue; // skip if field doesn't exist on model + + attributes.push({ + name: defaultTimestampLabels[field], + type, + field, + options: {}, + }); + } + + return attributes; +} + +module.exports = { modelToAttributes }; \ No newline at end of file diff --git a/utils/otp.util.js b/utils/otp.util.js new file mode 100644 index 0000000..03d3462 --- /dev/null +++ b/utils/otp.util.js @@ -0,0 +1,44 @@ +/*********************************************************************************************************************************************************************** + * File Name: otp.util.js + * Type of Program: Utility + * Description: One-Time Password (OTP) generation and validation helpers. + * - generateOTP() → 6-digit numeric string + * - getOTPExpiry() → Date object N minutes from now + * - isOTPExpired() → boolean check on the stored expiry + * Author: rgrgogu + * Date Created: Oct. 6, 2025 + *********************************************************************************************************************************************************************** + * HOW TO USE: + * const { generateOTP, getOTPExpiry, isOTPExpired } = require('../utils/otp.util'); + * user.otp_code = generateOTP(); + * user.otp_expires_at = getOTPExpiry(); + ***********************************************************************************************************************************************************************/ +const crypto = require('crypto'); + +/** + * Generates a cryptographically secure 6-digit OTP. + * @returns {string} e.g. "048291" + */ +const generateOTP = () => { + const bytes = crypto.randomBytes(3); // 3 bytes = 0–16777215 + const num = bytes.readUIntBE(0, 3) % 1_000_000; // force to 0–999999 + return num.toString().padStart(6, '0'); +}; + +/** + * Returns a Date object N minutes in the future. + * @param {number} [minutes=10] + * @returns {Date} + */ +const getOTPExpiry = (minutes = Number(process.env.OTP_EXPIRY_MINUTES) || 10) => { + return new Date(Date.now() + minutes * 60 * 1000); +}; + +/** + * Checks whether the stored OTP has expired. + * @param {Date|string} expiresAt + * @returns {boolean} + */ +const isOTPExpired = (expiresAt) => !expiresAt || new Date() > new Date(expiresAt); + +module.exports = { generateOTP, getOTPExpiry, isOTPExpired }; \ No newline at end of file diff --git a/utils/paginate.util.js b/utils/paginate.util.js new file mode 100644 index 0000000..44da562 --- /dev/null +++ b/utils/paginate.util.js @@ -0,0 +1,120 @@ +// utils/paginate.util.js +const { Sequelize } = require('sequelize'); +const { modelToAttributes } = require('./modelToAttributes'); +const { excludeJsonbPaths } = require('./excludeJsonbPaths'); +const { buildQuery } = require('./buildQuery.util'); + +const PAGE_START = 1; +const PAGE_SIZE = 10; +const MAX_LIMIT = 100; + +function safeParseJSON(value, fallback = []) { + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : fallback; + } catch { + return fallback; + } +} + +/** + * Generates audit subquery attributes for createdBy, updatedBy, deletedBy + * + * @param {Object} mdl_Users - Users model + * @param {string} parentAlias - Sequelize model alias e.g. 'User', 'UserGroup' + * @returns {Array} - Sequelize attribute include array + */ +function auditInclude(mdl_Users, parentAlias = 'User') { + const tableName = mdl_Users.getTableName(); + + const fullNameSubquery = (foreignKey) => + Sequelize.literal(`( + SELECT (u."personal_info"->>'name')::jsonb->>'full_name' + FROM "${tableName}" AS u + WHERE u."user_id" = "${parentAlias}"."${foreignKey}" + LIMIT 1 + )`); + + return { + attributes: [ + [fullNameSubquery('createdBy'), 'createdByName'], + [fullNameSubquery('updatedBy'), 'updatedByName'], + [fullNameSubquery('deletedBy'), 'deletedByName'], + ], + }; +} + +/** + * Reusable paginated findAndCountAll + * + * @param {Object} model - Sequelize model + * @param {Object} req - Express request + * @param {Object} options + * @param {string[]} options.excludeAttributes - fields to exclude + * @param {Object} options.jsonbSchemas - JSONB schema map + * @param {string} options.jsonbColumn - JSONB column name e.g. "personal_info" + * @param {Object} options.findOptions - extra Sequelize options (include, where, etc.) + * @param {Object} options.auditOptions - { mdl_Users, parentAlias } to auto-include audit subqueries + */ +async function paginate(model, req, { + excludeAttributes = [], + jsonbSchemas = {}, + jsonbColumn = null, + findOptions = {}, + auditOptions = null, // ← { mdl_Users, parentAlias } +} = {}) { + const page = Math.max(PAGE_START, parseInt(req.query.page, 10) || PAGE_START); + const limit = Math.min(parseInt(req.query.limit, 10) || PAGE_SIZE, MAX_LIMIT); + const offset = (page - PAGE_START) * limit; + + const filters = safeParseJSON(req.query.filters); + const sort = safeParseJSON(req.query.sort); + + const topLevelExclude = excludeAttributes.filter((f) => !f.includes('.')); + const jsonbExclude = excludeAttributes.filter((f) => f.includes('.')); + const jsonbAttr = jsonbColumn ? excludeJsonbPaths(jsonbColumn, jsonbExclude) : null; + + const attributes = modelToAttributes(model, { exclude: excludeAttributes, jsonbSchemas }); + const ALLOWED_FIELDS = attributes.map((a) => a.field); + + const { where, order } = buildQuery(filters, sort, ALLOWED_FIELDS); + + // Build attribute includes: jsonb + audit subqueries + any extra from findOptions + const baseIncludes = jsonbAttr ? [jsonbAttr] : []; + const auditAttrs = auditOptions + ? auditInclude(auditOptions.mdl_Users, auditOptions.parentAlias).attributes + : []; + const extraIncludes = findOptions.attributes?.include ?? []; + const mergedAttributeIncludes = [...baseIncludes, ...auditAttrs, ...extraIncludes]; + + const { attributes: _attr, ...restFindOptions } = findOptions; + + const { count, rows } = await model.findAndCountAll({ + ...restFindOptions, + where: { ...where, ...(restFindOptions.where ?? {}) }, + order, + limit, + offset, + attributes: { + exclude: topLevelExclude, + include: mergedAttributeIncludes, + }, + }); + + const totalPages = Math.ceil(count / limit); + + return { + data: rows, + pagination: { + page, + limit, + totalRecords: count, + totalPages, + hasPrevPage: page > PAGE_START, + hasNextPage: page < totalPages, + }, + attributes, + }; +} + +module.exports = { paginate, auditInclude, safeParseJSON }; \ No newline at end of file diff --git a/utils/personalInfo.util.js b/utils/personalInfo.util.js new file mode 100644 index 0000000..af3b15a --- /dev/null +++ b/utils/personalInfo.util.js @@ -0,0 +1,59 @@ +// utils/personalInfo.util.js + +/** + * Computes full_name, full_address, full_number + * from a personal_info object before saving to DB. + * + * @param {Object} personalInfo + * @returns {Object} enriched personal_info + */ +function enrichPersonalInfo(personalInfo = {}) { + if (!personalInfo || typeof personalInfo !== 'object') return personalInfo; + + const pi = { ...personalInfo }; + + // ── Full Name ──────────────────────────────────────────────────────────────── + if (pi.name) { + const { last_name, given_name, middle_name, extension_name } = pi.name; + + pi.name = { + ...pi.name, + full_name: [ + last_name ? `${last_name},` : null, + given_name, + middle_name, + extension_name, + ] + .filter((v) => v && v.trim() !== '') + .join(' '), + }; + } + + // ── Full Addresses ─────────────────────────────────────────────────────────── + if (Array.isArray(pi.addresses)) { + pi.addresses = pi.addresses.map((addr) => ({ + ...addr, + full_address: [ + addr.street, + addr.city, + addr.state, + addr.country, + addr.zip, + ] + .filter((v) => v && v.trim() !== '') + .join(', '), + })); + } + + // ── Full Phone Numbers ─────────────────────────────────────────────────────── + if (Array.isArray(pi.phone_number)) { + pi.phone_number = pi.phone_number.map((phone) => ({ + ...phone, + full_number: `${phone.country_code || ''}${phone.number || ''}`, + })); + } + + return pi; +} + +module.exports = { enrichPersonalInfo }; \ No newline at end of file diff --git a/utils/response.util.js b/utils/response.util.js new file mode 100644 index 0000000..f422c37 --- /dev/null +++ b/utils/response.util.js @@ -0,0 +1,28 @@ +/*********************************************************************************************************************************************************************** + * File Name: response.util.js + * Type of Program: Utility + * Description: Standardised HTTP response helpers. + * Wraps all responses in a consistent envelope: + * { status, message, data?, errors? } + * Author: rgrgogu + * Date Created: Oct. 6, 2025 + *********************************************************************************************************************************************************************** + * HOW TO USE: + * const R = require('../utils/response.util'); + * return R.success(res, 'User created', user, 201); + * return R.error(res, 'Not found', 404); + ***********************************************************************************************************************************************************************/ + +const success = (res, message = 'OK', data = null, statusCode = 200) => + res.status(statusCode).json({ status: 'success', message, data }); + +const error = (res, message = 'An error occurred', statusCode = 500, errors = null) => { + const body = { status: 'error', message }; + if (errors) body.errors = errors; + return res.status(statusCode).json(body); +}; + +const validationError = (res, errors) => + res.status(422).json({ status: 'error', message: 'Validation failed', errors }); + +module.exports = { success, error, validationError }; \ No newline at end of file diff --git a/utils/token.util.js b/utils/token.util.js new file mode 100644 index 0000000..501f0c8 --- /dev/null +++ b/utils/token.util.js @@ -0,0 +1,70 @@ +/*********************************************************************************************************************************************************************** + * File Name: token.util.js + * Type of Program: Utility + * Description: JWT access & refresh token helpers. + * - generateTokens() → produces { accessToken, refreshToken } + * - verifyAccessToken() → validates and returns decoded payload + * - verifyRefreshToken() → validates the long-lived refresh token + * - hashToken() → SHA-256 hash used to store refresh tokens in DB + * Author: rgrgogu + * Date Created: Oct. 6, 2025 + *********************************************************************************************************************************************************************** + * HOW TO USE: + * const { generateTokens, verifyAccessToken } = require('../utils/token.util'); + * const { accessToken, refreshToken } = generateTokens(user); + ***********************************************************************************************************************************************************************/ +const jwt = require('jsonwebtoken'); +const crypto = require('crypto'); + +/** + * Generates a signed access token and a signed refresh token. + * @param {object} user - Sequelize User instance + * @returns {{ accessToken: string, refreshToken: string }} + */ +const generateTokens = (user) => { + const payload = { + user_id: user.user_id, + email: user.email, + acc_type: user.acc_type, + reg_type: user.reg_type, + }; + + const accessToken = jwt.sign(payload, process.env.JWT_SECRET, { + expiresIn: process.env.JWT_EXPIRES_IN || '1d', + }); + + const refreshToken = jwt.sign( + { user_id: user.user_id }, + process.env.JWT_REFRESH_SECRET, + { expiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d' } + ); + + return { accessToken, refreshToken }; +}; + +/** + * Verifies an access token. + * @param {string} token + * @returns {object} decoded payload + * @throws if invalid / expired + */ +const verifyAccessToken = (token) => + jwt.verify(token, process.env.JWT_SECRET); + +/** + * Verifies a refresh token. + * @param {string} token + * @returns {object} decoded payload + */ +const verifyRefreshToken = (token) => + jwt.verify(token, process.env.JWT_REFRESH_SECRET); + +/** + * SHA-256 hash a token string for safe DB storage. + * @param {string} token + * @returns {string} hex digest + */ +const hashToken = (token) => + crypto.createHash('sha256').update(token).digest('hex'); + +module.exports = { generateTokens, verifyAccessToken, verifyRefreshToken, hashToken }; \ No newline at end of file diff --git a/validators/auth.validator.js b/validators/auth.validator.js new file mode 100644 index 0000000..eae9a8f --- /dev/null +++ b/validators/auth.validator.js @@ -0,0 +1,46 @@ +/*********************************************************************************************************************************************************************** + * File Name: auth.validator.js + * Type of Program: Validator + * Description: express-validator rule chains for authentication endpoints. + * - registerValidator → POST /auth/register + * - loginValidator → POST /auth/login + * - verifyOTPValidator → POST /auth/verify-otp + * - resendOTPValidator → POST /auth/resend-otp + * - changePassValidator → POST /auth/change-password + * Author: rgrgogu + * Date Created: Oct. 6, 2025 + ***********************************************************************************************************************************************************************/ +const { body } = require('express-validator'); + +const registerValidator = [ + body('email').isEmail().withMessage('Valid email is required.'), + body('password') + .isLength({ min: 8 }) + .withMessage('Password must be at least 8 characters.') + .matches(/[A-Z]/).withMessage('Password must contain an uppercase letter.') + .matches(/[0-9]/).withMessage('Password must contain a number.'), +]; + +const loginValidator = [ + body('email').isEmail().withMessage('Valid email is required.'), + body('password').notEmpty().withMessage('Password is required.'), +]; + +const verifyOTPValidator = [ + body('email').isEmail(), + body('otp').isLength({ min: 6, max: 6 }).isNumeric().withMessage('OTP must be 6 digits.'), +]; + +const resendOTPValidator = [ + body('email').isEmail().withMessage('Valid email is required.'), +]; + +const changePassValidator = [ + body('current_password').notEmpty().withMessage('Current password is required.'), + body('new_password') + .isLength({ min: 8 }).withMessage('New password must be at least 8 characters.') + .matches(/[A-Z]/).withMessage('Must contain an uppercase letter.') + .matches(/[0-9]/).withMessage('Must contain a number.'), +]; + +module.exports = { registerValidator, loginValidator, verifyOTPValidator, resendOTPValidator, changePassValidator }; \ No newline at end of file diff --git a/validators/profile.validator.js b/validators/profile.validator.js new file mode 100644 index 0000000..19e2127 --- /dev/null +++ b/validators/profile.validator.js @@ -0,0 +1,19 @@ +/*********************************************************************************************************************************************************************** + * File Name: profile.validator.js + * Type of Program: Validator + * Description: express-validator rule chains for profile update endpoints. + * Author: rgrgogu + * Date Created: Oct. 6, 2025 + ***********************************************************************************************************************************************************************/ +const { body } = require('express-validator'); + +const updateProfileValidator = [ + body('personal_info').optional().isObject().withMessage('personal_info must be an object.'), + body('personal_info.name').optional().isObject(), + body('personal_info.name.given_name').optional().isString().trim().isLength({ max: 100 }), + body('personal_info.name.last_name').optional().isString().trim().isLength({ max: 100 }), + body('personal_info.occupation').optional().isString().trim().isLength({ max: 150 }), + body('personal_info.date_of_birth').optional().isDate().withMessage('date_of_birth must be a valid date.'), +]; + +module.exports = { updateProfileValidator }; \ No newline at end of file