Request path

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

invalid or expiredvalidnot approvedapproveddeniedallowed

Client request
Authorization: Bearer JWT

Nginx
limit_req, limit_conn

generalApiLimiter
key: keyByUserOrIp

1 verifyJWT

401 Unauthorized

2 attachRole
user_roles.approved

403 Forbidden

3 authorizeRoute
route vs nav_item_roles

403 Forbidden

4 injectRLSContext
BEGIN, set_config app.current_user_id
and app.current_role

Handler runs queries
RLS policies filter rows

COMMIT, release client
back to the direct pg Pool

The four stages run in order. A failure stops the request and nothing later runs.
FunctionSourceDoesFails with
verifyJWTBetter AuthValidates the Bearer token or session cookie and sets req.user = { id, email }.401
attachRoleuser_rolesLoads the caller's role rows, rejects if none is approved, sets req.role.403
authorizeRoutenav_item_rolesCompares the route with the permission table for that role. Mirrors the sidebar rules.403
injectRLSContextpg PoolChecks out a client, BEGIN, sets the two session settings, exposes req.db, commits when the response ends.500
keyByUserOrIpexpress-rate-limitRate-limit key: req.user.id when present, else req.ip.429

Wiring

services/api/src/middleware/chain.ts
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 admin

Row-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.

services/api/src/middleware/rls.ts
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); }
}
Why set_config

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.

No PgBouncer

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.

Owner bypass

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.

Commit timing

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 use req.db

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.

LayerWhereRuleWhy
0fail2banBan an IP after N failed auth attempts in a windowCuts brute force and scanning before the app sees it
1Nginxauth_strict 2 r/s (burst 5), api_general 15 r/s (burst 30), body 25 MBSafety net against floods. Kept generous because a venue shares one public IP
2Better Auth rateLimitBuilt-in limiter on /api/auth/*Already knows which requests are login, signup and reset.
3generalApiLimiter100 requests per minute, keyed by user idReal per-client enforcement
3aiGenerationLimiter10 per hour per trainer on /api/questionnaires/:id/generateSlow, costly endpoint
3certificateIssueLimiter20 per minute on /api/certificates/issueSlow 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

sign up (Better Auth)user_roles row created,approved = falseadmin approvesadmin rejectsattachRole passes on everyrequest

Registered

Pending

Approved

Rejected

Active

Admin queue

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

GroupMethod and pathRolesNotes
AuthPOST /api/auth/*publicBetter Auth handlers
UsersGET /api/admin/users?status=pendingadminapproval queue
UsersPATCH /api/admin/users/:id/approveadminbulk capable
ProfilesGET, PUT /api/trainees/me/profiletrainee
ProfilesGET, PUT /api/trainers/me/profiletrainerregenerates the competency embedding on save
CoursesGET, POST /api/coursestrainer, admin to create
EnrollmentPOST /api/courses/:id/enrolltraineeunique (course_id, trainee_id) makes double clicks harmless
ResourcesPOST /api/resourcestrainermultipart to the volume, queues an embedding job
ResourcesGET /api/courses/:id/resourcesenrolled, owner, adminRLS scoped
AssessmentsPOST /api/questionnairestrainermanual, or ai_generated true
AssessmentsPOST /api/questionnaires/:id/generatetrainerAI draft, aiGenerationLimiter
AssessmentsPOST /api/questionnaires/:id/attemptstraineesubmit answers
FeedbackPOST /api/courses/:id/feedbacktraineerating 1 to 5
CertificatesPOST /api/certificates/issueadmin, trainerGotenberg then volume
CompetencyGET /api/admin/competency/suggest?course_id=adminranked trainer matches
NoticesGET, POST /api/noticesread all, post admintarget_roles filters the audience
Capacity Connect · Team Syntax Squad · SIH 2026 · PS 26075Code samples are implementation sketches.