Auth
Endpoints under /auth/*. All use HTTP + JSON.
POST /auth/login
Public. Exchange credentials for a token pair.
Request:
{
"email": "user@example.com",
"password": "secret123"
}
Response 200:
{
"user": {
"id": "uuid",
"email": "user@example.com",
"name": "Jane Doe",
"platformRole": "admin",
"orgPosition": "member",
"departmentId": "uuid-or-null",
"avatarColor": "#2a6fdb"
},
"accessToken": "eyJhbGc...",
"refreshToken": "eyJhbGc..."
}
Errors:
401{"error": "Invalid email or password"}— credentials wrong or user inactive503{"error": "not_bootstrapped"}— platform not initialized yet
POST /auth/refresh
Public. Rotate expired access token using a refresh token.
Request:
{ "refreshToken": "eyJhbGc..." }
Response 200:
{
"accessToken": "eyJhbGc...",
"refreshToken": "eyJhbGc..." // new refresh token (rotated)
}
Errors:
401— refresh token expired or invalid. Client should redirect to/login.
POST /auth/logout
Authenticated. Invalidates the refresh token server-side (access token continues to work until natural expiry).
Response 204 — empty.
GET /auth/me
Authenticated. Returns the current user (re-fetches from DB to pick up changes).
Response 200:
{
"user": {
"id": "uuid",
"email": "...",
"name": "...",
"platformRole": "...",
"orgPosition": "...",
"departmentId": "...",
"status": "active",
"lastLoginAt": "2026-05-12T...",
"createdAt": "2026-04-01T..."
}
}
Used by apps/web/lib/auth.tsx to hydrate the auth context on page load.
Token format
JWT signed with HS256 using JWT_SECRET. Payload:
{
"sub": "<user-id>",
"iat": 1716912345,
"exp": 1716913245 // 15 min for access, 7 days for refresh
}
Note: platform role is NOT in the token. After the access-control refactor, the API always fetches the user from DB via requireAuth — token carries only the ID. This means role changes take effect immediately, not at next refresh.
Client behavior
apps/web/lib/api.ts auto-refreshes on 401:
if (res.status === 401 && !skipAuth && this.refreshToken) {
await this.refreshAccessToken();
// retry original request with new token
res = await fetch(url, { ...options, headers });
}
If refresh itself fails (401 from /auth/refresh), the client clears tokens and redirects to /login.
Password policy
Hash: bcryptjs with cost factor 10. Storage: users.password_hash.
Rules enforced at validation:
- Min 8 characters (enforced by Zod on all create/update endpoints)
- No other complexity rules yet
Gotcha: bcrypt hash shell escaping
When setting a password hash manually via SQL (e.g. in a reset runbook), do not interpolate the hash into a bash double-quoted command. The $ signs in $2b$12$... get eaten by the shell.
Wrong:
ssh jaeger "docker exec lumen-postgres psql -c \"UPDATE users SET password_hash = '$HASH' ...\""
Right: write the SQL to a file and pipe it:
cat > reset.sql <<'SQL'
UPDATE users SET password_hash = '$2b$12$aexliH42aMY6mG3QM0JiBuIyYdn75FzoqQaxd5HUXVwerGavIY5Ly' WHERE email = '...';
SQL
scp reset.sql jaeger:/tmp/
ssh jaeger "docker cp /tmp/reset.sql lumen-postgres:/tmp/ && \
docker exec lumen-postgres psql -U lumen -d lumen -f /tmp/reset.sql"