Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,8 @@ assets
apps/genAI/uploads
**/uploads
coverage

# Super-admin panel and its API are deployed separately (Vercel / Render),
# not as images, so keep them out of every build context.
apps/admin
apps/admin-server
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,10 @@ jobs:
- name: Type-check
run: pnpm run check-types

# The super-admin panel (apps/admin, apps/admin-server) is deployed
# by hand to Vercel / Render and is deliberately left out of CI.
- name: Build all workspaces
run: pnpm run build
run: pnpm run build --filter='!admin' --filter='!admin-server'

prisma:
name: Prisma schema
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,5 @@ yarn-error.log*

# Local scratch: issue bodies to paste into GitHub
ISSUES-TO-CREATE.local.md
ISSUES-FROM-CODE-REVIEW.local.md
ISSUES-UI-UX.local.md
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ If you want to work on any of the following, there is real code here to work on:
| `apps/ws-server` | WebSockets | 8080 |
| `apps/genAI` | Python + FastAPI | 8000 |
| `packages/db` | Prisma + PostgreSQL | 5432 |
| `apps/admin` | React (super-admin panel) | 5174 |
| `apps/admin-server` | Node + Express (admin API) | 5000 |

The super-admin panel is optional and deployed separately, not through Docker or
CI/CD — see [apps/admin-server/README.md](apps/admin-server/README.md).

**[Read the full architecture guide →](ARCHITECTURE.md)** — service boundaries, data
model, request flows, caching, and the reasoning behind each decision.
Expand Down
21 changes: 21 additions & 0 deletions apps/admin-server/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Same Postgres database that http-server and ws-server use.
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/Qtrive

PORT=5000

# The one super-admin login. Use a long random password in production.
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=change-me-to-something-long

# Signs admin tokens. Must be at least 32 characters and different from
# http-server's JWT_SECRET, so a user token can never pass as an admin one.
# Generate one with: openssl rand -hex 32
ADMIN_JWT_SECRET=

# Where the admin panel is hosted, comma-separated (e.g. your Vercel URL).
# Required: only these origins may call the API from a browser.
ADMIN_ORIGIN=http://localhost:5174

# A session that has not finished this many hours after it started counts
# as abandoned and can be cancelled from the panel.
STALE_SESSION_HOURS=3
4 changes: 4 additions & 0 deletions apps/admin-server/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules
dist
.env
*.tsbuildinfo
117 changes: 117 additions & 0 deletions apps/admin-server/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# Rexial super-admin

Two small apps that give one super-admin a view of the whole platform:

| App | What it is | Deploy to |
|---|---|---|
| `apps/admin-server` | Express API, reads the same Postgres database as http-server | Render (web service) |
| `apps/admin` | React panel that talks to that API | Vercel (or Render static site) |

Neither is built into a Docker image or run by CI/CD. Both are excluded on
purpose (`.dockerignore`, and the build step in `.github/workflows/ci.yml`).

## What the panel shows

- **Overview**: users (total, new, active), quizzes by status, sessions by
outcome (completed, live, abandoned, cancelled), participants (guests vs
signed-in), answer accuracy, average players and length per session,
day-by-day charts for signups, players, sessions and API requests, live
sessions right now, most-played quizzes and newest users. Refreshes every 30s.
- **Sessions**: every hosted session with host, player count, progress and
duration; filter by live / completed / abandoned / cancelled. Per session:
full leaderboard (with account email where the player was signed in) and
per-question correct rate and answer time.
- **Quizzes**: every quiz with creator, questions, sessions and total
participants. Per quiz: organizers, all sessions, question stats.
- **Users**: name, email, sign-up date, last login, last activity, quizzes
created and played, total score. Search, sort, CSV export. Per user:
everything they created and played, IPs/devices used, recent API calls.
- **API traffic**: requests, unique IPs, signed-in callers, 4xx/5xx rates,
avg and p95 latency, per-endpoint table, status codes, browsers and
platforms, busiest IPs and the latest failed requests.

### Abandoned and cancelled sessions

Rexial had no "cancelled" state: a session the host walks away from just
stays WAITING or IN_PROGRESS forever. The panel calls such a session
**abandoned** once it has been idle for `STALE_SESSION_HOURS` (default 3), and
offers a **Cancel** button that marks it `CANCELLED`, clears the quiz's join
code and puts the quiz back to draft so the host can relaunch it. Sessions that
are still live cannot be cancelled, because ws-server is still driving them.

### Where traffic data comes from

http-server logs every request (method, route, status, latency, IP, user id,
user agent) into the `ApiRequestLog` table. Rows are buffered in memory and
written in batches every 5 seconds, so requests don't wait on the database.
Rows older than `REQUEST_LOG_RETENTION_DAYS` (default 30, set on
**http-server**) are deleted hourly. Login also records `User.lastLoginAt`.

## Database migration

The panel needs migration `20260929100000_admin_panel_tracking` (the
`ApiRequestLog` table, `User.lastLoginAt` and the `CANCELLED` session status).
The production backend container applies pending migrations on start, so the
next normal deploy of http-server takes care of it. Deploy that first.

## Run locally

```bash
cp apps/admin-server/.env.example apps/admin-server/.env # fill it in
cp apps/admin/.env.example apps/admin/.env.local
pnpm install
pnpm --filter @repo/db run db:generate
pnpm --filter admin-server dev # http://localhost:5000
pnpm --filter admin dev # http://localhost:5174
```

## Deploy the API to Render

New **Web Service** from this repo, root directory left empty (repo root):

| Setting | Value |
|---|---|
| Build command | `npm i -g pnpm@9.0.0 && pnpm install --frozen-lockfile --filter 'admin-server...' && pnpm --filter @repo/db run db:generate && pnpm --filter admin-server build` |
| Start command | `pnpm --filter admin-server start` |
| Health check path | `/health` |

The `--filter` installs only this app and the database package, not the
whole monorepo. Environment variables (see `.env.example`):

| Name | Notes |
|---|---|
| `NODE_VERSION` | `22` (the API needs Node 22.12 or newer). |
| `DATABASE_URL` | The production database. It must be reachable from Render, so the Postgres port on your server has to accept Render's connections (ideally over SSL and limited to Render's outbound IPs). |
| `ADMIN_EMAIL` | Your login email. |
| `ADMIN_PASSWORD` | 12+ characters; use a long random one. |
| `ADMIN_JWT_SECRET` | 32+ characters, e.g. `openssl rand -hex 32`. Must differ from http-server's `JWT_SECRET`. |
| `ADMIN_ORIGIN` | Required. The panel's URL, e.g. `https://rexial-admin.vercel.app`. Comma-separate several. |
| `STALE_SESSION_HOURS` | Optional, default 3. |

The server refuses to start if any required value is missing or too weak.

## Deploy the panel to Vercel

New project from this repo:

| Setting | Value |
|---|---|
| Root directory | `apps/admin` |
| Framework preset | Vite |
| Install command | `cd ../.. && pnpm install --frozen-lockfile --filter 'admin...'` |
| Build command | `pnpm run build` |
| Output directory | `dist` |
| Env var | `VITE_ADMIN_API_URL=https://<your-render-service>.onrender.com/api/admin` |

`vercel.json` already rewrites every path to `index.html` for client-side
routing. On a Render static site instead, add a rewrite rule `/*` → `/index.html`.

## Security notes

- There is exactly one admin account, defined by environment variables; it is
not a row in the `User` table, so no app user can be promoted into it.
- Admin tokens are signed with their own secret and audience and expire after
12 hours. Five wrong passwords from one IP lock logins for 15 minutes.
- The panel never receives password hashes, and the CSV export escapes cells
that a spreadsheet would run as formulas.
- The panel page is marked `noindex`. Keep its URL private all the same.
26 changes: 26 additions & 0 deletions apps/admin-server/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "admin-server",
"version": "1.0.0",
"private": true,
"description": "Super-admin API for Rexial: platform stats, users, quizzes, sessions and traffic",
"main": "dist/index.js",
"scripts": {
"build": "tsc -b",
"dev": "tsc -b && node dist/index.js",
"start": "node dist/index.js"
},
"dependencies": {
"@repo/db": "workspace:*",
"cors": "^2.8.6",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.5.0",
"typescript": "^6.0.2"
}
}
31 changes: 31 additions & 0 deletions apps/admin-server/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Reads and checks the environment once at startup. A missing or weak
// secret stops the server instead of silently falling back to a default:
// this service can read every user's data.

function required(name: string) {
const value = process.env[name]?.trim();
if (!value) throw new Error(`${name} is not set. See apps/admin-server/.env.example`);
return value;
}

const jwtSecret = required("ADMIN_JWT_SECRET");
if (jwtSecret.length < 32) throw new Error("ADMIN_JWT_SECRET must be at least 32 characters");
if (jwtSecret === process.env.JWT_SECRET) throw new Error("ADMIN_JWT_SECRET must differ from JWT_SECRET");

const password = required("ADMIN_PASSWORD");
if (password.length < 12) throw new Error("ADMIN_PASSWORD must be at least 12 characters");

required("DATABASE_URL");

export const config = {
port: Number(process.env.PORT) || 5000,
adminEmail: required("ADMIN_EMAIL").toLowerCase(),
adminPassword: password,
jwtSecret,
tokenTtlSeconds: 12 * 60 * 60,
origins: required("ADMIN_ORIGIN")
.split(",")
.map((o) => o.trim())
.filter(Boolean),
staleSessionHours: Number(process.env.STALE_SESSION_HOURS) || 3,
};
51 changes: 51 additions & 0 deletions apps/admin-server/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import "dotenv/config";
import express, { Express, NextFunction, Request, Response } from "express";
import cors from "cors";
import { config } from "./config";
import { requireAdmin } from "./middleware/auth";
import authRoutes from "./routes/auth";
import overviewRoutes from "./routes/overview";
import quizRoutes from "./routes/quizzes";
import sessionRoutes from "./routes/sessions";
import userRoutes from "./routes/users";
import trafficRoutes from "./routes/traffic";

const app: Express = express();

// Render, Railway and friends sit behind one proxy hop; this makes req.ip
// the real client, which the login rate limit keys on.
app.set("trust proxy", 1);
app.disable("x-powered-by");

app.use(cors({ origin: config.origins }));
app.use(express.json({ limit: "10kb" }));
app.use((_req, res, next) => {
res.setHeader("Cache-Control", "no-store");
res.setHeader("X-Content-Type-Options", "nosniff");
next();
});

app.get("/health", (_req, res) => {
res.json({ ok: true });
});

app.use("/api/admin/auth", authRoutes);
app.use("/api/admin/overview", requireAdmin, overviewRoutes);
app.use("/api/admin/quizzes", requireAdmin, quizRoutes);
app.use("/api/admin/sessions", requireAdmin, sessionRoutes);
app.use("/api/admin/users", requireAdmin, userRoutes);
app.use("/api/admin/traffic", requireAdmin, trafficRoutes);

app.use((_req, res) => {
res.status(404).json({ error: "Not found" });
});

// Express 5 forwards rejected async handlers here.
app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
console.log("Admin API error:", error);
res.status(500).json({ error: "Internal server error" });
});

app.listen(config.port, () => {
console.log(`Admin API listening on ${config.port}`);
});
17 changes: 17 additions & 0 deletions apps/admin-server/src/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { NextFunction, Request, Response } from "express";
import jwt from "jsonwebtoken";
import { config } from "../config";

export const requireAdmin = (req: Request, res: Response, next: NextFunction) => {
const header = req.headers.authorization;
const token = header?.startsWith("Bearer ") ? header.slice(7) : null;
if (!token) return res.status(401).json({ error: "Not signed in" });

try {
const payload = jwt.verify(token, config.jwtSecret, { audience: "rexial-admin" }) as { role?: string };
if (payload.role !== "SUPER_ADMIN") return res.status(403).json({ error: "Forbidden" });
next();
} catch {
return res.status(401).json({ error: "Session expired, sign in again" });
}
};
60 changes: 60 additions & 0 deletions apps/admin-server/src/routes/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Router } from "express";
import { createHash, timingSafeEqual } from "crypto";
import jwt from "jsonwebtoken";
import { config } from "../config";
import { requireAdmin } from "../middleware/auth";

const router: Router = Router();

// Brute-force guard: a handful of failed attempts per IP, then a cool-off.
const WINDOW_MS = 15 * 60 * 1000;
const MAX_FAILURES = 5;
const failures = new Map<string, { count: number; resetAt: number }>();

// Forget expired lockouts, so failed attempts from many IPs can't grow the
// map for as long as the process lives.
setInterval(() => {
const now = Date.now();
for (const [ip, entry] of failures) if (entry.resetAt <= now) failures.delete(ip);
}, WINDOW_MS).unref();

function sameSecret(a: string, b: string) {
// Hashing first gives equal-length buffers, so the comparison takes the
// same time whatever the input length.
const ha = createHash("sha256").update(a).digest();
const hb = createHash("sha256").update(b).digest();
return timingSafeEqual(ha, hb);
}

router.post("/login", (req, res) => {
const ip = req.ip || "unknown";
const now = Date.now();
const entry = failures.get(ip);
if (entry && entry.resetAt > now && entry.count >= MAX_FAILURES) {
const minutes = Math.ceil((entry.resetAt - now) / 60000);
return res.status(429).json({ error: `Too many attempts. Try again in ${minutes} min.` });
}

const email = String(req.body?.email ?? "").trim().toLowerCase();
const password = String(req.body?.password ?? "");

const ok = sameSecret(email, config.adminEmail) && sameSecret(password, config.adminPassword);
if (!ok) {
const fresh = !entry || entry.resetAt <= now;
failures.set(ip, { count: fresh ? 1 : entry.count + 1, resetAt: fresh ? now + WINDOW_MS : entry.resetAt });
return res.status(401).json({ error: "Invalid credentials" });
}

failures.delete(ip);
const token = jwt.sign({ role: "SUPER_ADMIN", email }, config.jwtSecret, {
audience: "rexial-admin",
expiresIn: config.tokenTtlSeconds,
});
return res.json({ token, email });
});

router.get("/me", requireAdmin, (_req, res) => {
res.json({ email: config.adminEmail });
});

export default router;
Loading
Loading