API Reference — Overview
The CivicPulse backend is a single NestJS application (apps/api) that serves two distinct API surfaces
from the same process: a public API consumed by the citizen-facing web app (apps/web), and an
admin API consumed by the admin console (apps/admin). Both are versionless, JSON-only, and mounted
under one global prefix.
Base URL and prefix
The Nest app calls app.setGlobalPrefix('api') in apps/api/src/main.ts, so every route in the system —
public or admin — is reachable under /api/*. In local development the server listens on the port from
the API_PORT environment variable, defaulting to 3001:
http://localhost:3001/api
main.ts also configures:
- CORS with a concrete origin allow-list and
credentials: true(viaCORS_ORIGINS, falling back tohttp://localhost:5173andhttp://localhost:5175in dev). This is stricter than a wildcardcors: truebecause the admin console's session cookie needscredentials: trueto be sent cross-origin — a reflect-any-origin setup without credentials wouldn't work for it. - A custom JSON body parser (
bodyParser: falseonNestFactory.create, thenexpress.json()wired in manually) that captures the raw request body bytes ontoreq.rawBody. This exists so inbound webhook handlers (for example the WhatsApp webhook) can verify an HMAC signature against the exact bytes received, rather than against a re-serialized JSON string. cookie-parser, used by the admin API to read the admin session cookie.
Public API vs. admin API
| Public API | Admin API | |
|---|---|---|
| Consumed by | apps/web | apps/admin |
| Path shape | /api/<resource> | /api/admin/<resource> |
| Auth | None | Cookie-based session, enforced per-controller |
| Example | GET /api/issues, GET /api/issues/:id | GET /api/admin/dashboard |
Public API controllers (issues, reporters, events, scores, search, geography, administrators, health) declare no
guards. For example, apps/api/src/issues/issues.controller.ts is a plain @Controller('issues') with
GET /issues (list, filterable by cityId, category, and a free-text q param) and GET /issues/:id —
no authentication is checked anywhere in the request path. These routes are meant to be read freely by the
public web app.
Admin API controllers live under route prefixes like @Controller('admin/dashboard') or
@Controller('admin/access'), which combine with the global api prefix to produce paths under
/api/admin/*. Each such controller carries @UseGuards(AdminAuthGuard) (either on the whole controller
class, or on individual handlers — see admin-auth.controller.ts, where POST /admin/auth/login is left
unguarded so an admin can obtain a session, while GET /admin/auth/me and POST /admin/auth/logout require
one). There is no global guard applied app-wide; each admin controller opts in explicitly.
One exception worth noting: the WhatsApp webhook controller (admin-integrations/whatsapp-webhook.controller.ts)
is mounted outside the admin/ path and does not use AdminAuthGuard at all — inbound webhook calls are
authenticated by HMAC signature (over the raw body captured above), not by a browser session cookie.
How AdminAuthGuard works
apps/api/src/admin-auth/admin-auth.guard.ts implements Nest's CanActivate:
- It reads a session token off the request's admin session cookie.
- If there is no cookie, it throws
UnauthorizedException('Not signed in'). - If there is a cookie but the session doesn't validate (expired, revoked, unknown), it clears the cookie
and throws
UnauthorizedException('Session expired'). - If the session is valid and due for renewal, it re-issues the cookie with a fresh expiry.
- On success it attaches the resolved admin principal to
req.adminfor downstream handlers to read, and returnstrue.
There is no bearer-token or API-key mode for the admin API as of this writing — it is cookie-session only,
which is why it is not usable directly from tools like curl without first completing the login flow and
carrying the cookie forward.
Error shape
The API does not register a custom global exception filter or a global ValidationPipe, so errors are
shaped by Nest's built-in default exception filter. Any HttpException thrown by a guard or handler — for
example the UnauthorizedException thrown by AdminAuthGuard — serializes to a JSON body of the form:
{
"statusCode": 401,
"message": "Not signed in",
"error": "Unauthorized"
}
The HTTP status code on the response matches statusCode in the body. error is the standard reason
phrase for that status; message is either a string (as above) or, for validation-style errors built with
BadRequestException, whatever message the throwing code supplied. There is no request-tracing ID or
envelope wrapper beyond this — the body is the exception's own shape, unmodified.
Where to go next
- Public API — full route list for the unauthenticated endpoints consumed by the web app.
- Admin API — full route list for the cookie-authenticated endpoints consumed by the admin console, including the login flow and admin role/permission model.