This document describes the HTTP API exposed by the application using Next.js Route Handlers in the App Router (app/api/*/route.js).
All routes below are served under the /api prefix.
Frontend developers: For practical usage examples and the small
apiClienthelper layer, readdocs/frontend-api-usage.md. That document is the frontend-oriented single source of truth.
The API expects a valid server configuration.
Required environment variables:
MONGODB_URI — database connection string (used by lib/db.js).SESSION_SECRET — JWT signing secret for session cookies.NAS_ROOT_PATH — NAS mount root for storage operations.Optional environment variables:
SESSION_COOKIE_SECURE — override for the cookie Secure flag (true/false).The Search API can run with different provider backends.
SEARCH_PROVIDER (optional)
fs | qsirchfsNotes:
fs is a local/test fallback that traverses the NAS-like folder structure directly.qsirch is the intended production provider (indexed search on QNAP).If SEARCH_PROVIDER=qsirch, these variables are required:
QSIRCH_BASE_URL — base URL of the Qsirch service (must be reachable from inside the app container).
http://192.168.0.22:8080QSIRCH_ACCOUNT — QTS/Qsirch account used for server-to-server search.
QSIRCH_PASSWORD — password for the account.
QSIRCH_PATH_PREFIX — path prefix that contains the branch folders.
/NiederlassungenOptional Qsirch tuning:
QSIRCH_DATE_FIELD
modified | created (case-insensitive)modifiedQSIRCH_MODE
sync | async | auto (case-insensitive)syncNotes:
auto currently behaves like sync (placeholder for a later async implementation).Normalization rule (A.1)
lib/config/validateEnv.js accepts these values case-insensitively.QSIRCH_DATE_FIELD / QSIRCH_MODE, and trim for QSIRCH_PATH_PREFIX) so behavior matches validation.The environment can be validated via:
lib/config/validateEnv.jsscripts/validate-env.mjsIn Docker/production-like runs, execute node scripts/validate-env.mjs before starting the server to fail fast.
Note: You may see a Node warning like
MODULE_TYPELESS_PACKAGE_JSONwhen running env validation. The validation still succeeds; the warning is harmless.
Authentication uses a signed JWT stored in an HTTP-only cookie (auth_session).
To access protected endpoints:
POST /api/auth/login to obtain the cookie.Notes:
Secure and the app should run behind HTTPS.http://localhost:3000), you may set SESSION_COOKIE_SECURE=false in your local docker env file.RBAC is enforced on filesystem-related endpoints.
Most endpoints return JSON.
Success responses keep their existing shapes (unchanged).
Error responses always use this standardized shape:
{
"error": {
"message": "Human readable message",
"code": "SOME_MACHINE_CODE",
"details": {}
}
}
Notes:
error.message is intended for humans (UI, logs).error.code is a stable machine-readable identifier (frontend handling, tests, monitoring).error.details is optional. When present, it must be a JSON object (e.g. validation info).Binary endpoints
Some endpoints may return a non-JSON body on the 200 happy path (for example application/pdf).
Rules for such endpoints:
The API uses the following status codes consistently:
400 — invalid/missing parameters, validation errors401 — unauthenticated (missing/invalid session) or invalid credentials403 — authenticated but not allowed (RBAC / branch mismatch)404 — resource not found (branch/year/month/day/file does not exist)500 — unexpected server errors (internal failures)The API uses these machine-readable codes (non-exhaustive list):
Auth:
AUTH_UNAUTHENTICATEDAUTH_INVALID_CREDENTIALSAUTH_FORBIDDEN_BRANCHValidation (generic):
VALIDATION_MISSING_PARAMVALIDATION_MISSING_QUERYVALIDATION_INVALID_JSONVALIDATION_INVALID_BODYVALIDATION_MISSING_FIELDValidation (password management):
VALIDATION_WEAK_PASSWORDValidation (filesystem route params):
VALIDATION_BRANCHVALIDATION_YEARVALIDATION_MONTHVALIDATION_DAYVALIDATION_FILENAMEVALIDATION_FILE_EXTENSIONVALIDATION_PATH_TRAVERSALValidation (search):
VALIDATION_SEARCH_SCOPEVALIDATION_SEARCH_BRANCHVALIDATION_SEARCH_BRANCHESVALIDATION_SEARCH_DATEVALIDATION_SEARCH_RANGEVALIDATION_SEARCH_LIMITVALIDATION_SEARCH_CURSORVALIDATION_SEARCH_MISSING_FILTERStorage:
FS_NOT_FOUNDFS_STORAGE_ERRORSearch (backend/provider):
SEARCH_BACKEND_UNAVAILABLE (provider misconfiguration/unavailability)Internal:
INTERNAL_SERVER_ERRORRoute handlers use shared helpers:
lib/api/errors.js (standard error payloads + withErrorHandling)lib/api/storageErrors.js (maps filesystem errors like ENOENT to 404 vs 500)Secure cookie behavior, prefer HTTPS.http://localhost, many tools/browsers treat localhost as a special-case “secure context”. Behavior may vary between environments.This project reads delivery-note PDFs from a NAS mount. New scans can appear at any time.
The caching strategy is designed to:
All JSON API responses explicitly disable HTTP caching:
Cache-Control: no-storeThis is applied centrally by lib/api/errors.js (the json() / jsonError() helpers). The policy applies to both success and error responses.
For binary endpoints (e.g. PDF streaming), the route handler must explicitly set:
Cache-Control: no-storeAll API route handlers are forced to execute dynamically at request time:
export const dynamic = "force-dynamic";This avoids any static/ISR-like behavior for NAS-dependent endpoints and keeps auth/RBAC behavior safe.
To reduce repeated fs.readdir() calls, lib/storage.js implements a small process-local TTL cache after RBAC is enforced (RBAC is checked in the route handlers).
TTL defaults:
listBranches() / listYears() → 60 secondslistMonths() / listDays() / listFiles() → 15 secondsImportant:
Frontend code should call these endpoints with explicit “fresh data” settings:
Use credentials:
fetch(url, { credentials: "include", cache: "no-store" });
Do not rely on next: { revalidate: ... } for these endpoints. Freshness is controlled via:
Cache-Control: no-store (HTTP)GET /api/healthPurpose
Health check endpoint:
db.command({ ping: 1 })).NAS_ROOT_PATH.Authentication: not required.
Response 200 (example)
{
"db": "ok",
"nas": {
"path": "/mnt/niederlassungen",
"entriesSample": ["@Recently-Snapshot", "NL01", "NL02"]
}
}
POST /api/auth/loginPurpose
Authenticate a user and set the session cookie.
Authentication: not required.
Request body (JSON)
{ "username": "example.user", "password": "plain-text-password" }
Responses
200 { "ok": true }
400 (invalid JSON/body)
{
"error": {
"message": "Invalid request body",
"code": "VALIDATION_INVALID_JSON"
}
}
400 (missing username/password)
{
"error": {
"message": "Missing username or password",
"code": "VALIDATION_MISSING_FIELD",
"details": { "fields": ["username", "password"] }
}
}
401 (invalid credentials)
{
"error": {
"message": "Invalid credentials",
"code": "AUTH_INVALID_CREDENTIALS"
}
}
500
{
"error": {
"message": "Internal server error",
"code": "INTERNAL_SERVER_ERROR"
}
}
GET /api/auth/logoutPurpose
Destroy the current session by clearing the cookie.
Authentication: recommended (but endpoint is idempotent).
Response
200 { "ok": true }Error response (rare)
500
{
"error": {
"message": "Internal server error",
"code": "INTERNAL_SERVER_ERROR"
}
}
GET /api/auth/mePurpose
Provide the current session identity for frontend consumers.
Semantics (frontend-friendly):
{ user: null } when unauthenticated{ user: { userId, role, branchId } } when authenticatedThis avoids using 401 as control-flow for basic "am I logged in?" checks.
POST /api/auth/change-password (RHL-009)Purpose
Change the password for the currently authenticated user.
Authentication: required.
Request body (JSON)
{
"currentPassword": "<string>",
"newPassword": "<string>"
}
Response 200
{ "ok": true }
Behavior notes
currentPassword against the stored passwordHash.docs/auth.md).mustChangePassword = falsepasswordResetToken = nullpasswordResetExpiresAt = nullError responses
401 when no session exists or the session is invalid:
{ "error": { "message": "Unauthorized", "code": "AUTH_UNAUTHENTICATED" } }
401 when currentPassword does not match:
{
"error": {
"message": "Invalid credentials",
"code": "AUTH_INVALID_CREDENTIALS"
}
}
400 when the JSON body is invalid:
{
"error": {
"message": "Invalid request body",
"code": "VALIDATION_INVALID_JSON"
}
}
400 when required fields are missing:
{
"error": {
"message": "Missing currentPassword or newPassword",
"code": "VALIDATION_MISSING_FIELD",
"details": { "fields": ["currentPassword", "newPassword"] }
}
}
400 when newPassword violates the password policy:
{
"error": {
"message": "Weak password",
"code": "VALIDATION_WEAK_PASSWORD",
"details": {
"minLength": 8,
"requireLetter": true,
"requireNumber": true,
"disallowSameAsCurrent": true,
"reasons": ["MIN_LENGTH", "MISSING_NUMBER"]
}
}
}
500 for unexpected errors:
{
"error": {
"message": "Internal server error",
"code": "INTERNAL_SERVER_ERROR"
}
}
GET /api/branchesReturns the list of branches (e.g. ["NL01", "NL02"]).
Authentication: required.
RBAC behavior
branch role → only own branchadmin/dev → all branchesResponse 200
{ "branches": ["NL01", "NL02"] }
Error responses
401
{ "error": { "message": "Unauthorized", "code": "AUTH_UNAUTHENTICATED" } }
500
{
"error": { "message": "Internal server error", "code": "FS_STORAGE_ERROR" }
}
GET /api/branches/[branch]/yearsExample: /api/branches/NL01/years
Authentication: required.
Response 200
{ "branch": "NL01", "years": ["2023", "2024"] }
GET /api/branches/[branch]/[year]/monthsExample: /api/branches/NL01/2024/months
Authentication: required.
Response 200
{ "branch": "NL01", "year": "2024", "months": ["01", "02", "10"] }
GET /api/branches/[branch]/[year]/[month]/daysExample: /api/branches/NL01/2024/10/days
Authentication: required.
Response 200
{ "branch": "NL01", "year": "2024", "month": "10", "days": ["01", "23"] }
GET /api/files?branch=&year=&month=&day=Example:
/api/files?branch=NL01&year=2024&month=10&day=23
Authentication: required.
Response 200
{
"branch": "NL01",
"year": "2024",
"month": "10",
"day": "23",
"files": [{ "name": "test.pdf", "relativePath": "NL01/2024/10/23/test.pdf" }]
}
GET /api/files/:branch/:year/:month/:day/:filenamePurpose
Stream (or download) a single PDF file from the NAS while enforcing authentication and branch-level RBAC.
Authentication: required.
RBAC behavior
401 AUTH_UNAUTHENTICATED when no valid session exists.403 AUTH_FORBIDDEN_BRANCH when the session is not allowed to access :branch.URL params
branch: NL + digits (e.g. NL01)year: YYYY (4 digits)month: MM (01–12)day: DD (01–31)filename: PDF file name (must be a simple file name; no path segments)Query params (optional)
download=1 or download=true
Content-Disposition: attachment (download)inline (open in browser)Success response (200)
Content-Type: application/pdfCache-Control: no-storeError responses (JSON)
Even though the happy path is binary, error responses remain standardized JSON.
GET /api/searchPurpose
Search delivery note content across PDFs.
The endpoint returns search hits with enough metadata to:
Authentication: required.
RBAC behavior
branch role:
branchId403 AUTH_FORBIDDEN_BRANCHadmin/dev role:
Query params
q (optional)
scope (optional)
branch — single branch scope (admin/dev only; branch users are forced to their own branch)all — all branches (admin/dev only)multi — only the branches listed in branches (admin/dev only)branch (optional)
scope=branch.branch and are forced to their own branch.branches (optional)
scope=multi.from, to (optional)
YYYY-MM-DD format.limit (optional)
cursor (optional)
Filter rule (important)
To avoid accidental "match everything" queries (especially dangerous in scope=all), the backend requires at least one of:
qfromtoIf all three are missing, the API returns:
400 VALIDATION_SEARCH_MISSING_FILTERResponse 200 (example)
{
"items": [
{
"branch": "NL20",
"date": "2025-12-18",
"year": "2025",
"month": "12",
"day": "18",
"filename": "Stapel_Seiten-4_Zeit-141039.pdf",
"relativePath": "NL20/2025/12/18/Stapel_Seiten-4_Zeit-141039.pdf",
"snippet": "..."
}
],
"nextCursor": "<opaque>",
"total": 123
}
The endpoints and response shapes documented here (and in docs/frontend-api-usage.md) are considered API v1 for the first frontend implementation.
Rules:
When adding new endpoints:
route.js under app/api/....lib/storage for filesystem listing/navigation access.getSession() + canAccessBranch() as needed).withErrorHandling + ApiError helpers).