Platform
Request path
What happens between a client sending a request and a handler touching the database. The chain has four stages, each implemented as one function, followed by a transaction that carries the caller identity into Postgres.
The middleware chain
Rate limiters run first because they are cheap. Then identity, approval, route permission, and finally the database context. A failure at any stage stops the request and no later stage runs.
Request lifecycle
| Function | Source | Does | Fails with |
|---|---|---|---|
verifyJWT | Better Auth | Validates the Bearer token or session cookie and sets req.user = { id, email }. | 401 |
attachRole | user_roles | Loads the caller's role rows, rejects if none is approved, sets req.role. | 403 |
authorizeRoute | nav_item_roles | Compares the route with the permission table for that role. Mirrors the sidebar rules. | 403 |
injectRLSContext | pg Pool | Checks out a client, BEGIN, sets the two session settings, exposes req.db, commits when the response ends. | 500 |
keyByUserOrIp | express-rate-limit | Rate-limit key: req.user.id when present, else req.ip. | 429 |
Wiring
import { verifyJWT, attachRole, authorizeRoute, injectRLSContext } from './stages';
import { generalApiLimiter } from './limits';
// Every protected router mounts the same four stages, in this order.
export const protect = [verifyJWT, attachRole, authorizeRoute, injectRLSContext];
app.use('/api/', generalApiLimiter);
app.use('/api/auth', authRouter); // Better Auth handlers, no chain
app.use('/api/courses', protect, coursesRouter);
app.use('/api/resources', protect, resourcesRouter);
app.use('/api/questionnaires',protect, questionnairesRouter);
app.use('/api/admin', protect, adminRouter); // authorizeRoute limits this to adminRow-level security context
Policies read two settings, app.current_user_id and app.current_role. The last stage of the chain sets them for exactly one transaction.
import { pool } from '../db/pool'; // direct pg Pool, no PgBouncer
export async function injectRLSContext(req, res, next) {
const client = await pool.connect();
let finished = false;
const end = async (cmd: 'COMMIT' | 'ROLLBACK') => {
if (finished) return; finished = true;
try { await client.query(cmd); } finally { client.release(); }
};
try {
await client.query('BEGIN');
// set_config(..., true) is the parameterisable form of SET LOCAL
await client.query(
"SELECT set_config('app.current_user_id', $1, true), set_config('app.current_role', $2, true)",
[req.user.id, req.role]);
req.db = client;
res.on('finish', () => end(res.statusCode < 400 ? 'COMMIT' : 'ROLLBACK'));
res.on('close', () => end('ROLLBACK')); // client aborted
next();
} catch (e) { await end('ROLLBACK'); next(e); }
}SET LOCAL cannot take bind parameters, so a naive version concatenates the user id into SQL. set_config(name, value, true) does the same thing (is_local = true) safely.
The setting lives for one transaction on one server connection. A transaction-mode pooler can hand that connection to another request. The build uses a direct pg Pool. If a pooler is added later, use session pooling or pass the context another way.
Postgres skips RLS for a table's owner. The API must connect as a role that does not own the tables, or the tables need ALTER TABLE ... FORCE ROW LEVEL SECURITY.
This sketch commits after the response finishes, so a commit failure cannot change the status the client saw. For writes the client must be able to trust, commit inside the handler before sending the response.
Handlers must run their queries on req.db, never on the shared pool, or the policies see an empty identity and return no rows.
Rate limiting
Layered, cheapest first. The goal is to keep the system stable under runaway clients and to protect its two expensive endpoints, AI generation and certificate rendering.
| Layer | Where | Rule | Why |
|---|---|---|---|
| 0 | fail2ban | Ban an IP after N failed auth attempts in a window | Cuts brute force and scanning before the app sees it |
| 1 | Nginx | auth_strict 2 r/s (burst 5), api_general 15 r/s (burst 30), body 25 MB | Safety net against floods. Kept generous because a venue shares one public IP |
| 2 | Better Auth rateLimit | Built-in limiter on /api/auth/* | Already knows which requests are login, signup and reset. |
| 3 | generalApiLimiter | 100 requests per minute, keyed by user id | Real per-client enforcement |
| 3 | aiGenerationLimiter | 10 per hour per trainer on /api/questionnaires/:id/generate | Slow, costly endpoint |
| 3 | certificateIssueLimiter | 20 per minute on /api/certificates/issue | Slow endpoint that calls Gotenberg |
Account approval
Roles live in a join table, user_roles, with an approved flag that defaults to false. Until an admin flips it, attachRole rejects the account, whatever else is valid.
Account approval states
GET /api/admin/users?status=pending lists the queue. PATCH /api/admin/users/:id/approve approves one or many. A ListPage with bulk actions renders it.
REST surface
| Group | Method and path | Roles | Notes |
|---|---|---|---|
| Auth | POST /api/auth/* | public | Better Auth handlers |
| Users | GET /api/admin/users?status=pending | admin | approval queue |
| Users | PATCH /api/admin/users/:id/approve | admin | bulk capable |
| Profiles | GET, PUT /api/trainees/me/profile | trainee | |
| Profiles | GET, PUT /api/trainers/me/profile | trainer | regenerates the competency embedding on save |
| Courses | GET, POST /api/courses | trainer, admin to create | |
| Enrollment | POST /api/courses/:id/enroll | trainee | unique (course_id, trainee_id) makes double clicks harmless |
| Resources | POST /api/resources | trainer | multipart to the volume, queues an embedding job |
| Resources | GET /api/courses/:id/resources | enrolled, owner, admin | RLS scoped |
| Assessments | POST /api/questionnaires | trainer | manual, or ai_generated true |
| Assessments | POST /api/questionnaires/:id/generate | trainer | AI draft, aiGenerationLimiter |
| Assessments | POST /api/questionnaires/:id/attempts | trainee | submit answers |
| Feedback | POST /api/courses/:id/feedback | trainee | rating 1 to 5 |
| Certificates | POST /api/certificates/issue | admin, trainer | Gotenberg then volume |
| Competency | GET /api/admin/competency/suggest?course_id= | admin | ranked trainer matches |
| Notices | GET, POST /api/notices | read all, post admin | target_roles filters the audience |