Authentication and Users
Log in, understand the HS256 JWT claims, enforce role checks, and manage users through the admin endpoints.
You authenticate every request with a bearer token from POST /auth/login. The token logic lives in apps/server/src/core/auth/token.ts, the middleware and role checks live in apps/server/src/core/auth/middleware.ts, and the login routes live in apps/server/src/features/auth/route.ts.
How login works
You send an email and password to the public login endpoint. It is rate limited to 5 attempts per 60 seconds per client by the loginRateLimit middleware in apps/server/src/core/auth/middleware.ts, backed by the per address counters in apps/server/src/core/auth/login-attempts.ts. A failed attempt stays in the window while a successful one removes its own entry.
curl -X POST http://localhost:8000/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@demo.pharmaflow","password":"Demo@1234"}'
You get back access_token, token_type, user_id, role, branch_id, branch_name, and full_name. Every login writes a LOGIN row to the audit log through apps/server/src/features/auth/service.ts with the caller IP, and updates last_login_at on the user.
The JWT shape
Tokens are HS256 JWTs signed with JWT_SECRET, which must be at least 32 characters. The default lifetime is 480 minutes, configurable with JWT_EXPIRE_MINUTES and enforced in apps/server/src/core/auth/token.ts. The token carries an org_id claim that selects the tenant, and authMiddleware never trusts an org from a header.
{
"sub": "user-uuid",
"role": "admin",
"branch_id": "branch-uuid",
"email": "admin@demo.pharmaflow",
"org_id": "default"
}
You send the token on every protected call:
curl http://localhost:8000/auth/me \
-H "Authorization: Bearer $TOKEN"
How authMiddleware protects you
authMiddleware in apps/server/src/core/auth/middleware.ts verifies the token, then re-reads the user row on every request. If the account is missing or is_active is not true, you get a 401 even with a valid token. It also overwrites the token role and branch_id with the fresh database values, so a demoted user loses access immediately, and it applies the tenant search path from the token org_id through apps/server/src/core/database/tenancy.ts.
How requireRoles works
requireRoles(...allowed) in apps/server/src/core/auth/middleware.ts runs after authMiddleware. The admin role always passes, every other role must be in the allowed list or you get a 403. requireAdmin is requireRoles() with an empty list, so only admins pass.
userRoutes.get("/", requireAdmin, async (context) => { ... });
syncRoutes.post("/push", requireRoles("admin", "branch_manager", "pharmacist", "cashier"), async (context) => { ... });
Managing users
All user routes live in apps/server/src/features/users/route.ts behind authMiddleware, and the business rules live in apps/server/src/features/users/service.ts. Admins list everyone with role and active filters, and the list defaults to active users only.
curl "http://localhost:8000/users?page=1&page_size=20&role=pharmacist&is_active=true" \
-H "Authorization: Bearer $TOKEN"
You fetch one user when you are that user or an admin. GET /users/:userId returns 403 otherwise and 404 for unknown ids.
Creating and updating users
Only admins create users with POST /users. The role must be admin or pharmacist, the branch must exist and be active, the email must be unique and valid, and the password must be at least 8 characters. Creation emits a users sync change and logs a CREATE audit row, both in apps/server/src/features/users/service.ts.
curl -X POST http://localhost:8000/users \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"email":"new@demo.pharmaflow","password":"Secret123","full_name":"New Pharmacist","role":"pharmacist","branch_id":"BRANCH_ID"}'
You update with PUT /users/:userId under a field allowlist. You may change your own full_name and phone only, while admins may also change role, branch_id, and is_active. Passwords never go through this endpoint. Use POST /auth/change-password for your own password with current_password plus a new_password of 8 or more characters, and POST /users/:userId/reset-password for the admin reset with matching new_password and confirm_password.
Next steps
Read how branches scope your data in /platform/branches, query who changed what in /platform/audit-log, and walk the full counter flow in /guides/selling-end-to-end.