Backend HTTP API

Every public endpoint with request/response shape, auth requirement, and error semantics.

Purpose

This page is the canonical HTTP API reference for the GENIE.AI backend (gov-chat-backend). For each endpoint: the path, the HTTP method, the required auth, the request shape, the response shape, and the error semantics.

For the narrative version with worked examples, see this page itself — this is the single source of truth for the HTTP API.

Component

components/gov-chat-backend/ (Node.js 22, Express, JWT, winston). Base URL: https://<domain>/api (via Kong Gateway). Internal port: 3000 (not exposed in Swarm). OpenAPI: /api-docs (Swagger UI served from the BFF itself).

Sibling services are documented separately. The /api/files and /api/labels endpoints are served by document-repository — a different Node.js process with its own auth middleware (authorizeRole(['Admin', 'dataprep-service'])) and its own deployment boundary. The schemas here cover the BFF only.

Before you start

Before integrating against this API, confirm the following prerequisites are in place. An integrator who skips this section typically hits a UNAUTHENTICATED (401) or FORBIDDEN (403) on the first call and wastes time guessing what is wrong.

Required

  • A reachable deployment. The public URL is https://<domain>/api where <domain> is set by NGINX_PUBLIC_DOMAIN (defined in the root env template). For local development the BFF listens on http://localhost:3000 and Swagger is reachable at http://localhost:3000/api-docs.
  • A Keycloak realm and client. The BFF validates tokens issued by KEYCLOAK_URL/realms/<KEYCLOAK_REALM> against the OIDC issuer configured by KEYCLOAK_URL and KEYCLOAK_REALM. The realm, client ID, and admin credentials live in .env; see Keycloak Admin Guide for how to obtain them.
  • An OIDC flow that produces a Bearer access token. The BFF accepts tokens from any standard OIDC flow:
    • Authorization Code + PKCE for end-user apps (web, mobile) — recommended.
    • Client Credentials for service-to-service calls (dataprep, document-repository, cron jobs).
    • Resource Owner Password Credentials (ROPC) is supported but disabled in production realms for security reasons; enable it only temporarily for tests, then revert. The agent playbook for this lives in .claude/rules/SERVER-TESTING.md (developer-internal, not published).
  • Kong is routing /api/* to the backend. The express-api service in api-gateway-solution/new-config/kong_config.json (/api catch-all plus named routes for /api/auth, /api/me, /api/queries, etc.) must be applied.

For admin endpoints (/api/admin/*)

  • A user with the realm role admin assigned (lowercase — verified in keycloak-auth-middleware.js:183).

Recommended

  • The OpenAPI JSON (GET /api-docs.json) — a programmatic copy of all the schemas below, refreshed on every BFF restart. Useful for client code generation and contract tests.

Verify your setup

Two smoke tests prove the wiring is correct end-to-end. The first must succeed without a token (public); the second must fail with the documented error code.

# 1. Public probe — GET /api/health is on the public-path allowlist
curl -sk -w "\nHTTP %{http_code}\n" \
  "https://<domain>/api/health"
# Expected:
# {"status":"ok","serverTime":"2026-09-18 12:34:56","uptime":42 seconds}
# HTTP 200

# 2. Auth probe — without a token, /api/me returns UNAUTHENTICATED (not UNAUTHORIZED)
curl -sk -w "\nHTTP %{http_code}\n" \
  "https://<domain>/api/me"
# Expected:
# {"error":"TOKEN_INVALID","message":"Missing or malformed Authorization header","details":{}}
# HTTP 401

If the second call returns UNAUTHORIZED instead of TOKEN_INVALID, you are on an old release — the error code was renamed to align with the middleware contract; see Error response format.

How to call these endpoints

Every authenticated endpoint expects a Keycloak-issued Bearer token in the Authorization header. Attach it on every request:

TOKEN="$(curl -sk -X POST \
  "$KEYCLOAK_URL/realms/$KEYCLOAK_REALM/protocol/openid-connect/token" \
  --data-urlencode "grant_type=password" \
  --data-urlencode "client_id=$KEYCLOAK_CLIENT_ID" \
  --data-urlencode "username=$USERNAME" \
  --data-urlencode "password=$PASSWORD" \
  | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])")"

curl -sk -H "Authorization: Bearer $TOKEN" \
  "https://<domain>/api/me" | jq .

Refresh on 401 (any error code in the TOKEN_* family). The BFF rejects UNAUTHENTICATED (missing/invalid token) and TOKEN_EXPIRED (valid but past expiry) as 401; clients should not retry with the same token — request a new one from Keycloak instead.

Conventions

ElementConvention
Base URL${VUE_APP_API_URL} = /api (proxied by Kong / Nginx)
AuthKeycloak JWT in Authorization: Bearer <token> unless Allow public
Content-Typeapplication/json for request and response bodies
Errors{ "message": "<msg>" } for typed errors; { "error": "<CODE>", "message": "<msg>" } for explicit route-level errors (no details field today)
ID formatUUID v4 for new entities, MongoDB ObjectId-compatible string otherwise
TimestampsISO-8601 in UTC

Allowlist — public paths (no auth required)

PathPurpose
GET /api/healthLiveness probe
GET /robots.txtWeb crawler policy
GET /Service banner (lists available endpoints)
GET /sitemap.xmlEmpty sitemap (SPA renders client-side)
GET /api-docsSwagger UI
GET /api-docs.jsonOpenAPI JSON
GET /api-docs/Swagger UI (trailing-slash form)
GET /api/auth/callbackOIDC Authorization-Code callback target
GET /api/auth/logout/callbackOIDC post-logout redirect target
GET /Uploads/*Legacy static serving (Kong-enforced auth)

The Keycloak auth middleware explicitly bypasses JWT verification for these paths (keycloak-auth-middleware.js:10-18). Probes, documentation, and the OIDC redirect flow work without an access token.

Do not assume these paths exist on the BFF. /api/auth/callback and /api/auth/logout/callback are allowlisted for the auth middleware only. The actual handler is normally served by Keycloak itself via Kong’s keycloak-route; the BFF exists as a passthrough so the middleware does not 401 the redirect.

Everything else requires a valid Keycloak JWT.

Note: the runtime frontend configuration (locale whitelist, default locale, etc.) is not served by a backend endpoint. It is injected at container startup into window.APP_CONFIG and read by the SPA bundle — see Restrict active locales.

Error codes

CodeHTTPMeaning
UNAUTHENTICATED401Missing or invalid token
FORBIDDEN403Authenticated but missing required realm role
NOT_FOUND404Resource does not exist
VALIDATION_FAILED422Not emitted. Backend uses ValidationError (HTTP 400) — no dedicated envelope code is sent; either implement 422 or drop the row
RATE_LIMITED429Emitted by Kong, not the backend (Kong enforces 1000/min per consumer on express-api and returns plain text API rate limit exceeded, not the standard JSON envelope)
CHATQNA_STREAM_ERROR502Upstream ChatQnA streaming error (ArangoDB / ChatQnA / Keycloak upstream errors propagate through this code)
INTERNAL500Unhandled exception
TOKEN_INVALID401keycloak-auth-middleware.js:79,157
TOKEN_EXPIRED401keycloak-auth-middleware.js:150
PROVISIONING_FAILED500keycloak-auth-middleware.js:106
INTERNAL_ERROR500keycloak-auth-middleware.js:165
STREAMING_DISABLED501query-routes.js:146
STREAM_ERRORvariesquery-routes.js:389 (generic upstream stream failure)
CITY_NOT_FOUND503services/weather-service.js:199 (no city within lookup radius)
CHATQNA_UNAVAILABLE504query-routes.js:384 (ChatQnA timeout / aborted)
INVALID_FILTERS_JSON400analytics-routes.js:289 (malformed filters query param)
Not Found404Sensitive-path blocker (index.js:566), 404 fallback (index.js:822)

Note: the legacy UNAUTHORIZED code is not emitted. Clients matching on it will silently break — match UNAUTHENTICATED.

Defense in depth

LayerWhat it blocks
NginxDotfiles (.X except .well-known) and VCS dirs (.git, .svn, .hg, CVS, BitKeeper)
KongAdds headers (X-Forwarded-For, etc.); rate limit on express-api service
Helmet (backend)CSP, HSTS, frame-options, X-Content-Type-Options
CORSAllowlist via CORS_ALLOWED_ORIGINS
Rate limitKong rate-limit 1000 / minute per consumer on the express-api service

Defense in depth (sensitive-path blocker)

The BFF returns 404 for any path matching /.<segment> (e.g. /.env, /.git), /.git, /.env, or /BitKeeper (index.js:553-578). The matcher logs a SECURITY: Blocked access to sensitive path warning with the caller IP, method, and User-Agent, then returns:

{ "message": "Not Found" }

This is defense-in-depth — Kong should already be blocking these paths, but the BFF rejects them as well so a misconfigured Kong cannot leak secrets.

Route domains

1. Authentication routes (/api/auth)

Route file: routes/auth-routes.js Mount: /api/auth (Kong named route, auth-route) Auth required: Yes — every endpoint under /api/auth requires a valid Keycloak JWT. The /api/auth/callback paths bypass this check.

Login is not proxied by the BFF. Kong routes the actual /auth/* flow directly to Keycloak (keycloak-route in kong_config.json). The BFF only exposes the application-level logout.

MethodPathAuthDescription
POST/logoutYesLogout — invalidate ArangoDB session, emit audit log, leave OIDC revocation to Keycloak

Logout behaviour (verified in auth-routes.js + authController.logout):

  • The endpoint requires the Bearer token to identify the session to invalidate. A missing/invalid token returns TOKEN_INVALID (401) before any logout work runs.
  • On success, the backend tears down any active ArangoDB session for the user and emits a structured audit log entry.
  • Keycloak-side OIDC revocation is handled by Keycloak itself; the BFF does not call Keycloak’s /logout endpoint. Clients that want to also clear the IdP session should additionally redirect through Keycloak’s end-session endpoint.

2. User profile routes (/api/me)

Route file: routes/user-routes.js Mount: /api/me (Kong user-route) Auth required: Yes on every endpoint.

MethodPathAuthDescription
GET/YesGet current user profile (singleton)
GET/contextYesGet sanitized AI-enrichment context
POST/reset-dataYesReset profile data (clears profile fields, keeps credentials)
POST/deleteYesGDPR right-to-erasure (soft-delete with PII nullification)
PUT/YesUpdate profile + optional avatar upload

GET /api/me — returns the full user document from ArangoDB, populated from the JWT on first hit (JIT provisioning). Subsequent reads come from ArangoDB. Confirmed fields: email, firstName, lastName, createdAt, updatedAt, iss_sub, roles. Other fields (avatarUrl, theme, locale, and custom settings merged back from customSettings at user-profile-service.js:148-162) may be present depending on profile state — request /api-docs.json for the canonical schema.

GET /api/me/context — returns a sanitized subset of the profile for OPEA AI enrichment. Use this endpoint to pass user data to the AI layer: it strips internal IDs and only ships the fields that OPEA is allowed to consume (role claims come fresh from the JWT, never from the cached ArangoDB document).

POST /api/me/reset-data — clears profile fields but preserves email, createdAt, and Keycloak credentials so JIT provisioning can re-bootstrap the user on the next login. This is the right endpoint for a “reset my chat history but keep my account” UX; for full erasure, use /delete.

POST /api/me/delete — irreversible. Deletes the user from Keycloak and nullifies PII in ArangoDB (deleted=true, all PII fields overwritten with null). The user cannot log in again; any cached JWTs will hit the FORBIDDEN path on subsequent requests (req.user.deleted check in keycloak-auth-middleware.js:113).

PUT /api/me — multipart update. The route uses upload.any() (user-routes.js:241) which means it accepts arbitrary multipart fields; in practice the frontend sends:

  • firstName, lastName, email, username → forwarded to Keycloak Account API (JIT fields; see JIT_FORWARD_FIELDS in constants/jit-fields.js)
  • locale, theme, avatarUrl, and any custom fields → written to ArangoDB
  • avatar (image file, recommended) → max 10 MB (multer limit at user-routes.js:38)

The cap is set in code, not by an env var; raising it requires a backend code change.

3. Query routes (/api/queries, /api/query)

Route file: routes/query-routes.js Mount: /api/queries (Kong query-route), /api/queries/stream (Kong query-stream-route). /api/query is a legacy alias — both mount points load the same router; clients should use /api/queries. Auth required: Yes on every endpoint.

MethodPathAuthDescription
POST/streamYesStream RAG response over SSE — see SSE Streaming Protocol
POST/YesCreate new query (single-message mode)
GET/YesList queries (paginated, current user only)
GET/:queryIdYesGet a single query (current user or admin)
PATCH/:queryId/responsetimeYesUpdate response-time metric — called by SSE consumer
POST/:queryId/feedbackYesSubmit user feedback (1–5 rating, optional comment)
PATCH/:queryId/answeredYesMark query as seen by user (stamps responseTime)
GET/:queryId/conversationsYesList conversations linked to this query
POST/:queryId/conversationYesPromote a query to a new conversation
POST/:queryId/link/:messageIdYesRe-link a query to a chat message (after a failed link)

POST /api/queries has two semantic modes selected by request body shape:

  • Single-message ({ question, context }) — one user turn, no conversation linkage, no link to a chat message.
  • Conversation-with-context-labels ({ question, conversationId, messageId, context }) — appends to an existing conversation, records context labels for retrieval blending (issue #833 multi-turn vector-space blending).

The analytics emission is identical in both modes; the conversationId controls whether the query shows up under “today’s chats” or as an orphan turn.

Requested language: the language the answer should be written in travels in the request body (context.language), so it is caller-controlled. It is resolved against the language map the loaded translation backend ships. A language the map does not know is refused — 400 with { "error": "UNSUPPORTED_LANGUAGE", "message": "Unsupported target language" } — before anything is generated, so an answer is never produced in a language the caller did not ask for. The message names the refused side only: the caller’s value is never echoed back into the response or into the logs. A translation that fails for another reason (the translator is down, the call times out) is not a refusal: the request goes through, the answer is delivered in English, and the stream reports the failure on the done frame — see SSE Streaming Protocol.

The value is normalized before it is resolved — trimmed, bounded, and lower-cased — so "FR" and "fr" name the same language, and a value padded with spaces is not a value the map cannot find.

"auto" is not a language: it asks the service to detect the language the conversation is written in, which the chatqna service does on its own. It is accepted, never refused, and it names no translation target. English ("en") is the language the answer is written in, so it is accepted too, and names no target either. A request that names no language at all is treated exactly like "auto".

What the answer looks like then depends on which path served the request, because the two paths translate in different places:

  • Streaming (POST /api/queries/stream) — the backend translates the answer. No target named means no translation: the answer arrives as the model wrote it, and the done frame carries translationFailed: false.
  • Non-streaming (POST /api/queries) — the chatqna service translates the answer. It detects the language of the conversation, and when the detected code is one the language map covers — and is not English — the answer is translated into it and the metadata carries translation_status: "ok". When the detection fails, or names a code the map does not cover, the answer stays in English and the status key is absent.

On the non-streaming path (POST /api/queries) the answer’s metadata carries the outcome under translation_status: ok when the answer was translated, refused when the requested language was refused, failed when the translation broke. The key is absent when nothing was translated (the request named English, or named no language and nothing was detected that the deployment can answer in). refused and failed both leave the English answer in place, so a client has to be able to tell an untranslated answer from a translated one — the status is that signal.

GET /api/queries is paginated, scoped to the JWT user. Query params:

ParamTypeDefaultNotes
sessionIdstring—Filter by chat session
textstring—Full-text search across question/response
categoryIdstring—Filter by category
serviceIdstring—Filter by service
isAnsweredboolean—true/false
startDate / endDateISO date—Date range on createdAt
limitint20Max 100, min 1
offsetint0Min 0

POST /api/queries/:queryId/feedback body:

{
  "rating": 4,
  "comment": "Helpful"
}

Contributes to /api/analytics/satisfaction/* aggregates.

PATCH /api/queries/:queryId/responsetime body: { "responseTime": 1234 } (milliseconds). Called by the SSE consumer on stream completion to backfill the timing metric — the metadata SSE event already carries it in-band, so this endpoint is the fallback when the metadata event was missed.

PATCH /api/queries/:queryId/answered is idempotent: each call stamps responseTime to “now” if the query was not already answered. Multiple calls do not double-stamp.

GET /api/queries/:queryId/conversations — a query can be promoted to multiple conversations (e.g. user forks the same answer into two threads). Returns an array (possibly empty); never returns 404 for an existing query with zero linked conversations.

POST /api/queries/:queryId/conversation — promotes a query into a new conversation (the query becomes the first message). Body:

{
  "title": "Optional conversation title",
  "responseText": "Optional override of the cached response",
  "tags": ["optional", "tags"]
}

POST /api/queries/:queryId/link/:messageId — re-attaches a query to an existing chat message after the auto-link step at SSE completion failed. The optional body may carry responseType and confidenceScore overrides.

4. Chat history routes (/api/chat)

Route file: routes/chat-history-routes.js Mount: /api/chat (Kong chat-history-route) — note that the router is also mounted at /api/chat-history in index.js:476 but Kong only exposes /api/chat (kong_config.json:376). /api/chat-history is reachable only via the catch-all /api route. Auth required: Yes on every endpoint.

MethodPathAuthDescription
GET/conversationsYesList user conversations (paginated)
GET/conversations/:conversationIdYesConversation + messages
POST/conversationsYesCreate conversation
PATCH/conversations/:conversationIdYesUpdate title/metadata
DELETE/conversations/:conversationIdYesSoft-delete conversation
GET/conversations/:conversationId/messagesYesMessages, paginated
POST/conversations/:conversationId/messagesYesAppend message
POST/conversations/:conversationId/messages/readYesMark messages as read
GET/query/:queryId/messagesYesReverse lookup — messages for a query
GET/messages/:messageId/queryYesReverse lookup — query for a message
POST/query/:queryId/conversationYesPromote query to conversation (alias of /api/queries/:queryId/conversation)
GET/searchYesFull-text search across conversations
GET/recentYesLast-N conversations (sidebar)
GET/statsYesPer-user conversation statistics
GET/foldersYesList folders
POST/foldersYesCreate folder
GET/folders/:folderIdYesFolder details
PATCH/folders/:folderIdYesRename / update folder
DELETE/folders/:folderIdYesSoft-delete folder, cascade conversations
GET/folders/searchYesSearch folders
POST/folders/reorderYesCustom sort order
GET/folders/:folderId/pathYesBreadcrumb path
POST/folders/:folderId/conversations/:conversationIdYesAdd conversation to folder
DELETE/folders/:folderId/conversations/:conversationIdYesRemove conversation from folder
GET/conversations/:conversationId/folderYesGet folder for conversation
POST/conversations/:conversationId/moveYesMove conversation to another folder

POST /folders/reorder body:

{
  "orderedFolderIds": ["folder_3", "folder_1", "folder_2"]
}

POST /conversations body:

{
  "title": "My conversation",
  "messages": [{ "role": "user", "content": "Hello" }],
  "folderId": "optional-folder-id",
  "tags": ["optional"]
}

DELETE /folders/:folderId performs a cascade soft-delete on every conversation in it (the conversations are not hard-deleted — they survive a folder-recreate). See Deletion semantics.

POST /conversations/:conversationId/messages body:

{
  "role": "user",
  "content": "Message text",
  "queryId": "optional",
  "metadata": { "optional": true }
}

5. Analytics routes (/api/analytics)

Route file: routes/analytics-routes.js Mount: /api/analytics (Kong analytics-route) Auth required: Yes on every endpoint.

MethodPathAuthDescription
GET/dashboardYesAggregate dashboard metrics
GET/metric/:metricYesSingle metric (generic)
GET/YesHigh-level overview
GET/timeseries/:metricTypeYesTime-bucketed series
POST/eventsYesRecord a custom analytics event
GET/recordsYesRaw event records
GET/eventsYesFiltered event list
GET/satisfaction/gaugeYesSatisfaction gauge (1–5)
GET/satisfaction/heatmapYesSatisfaction by category

GET /events supports the same startDate/endDate/limit/offset pagination as /api/queries (default limit=20, max 100).

POST /events body:

{
  "eventType": "pageView",
  "eventData": { "path": "/chat", "durationMs": 1234 }
}

6. Admin routes (/api/admin)

Route file: routes/admin-routes.js Mount: /api/admin (Kong admin-route) Auth required: Yes + realm role admin (lowercase). The router-level requireAdmin is applied at admin-routes.js:44-45.

MethodPathAuthDescription
GET/system-healthAdminDeep system health probe (DB, OPEA, Redis, etc.)
GET/database/statsAdminArangoDB collection statistics
GET/logsAdminApplication log retrieval
POST/logs/rolloverAdminTrigger log rollover (legacy — see note below)
GET/user-statsAdminUser statistics
GET/security-metricsAdminSecurity event metrics
POST/security-scanAdminTrigger security scan
GET/security/last-scanAdminLast scan result
POST/diagnosticsAdminRun a diagnostics bundle
GET/logs/summaryAdminAggregated log stats
GET/logs/searchAdminLog search
GET/logs/debug-yesterdayAdminYesterday’s debug logs
POST/database-operations/backupAdminTrigger backup — also reachable at /api/database/backup
POST/database-operations/optimizeAdminOptimize DB — also reachable at /api/database/optimize
GET/users/searchAdminSearch users
GET/queries/inspectAdminList all queries (admin view, cross-user)
GET/queries/inspect/:queryIdAdminInspect a query

Two separate implementations share these names — they are NOT aliases. /api/admin/database-operations/backup and /api/admin/database-operations/optimize are handled by adminService.backupDatabase() / adminService.optimizeDatabase() (services/admin-dashboard-service.js:601,656), which export every collection as a single JSON file. The /api/database/backup and /api/database/optimize routes are handled by databaseService.backupDatabase() / databaseService.optimizeDatabase() (services/database-operations-service.js:59,239), which stream JSON/JSONL to BACKUP_DIR with metadata. Use the admin-prefixed paths in client code (they require an admin role; /api/database/* is authenticated-only).

Log rollover note: POST /api/admin/logs/rollover returns 200 for browser callers and 410 Gone for cron UAs (cron, curl, wget, httpie, python-requests, python-urllib, go-http-client — see admin-routes.js:200-217). The endpoint is a no-op: logs are written directly to VictoriaLogs and there is nothing to rotate. It remains for backward-compat with older scripts.

7. Service category routes (/api/service-categories)

Route file: routes/service-category-routes.js Mount: /api/service-categories (Kong service-category-route) Auth required: Yes on every endpoint.

MethodPathAuthDescription
GET/categoriesYesList service categories (hierarchical)
GET/categories/detailedYesCategories with translations + services
GET/categories/:categoryIdYesSingle category
GET/:categoryId/translationsYesTranslations for one category
GET/services/:serviceId/translationsYesTranslations for one service
GET/searchYesFull-text search
POST/YesCreate category
DELETE/:categoryIdYesSoft-delete category
DELETE/services/:serviceIdYesSoft-delete service
POST/initYesInitialize default categories (idempotent)
POST/:categoryId/servicesYesAdd service to category
PUT/:categoryIdYesUpdate category metadata
PUT/services/:serviceIdYesUpdate service metadata

Translation routes (GET /:categoryId/translations, GET /services/:serviceId/translations) return translation records keyed ${sourceKey}_${languageCode} (e.g. name_en, name_fr). The translation schema is documented in the i18n section of CHANGELOG.md and on the frontend inventory page (see UI Component Inventory).

8. Service routes (/api/services)

Route file: routes/service-routes.js Mount: /api/services (Kong service-route) Auth required: Yes.

MethodPathAuthDescription
GET/categoriesYesFlat list of all services
GET/categories/:categoryIdYesServices under one category
GET/searchYesFull-text search across services

9. Translation routes (/api/translate)

Route file: routes/translation-routes.js Mount: /api/translate (catch-all /api route only — no dedicated Kong route). Auth required: Yes.

MethodPathAuthDescription
POST/YesTranslate an array of text strings
POST/markdownYesTranslate markdown, preserve structure

POST /api/translate body:

{
  "texts": ["Hello world", "Good morning"],
  "source_lang": "en",
  "target_lang": "fr"
}

Response:

{ "translated_texts": ["Bonjour le monde", "Bonjour"] }

POST /api/translate/markdown body:

{
  "markdown": "# Title\n\nHello **world**.",
  "source_lang": "en",
  "target_lang": "fr"
}

Uses AST-based translation: only text leaf nodes are sent to the translator; markdown structure (headings, lists, links) is preserved by the skeleton.

Refused language: both languages travel in the request body, so both are checked against the language map the loaded translation backend ships. A language the map does not know is refused — it is never passed to the model, and never guessed.

400 response (same for both routes):

{ "error": "UNSUPPORTED_LANGUAGE", "message": "Unsupported target language" }

"Unsupported source language" when the source is the one refused. The message names the refused side only: the caller’s value is never echoed back into the response or into the logs. The code tells the client the request was refused — not that it failed — without it having to read the message.

Streaming translation is a separate, optional path: set STREAMING_TRANSLATION_ENABLED=1 to translate chat-stream units as they arrive (issue #829). See SSE Streaming Protocol.

10. Weather routes (/api/weather)

Route file: routes/weather-routes.js Mount: /api/weather (catch-all /api route). Auth required: Yes.

MethodPathAuthDescription
POST/YesFetch weather data

Body: { "latitude": <number>, "longitude": <number> } Response: {"location": "<City, Country>", "current": {"temperature": ..., "condition": "...", "humidity": ..., "windSpeed": ...}, "forecast": [{"date": "...", "condition": "...", "highTemp": ..., "lowTemp": ...}, ...]}. Errors with CITY_NOT_FOUND (HTTP 503) when reverse-geocoding finds no city within the lookup radius.

Note — The weather route is a legacy/integration endpoint; the chat pipeline does not call it. Keep it in the public surface only if an upstream client still depends on it.

11. Database operations routes (/api/database)

Route file: routes/database-operations-routes.js Mount: /api/database (Kong database-operations-route) Auth required: Yes — authenticated users only, not admin-restricted (verified at database-operations-routes.js:8: only authenticate, no requireAdmin). In practice these endpoints are still mostly invoked by admins, but a non-admin caller with a valid token will reach the handler.

MethodPathAuthDescription
POST/backupYes (any user)Trigger database backup
POST/optimizeYes (any user)Optimize database

These are not aliases of the /api/admin/database-operations/* paths — they call a different service implementation. See Section 6 for the full breakdown: /api/database/* uses databaseService (streamed JSON/JSONL to BACKUP_DIR), while /api/admin/database-operations/* uses adminService (single in-memory JSON file per collection). Use the admin-prefixed variants in client code (they require an admin role; /api/database/* is authenticated-only).

13. Document repository API (/api/files, /api/labels)

The document-repository is a sibling service, not part of the BFF. It runs in components/document-repository/, exposes its own routes, and authenticates with its own middleware (authorizeRole rather than keycloakAuthMiddleware).

Quick reference:

PathMountRolesNotes
POST /api/files/uploadAdminAdminSingle file
POST /api/files/uploadsAdminAdminMultiple files
POST /api/files/upload-linkAdminAdminURL-based upload
POST /api/files/crawl/scheduleAdminAdminWeb crawl
GET /api/files, /search, /search/files, /:fileIdAny user—List/search
GET /api/files/:fileId/crawl-*, POST .../kill-crawl, POST .../kill-ingestAdminAdminCrawl ops
GET /api/files/:fileId/view, /viewbrowser, /download, POST /api/files/downloadsAny user—Read
DELETE /api/files/:fileId, DELETE /api/files/AdminAdminDelete
PATCH /api/files/:fileIdAdminAdminUpdate metadata
POST /api/files/:fileId/ingest, /retract, POST /api/files/ingest, /retractAdminAdminRAG ingest/retract
POST /api/files/:fileId/ingestion-logAdmin, dataprep-serviceAdminLog ingestion event
GET /api/files/:fileId/ingestion-logAdmin, dataprep-serviceAdminGet logs
PATCH /api/files/:fileId/statusAdmin, dataprep-serviceAdminUpdate status
/api/labels/*AdminAdminLabel CRUD

File upload limits (set in components/document-repository/src/config/appConfig.js):

Env varDefaultNotes
MAX_FILE_SIZE52428800 (50 MB)Per-file cap
MAX_FILES_UPLOAD10Per-request cap
UPLOAD_DIR./uploadsStorage dir

Allowed types: application/pdf, application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.document, application/vnd.ms-excel, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, text/markdown, text/plain, text/html, application/zip, application/x-zip-compressed. Extensions: .pdf, .docx, .xlsx, .md, .html, .txt. ClamAV scans all uploads when VIRUS_SCANNING=true; failed scans return 400 with the rejection reason.

Authorization model for write ops: all write paths require authorizeRole(['Admin']). The three ingestion-log/status endpoints additionally accept the dataprep-service role — that role exists for the dataprep service-account to call back without needing an Admin JWT.

SSE streaming protocol

Endpoint: POST /api/queries/stream (also aliased as /api/queries/stream via Kong query-stream-route).

Request headers

Content-Type: application/json
Authorization: Bearer <token>

Request body

{
  "question": "What does the policy say about X?",
  "context": {
    "language": "en",
    "conversationId": "optional-existing-conversation",
    "messageId": "optional-message-to-link",
    "labels": ["optional", "labels"]
  }
}

Response headers

Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
X-Accel-Buffering: no

Event types

The stream emits one or more of the following event types. Field types are stable; renaming a field is a breaking change.

TypeFieldsNotes
chunkcontent: stringOne streaming chunk (an English text unit by default)
metadataqueryId: string, responseTime: number, source_documents?: array, confidence_score?: number, is_grounded?: boolean, retrieval_confidence_score?: number | null, self_confidence?: number | null, abstained?: boolean, abstention_reason?: string | nullCaptured from chatqna’s in-stream metadata, forwarded to client (no separate backend retrieval)
translationcontent: stringEmitted by /api/queries/stream on the post-stream translation path (STREAMING_TRANSLATION_ENABLED=0, the English-then-flip path). The standalone /api/translate endpoints answer with JSON, not with stream events
chunkcontent: string (translated)Emitted by streaming-translation path — see below
errormessage: string, code: stringCloses the stream after emission
donequeryId: string, translationFailed: booleanAlways last; closes the stream. translationFailed is true when a translation was attempted and failed, and the client is showing the English original. A language the translation models do not cover is refused before the stream opens, so it never reaches this frame

Example stream (English-then-flip path):

data: {"type":"metadata","queryId":"abc123","responseTime":0,"source_documents":[...],"confidence_score":0.87,"is_grounded":true}

data: {"type":"chunk","content":"The policy states that "}

data: {"type":"chunk","content":"applicants must..."}

data: {"type":"translation","content":"La politique indique que les candidats doivent..."}

data: {"type":"done","queryId":"abc123","translationFailed":false}

Keepalive comment lines (: keepalive) are emitted on a fixed interval so intermediate proxies do not time out the connection.

Metadata fields

confidence_score is the citizen-facing value: the LLM self-grade when LLM_SELF_CONFIDENCE_ENABLED=1, otherwise the retrieval confidence. The remaining fields are diagnostics for the admin Query Inspector and post-hoc evaluation — a chat client should render confidence_score only.

FieldMeaning
retrieval_confidence_scoreRaw rank-weighted retrieval confidence, always present. null when chatqna omits it.
self_confidenceLLM self-grade. Keyed by presence: absent when LLM_SELF_CONFIDENCE_ENABLED=0, null when the feature is on but the [[CONF:…]] sentinel was missing or malformed. Consumers must test the key, not the value, to tell “disabled” from “no sentinel”.
abstainedtrue when chatqna withheld a real answer. Defaults to false.
abstention_reasonMachine-readable cause. On this event, reranker_input_too_long — the reranker refused an oversized input. (translation_input_too_long is a different transport: it surfaces as an HTTP 422, not as a metadata event.) Defaults to null.

Streaming translation (issue #829)

Set STREAMING_TRANSLATION_ENABLED=1 to translate chat-stream units as they arrive instead of buffering the full English response and flipping at the end. When enabled:

  • The backend buffers English chunks unit-by-unit (sentence / paragraph boundaries — see services/translation/stream-boundary.js).
  • Each complete unit is translated via translationService.translateMarkdown (AST-preserving: only text leaf nodes reach the model).
  • Translated chunks are emitted as data: {"type":"chunk","content":"..."} events. The translation event type is NOT emitted on this path.
  • If translation fails for a unit, the unit is sent in English (degraded path) and a warning is logged. The stream continues.
  • Default STREAMING_TRANSLATION_ENABLED=0 (English-then-flip) for back-compat.

The flag is checked in routes/query-routes.js:208-213 and is a string comparison — only '1' or 'true' enable it.

Environment control

SSE is enabled by default — any value of OPEA_STREAMING other than the literal string 'false' enables it. Disable explicitly:

OPEA_STREAMING=false   # POST /api/queries/stream returns 501 STREAMING_DISABLED

The 501 response body:

{
  "error": "STREAMING_DISABLED",
  "message": "SSE streaming is disabled. Set OPEA_STREAMING=true to enable."
}

Failure modes (SSE)

SymptomLikely cause
HTTP 501 STREAMING_DISABLEDOPEA_STREAMING=false
HTTP 401 UNAUTHENTICATEDBearer token missing or invalid
HTTP 400 UNSUPPORTED_LANGUAGEThe requested language is not one the deployment can serve. Refused before the stream opens — nothing was generated
Stream starts then stops with CHATQNA_STREAM_ERRORUpstream OPEA ChatQnA failure (timeouts, refused)
Stream starts then stops with STREAM_ERRORGeneric upstream stream error
Answer arrives in English, done carries translationFailed: trueThe translation backend did not answer. Not a stream failure — the answer is complete, only its language is not
Answer arrives in English, done carries translationFailed: falseNo translation was asked for: the request named English, named "auto", or named no language at all. Nothing failed
Stream silently hangs and times outUpstream ChatQnA unresponsive; check CHATQNA_STREAM_TIMEOUT (default 3600000 ms = 60 min)

Static & operational endpoints

These endpoints live in components/gov-chat-backend/index.js. They are public (on the auth allowlist) and have no business logic.

GET /api/health

{ "status": "ok", "serverTime": "2026-09-18 12:34:56", "uptime": "42 seconds" }

Always returns 200 unless the process itself is broken. Use this for Docker Swarm/Kubernetes/Ansible liveness probes; it is a constant-time handler (no DB calls). See Operate → Health checks for the runtime contract.

GET /

{
  "message": "Welcome to the Government Services API",
  "apiDocumentation": "/api-docs",
  "availableEndpoints": [
    "/api/me", "/api/queries", "/api/services",
    "/api/service-categories", "/api/chat-history", "/api/chat",
    "/api/analytics", "/api/auth",
    "/api/database", "/api/admin", "/api/weather", "/api/translate"
  ]
}

Useful as a one-line smoke test after a deploy.

GET /robots.txt

User-agent: *
Disallow: /api/
Disallow: /Uploads/

Blocks crawlers from /api/ (no public content) and /Uploads/ (private files).

GET /sitemap.xml

Empty <urlset/> placeholder. The frontend is a single-page app; populating this requires server-side rendering that the project does not currently do.

GET /Uploads/*

Serves files from UPLOAD_DIR (default ./Uploads). The BFF does not enforce auth here — Kong is expected to block this path for unauthenticated callers (UPLOAD_DIR is auth-gated at the gateway). Most files now live in document-repository; /Uploads is a legacy path.

Middleware reference

keycloakAuthMiddleware.authenticate

Location: components/gov-chat-backend/middleware/keycloak-auth-middleware.js Use: validates the Bearer token, JIT-provisions (or refreshes) the ArangoDB user record, blocks soft-deleted users, attaches req.user and req.claims for downstream handlers.

Applied: at router level (router.use(authenticate)) for the analytics, chat-history, database-operations, query, service, service-category, translation, and weather routes; per-route for the /api/me/* and /api/auth/logout routes.

Public allowlist: see Allowlist — public paths.

Failure modes:

StatusCodeCause
401TOKEN_INVALIDHeader missing or malformed
401TOKEN_EXPIREDToken past expiry (also marks user deleted in ArangoDB if Keycloak reports disabled)
500PROVISIONING_FAILEDJIT provisioning failed
500INTERNAL_ERRORUnexpected middleware error
403FORBIDDENSoft-deleted user (req.user.deleted === true)

keycloakAuthMiddleware.requireAdmin

Location: same file, lines 178-194. Use: rejects callers whose JWT realm_access.roles does not include the literal string admin (lowercase). Roles are read fresh from req.claims.realm_access.roles (set on every request from the verified JWT), never from the cached ArangoDB user document.

Failure mode: 403 FORBIDDEN with message: 'Admin access required'.

authorizeRole(['Admin']) (document-repository only)

Location: components/document-repository/src/middlewares/keycloak-auth-middleware.js:168 Use: equivalent gate on the document-repository side. The middleware reads req.user.role (singular, normalized to Admin at lines 57-60) and compares case-insensitively against the allowed-roles list at line 179, so functionally it accepts the same admin role as the BFF — only the spelling in route definitions differs. No runtime mismatch.

Request body limits

The BFF body parser (index.js:640-641) accepts up to 50 MB JSON / urlencoded bodies. Multipart on /api/me is capped at 10 MB (user-routes.js:38). Multipart on /api/files/* is capped at 50 MB per file by default (MAX_FILE_SIZE).

Pagination & filters

List endpoints (/api/queries, /api/chat/conversations, /api/chat/messages, /api/analytics/events, /api/admin/users/search) share a common pagination contract:

Query paramTypeDefaultNotes
limitint20Page size, min 1, max 100
offsetint0Min 0
startDate / endDateISO date—Inclusive range on createdAt
sortstringcreatedAt DESCField + space + direction
Free filter fieldsper endpoint—See endpoint table

There is no cursor parameter — pagination is offset-based. For very large result sets, narrow the startDate/endDate window rather than paging deep.

Deletion semantics

The BFF uses soft-delete throughout. There are three flavours; check the table when in doubt.

EndpointBehavior
POST /api/me/deleteSoft-delete with PII nullification — Keycloak user is deleted, ArangoDB user is marked deleted=true and every PII field is overwritten with null. Irreversible. Subsequent requests with cached tokens return FORBIDDEN.
POST /api/me/reset-dataReset profile data — clears custom profile fields but preserves email, createdAt, and Keycloak credentials so JIT re-bootstraps on next login. Reversible (re-login recreates).
DELETE /api/chat/conversations/:idSoft-delete conversation — sets deleted=true on the conversation; messages remain in ArangoDB but are filtered out by reads.
DELETE /api/chat/folders/:folderIdCascade soft-delete — sets deleted=true on the folder AND every conversation in it. Conversations are not hard-deleted; restoring the folder restores membership.
DELETE /api/files/:fileIdSoft-delete file — sets deleted=true on the file document; the underlying upload stays on disk until the periodic GC sweep.
DELETE /api/service-categories/:categoryId and /services/:serviceIdSoft-delete — service-category tree keeps the node but reads filter it out.
DELETE /api/labels/:labelIdSoft-delete — the label and any descendants are marked deleted; /api/labels/:labelId/with-children cascades the soft-delete explicitly.

There is no hard-delete endpoint on the BFF or document-repository. GDPR right-to-erasure is satisfied by POST /api/me/delete.

Error response format

JSON errors (most endpoints)

{
  "error": "ERROR_CODE",
  "message": "Human-readable error message",
  "details": { "field": "context" }
}

Some legacy endpoints return { message } only

A few older handlers return { "message": "..." } without an error code (e.g. user-routes.js:76, chat-history-routes.js fall-throughs, the sensitive-path blocker, the 404 fallback at index.js:822). Clients should treat HTTP status as the primary signal and parse either field.

The full error-code table is in Error codes above.

Rate limiting

Rate limiting is enforced by Kong’s rate-limiting plugin (api-gateway-solution/new-config/kong_config.json:427-446 is the chat-history-route plugin on the /api/chat route; :757-777 is the service-level plugin on express-api). The admin-route (/api/admin) has no route-level plugin — only the express-api service-level rate-limiting applies to it.

Default policy (identical on both plugins):

WindowLimitPolicyScope
per minute1000localper Kong consumer (not per IP)
per hour10000localper Kong consumer

Scope is per-consumer, not per-user, per-role, or per-IP. There is no tiered “admin bypass” — every Kong-authenticated consumer hits the same limit. If you need a higher quota, create a dedicated Kong consumer with overrides.

429 response:

{ "message": "API rate limit exceeded" }

HTTP status: 429. Kong does not emit a Retry-After header by default (hide_client_headers: false); clients should back off with exponential delay and jitter, starting at 1 s.

Per-route overrides: the document-repository has its own rate-limit settings — check the Kong config for the document-repository service block before assuming the global limits apply.

OpenAPI / Swagger documentation

Interactive API documentation is served by the BFF itself (not by Kong):

URLPurpose
http://localhost:3000/api-docsLocal dev Swagger UI
https://<domain>/api-docsProduction Swagger UI (Keycloak OAuth2 + PKCE baked in via swagger-ui config)
http://localhost:3000/api-docs.jsonLocal OpenAPI 3.0 JSON
https://<domain>/api-docs.jsonProduction OpenAPI JSON

Swagger definitions are inline in route files using JSDoc comments (@swagger, @summary, @tags, etc.) — swagger-jsdoc reads ./routes/*.js at module-load time and assembles the spec. To add a new endpoint to the spec, write the @swagger block above the route definition; no separate spec file to edit.

CORS configuration

CORS is configured in the backend (components/gov-chat-backend/index.js:444-446), not Kong — the Kong cors plugin entries (kong_config.json:394, 779) are both enabled: false.

SettingValue
Allowed originsCORS_ALLOWED_ORIGINS (comma-separated; each entry is matched exactly, or as a /pattern/ regex if wrapped in slashes)
Allowed methodsGET, POST, PUT, PATCH, DELETE, OPTIONS
Allowed headersContent-Type, Authorization, X-Requested-With
Credentialstrue
OPTIONS success status204

Missing origin is allowed (e.g. server-to-server calls with no Origin header). Set CORS_ALLOWED_ORIGINS to the production frontend origin (and any staging origins) before going live; an empty allowlist silently accepts no browser callers.

Observability overhead gates

The BFF applies a per-request metrics middleware that records HTTP counters and a duration histogram. It can be turned off for benchmarking or ultra-lean stacks:

Env varDefaultEffect
ENABLE_METRICStrueWhen false, the metrics middleware is a no-op (metrics-middleware.js:26)
ENABLE_OBSERVABILITYfalse (compose default)Enables the broader OTel Collector / Victoria* stack

ENABLE_METRICS=false is the right knob for a load test where every µs counts; the broader stack still works through OTEL_EXPORTER_OTLP_ENDPOINT.

Health check

GET /api/health returns { "status": "ok", "serverTime": "<iso>", "uptime": "<n> seconds" } when the process is alive and dependencies are reachable. See Operate → Health checks for the runtime contract.