A security operations portal for monitoring security events across your infrastructure.
This is a React + TypeScript security operations portal with a Node.js + Express backend API.
- Node.js 24+
This project uses Node's built-in node:sqlite module for local SQLite persistence.
(I was using pnpm for this project but of course npm would work just fine)
Install dependencies:
npm installStart the API:
npm run start:serverFor backend development with automatic restarts, use:
npm run dev:serverStart the frontend:
npm run devThe frontend runs at http://localhost:5173 and the API runs at http://localhost:3001.
To build the frontend and type-check the project:
npm run buildTo run linting:
npm run lintSeeded credentials:
admin@penguwave.io/admin123analyst@penguwave.io/pass456viewer@penguwave.io/view789is disabled and cannot sign in
The local database is created automatically at data/penguwave.sqlite the first time the API needs user or event data.
- React + Vite + TypeScript frontend
- Express API with cookie-based authentication
- Persistent SQLite database at
data/penguwave.sqlite - 50 realistic mock security events (
data/mock_events.json) - API endpoint contract (
docs/api_contract.md) - Threat-thinking document (
THREAT-THINKING.md)
Users sign in through POST /api/auth/login. The backend normalizes the supplied email, looks up the user in SQLite, compares the submitted password with the stored bcrypt hash, and rejects disabled accounts.
On a successful login, the API signs a JWT containing the user's id and stores it in an HttpOnly, SameSite=Lax cookie named penguwave_session. The cookie lasts 12 hours. In production, the cookie is also marked Secure, so it's only sent over HTTPS.
The React app doesn't store the JWT in local storage. It restores the current session by calling GET /api/auth/me, which verifies the cookie, reloads the user from SQLite, and confirms the account is still active. Logout uses POST /api/auth/logout, which clears both the session cookie and the CSRF cookie.
State-changing API calls are protected with a CSRF double-submit token:
- The frontend calls
GET /api/auth/csrf. - The backend returns a random token and sets the readable
penguwave_csrfcookie. - The frontend sends the same value in the
X-CSRF-Tokenheader onPOST,PATCH, andDELETErequests. - The API rejects missing or mismatched tokens with
403.
Protected frontend routes wait for the /api/auth/me check before rendering page content. This improves the user experience, but it's not the security boundary - every protected API endpoint also verifies authentication on the server.
Authorization is enforced on the backend. The events API uses requireAuth, so only authenticated, active users can read security events. Event queries are also scoped by userId, so /api/events and /api/events/:id only return events owned by the authenticated user. User management routes use both requireAuth and requireRole("admin"), so non-admin users receive 403 Access forbidden even if they call /api/users directly.
The frontend mirrors these rules for usability. The /users page is wrapped in AdminRoute, so analysts don't see the management UI. This client-side guard is only a convenience - the Express middleware is what actually prevents unauthorized access.
User records returned by the API are passed through toPublicUser, which removes password hashes before data leaves the server. Admin actions are also constrained by route-level checks:
- Admins cannot delete their own account.
- Admins cannot delete other admin accounts.
- Admins cannot disable or demote other admin accounts.
- The API rejects any role/status update that would leave the system with no active admin accounts.
- User creation validates role, duplicate email, and minimum password length before storing a bcrypt hash.
This prevents analysts from managing users, prevents password hashes from leaking through responses, and reduces the chance of accidentally locking all administrators out of the system.
User and event data are persisted in data/penguwave.sqlite using Node's built-in node:sqlite module. On first use, the backend creates the users and events tables. Tables are seeded only when empty. Security events are imported from data/mock_events.json, and user records are loaded from data/users.json.
After the initial seed, login lookups, user creation, user updates, user deletes, and event reads are served from SQLite.
If this system were deployed to production, I would make the following changes before exposing it to real users:
- Run the frontend and API only over HTTPS with HSTS.
- Set
NODE_ENV=productionso session and CSRF cookies use theSecureflag. - Replace the development
JWT_SECRETwith a high-entropy secret from a secret manager, and rotate it through a documented process. - Restrict CORS to the exact deployed frontend origin instead of allowing broad origins.
- Put the API behind a reverse proxy or load balancer with request size limits, security headers, access logs, and rate limiting for login attempts.
- Use a managed relational database such as PostgreSQL instead of local SQLite, with migrations, encrypted backups, monitoring, least-privilege database credentials, and a tested restore process.
- Keep password storage on a slow hash such as bcrypt or Argon2, with appropriate cost settings for the production hardware.
- Store secrets outside the repository and CI logs.
- Add centralized audit logging for login attempts and user-management actions.
- Serve built frontend assets from a static host or CDN and deploy the API as a separate service with health checks.
- Continue enforcing authentication and authorization in the backend, with frontend route guards treated only as UX.