4.1 REST
All endpoints live under /api/v1. Endpoints that mutate or read
restricted data require a valid session (see §11). The column "Roles"
lists who can call the endpoint (* = any authenticated user, with
ownership/lease checks applied where applicable).
# --- Authentication ---
GET /api/v1/layouts/login PUBLIC # unauthenticated: list of layouts to pre-fill the login dropdown. Returns only non-locked rows: [{id, name, isSystem}]. UI substitutes the i18n key `layout:system_default_label` for rows where isSystem == true.
POST /api/v1/auth/login { login, pin, layoutId } * # exchange login+PIN+layout for a session token. layoutId is REQUIRED. 422 layout_not_found / layout_locked are possible on top of 401 invalid_credentials. The issued JWT carries {userId, layoutId} and the binding is immutable for the token lifetime.
POST /api/v1/auth/logout * # also deletes the caller's SudoElevation row for the JWT-pinned layout (if any) and broadcasts `auth.elevationChanged` to every other live session of the user (§7a.7).
GET /api/v1/auth/me * # current user, effective role label, signalman flag, plus { layoutId, layoutName, layoutIsSystem } from the JWT and the active sudo grant: `{ ..., effectiveRole, isSignalman, sudo: { grantedAt, expiresAt } | null }`. The UI drives the AppBar padlock countdown from `sudo.expiresAt`.
# Sudo elevation (§7a.7) – PIN-gated 2-min admin self-promotion. Always
# admin-only; the row is auto-reaped by the janitor. Sudo grants the same
# authority as a permanent admin everywhere — there is no "sudo-only"
# rejection.
POST /api/v1/layouts/{id}/sudo { pin } * # `{id}` MUST equal the JWT `layoutId` – cross-layout sudo is rejected with `422 sudo_layout_mismatch`. Verifies `pin` against `Layout.AdminPINHash`; on mismatch returns `401 sudo_invalid_pin` and bumps an in-memory failure counter with rolling-window back-off; after N consecutive failures returns `429 sudo_locked` and writes `auth.sudo_locked` (§3a.5). On success returns `{ grantedAt, expiresAt }`, writes / refreshes the `SudoElevation` row, audits `auth.sudo_granted`, and broadcasts `auth.elevationChanged` to every live WS session of the caller. **A second call while the row is still active simply pushes `ExpiresAt` forward.**
DELETE /api/v1/layouts/{id}/sudo * # explicit user-side revoke. Idempotent: returns `204 No Content` even when no row existed. Audits `auth.sudo_revoked { reason:"user_action" }` and broadcasts `auth.elevationChanged`.
# Permanent self-grant of the layout-scoped signalman role (§7a.7) –
# the engineer's-cap icon next to the padlock. Same PIN gate as sudo,
# but the resulting `LayoutSignalman` row has no expiry. Click again
# (or call DELETE) to step down.
POST /api/v1/layouts/{id}/signalman { pin } * # PIN-verified upsert into `layout_signalmen` with `expires_at = NULL`. Same lockout/back-off rules as `/sudo`. Audits `layout_signalman.granted { source:"self" }` and broadcasts `auth.elevationChanged`.
DELETE /api/v1/layouts/{id}/signalman * # drop own signalman membership; idempotent. Audits `layout_signalman.revoked { source:"self" }` and broadcasts `auth.elevationChanged`.
# --- API keys (per-user temporary keys, max lifetime 365d) ---
GET /api/v1/apikeys * # own keys only (prefix + metadata, never plaintext)
POST /api/v1/apikeys { name, expiresAt, scopes:[...] } * # mint; returns plaintext ONCE in response
DELETE /api/v1/apikeys/{id} * # revoke (own key, or admin for any)
# --- User management ---
GET /api/v1/users admin
POST /api/v1/users { login, pin, role } admin
PUT /api/v1/users/{id}/role { role } admin # change permanent role
POST /api/v1/users/{id}/temp-role { role, expiresAt } admin # grant temporary role
DELETE /api/v1/users/{id}/temp-role/{tempRoleId} admin # revoke early
PUT /api/v1/users/{id}/dcc-pool { ranges:[{from,to},...] } admin # assign DCC pool
# --- Vehicles ---
GET /api/v1/vehicles * # all visible (own + leased + signalman-overridden)
POST /api/v1/vehicles { dccAddress, name, ... } * # register inside own DCC pool
PUT /api/v1/vehicles/{addr} { name, ... } owner # edit
DELETE /api/v1/vehicles/{addr} owner
GET /api/v1/vehicles/{addr}/cv/{n} owner # CV read (lessee cannot)
POST /api/v1/vehicles/{addr}/cv { entries:[{n,v},...] } owner # CV write
# --- Vehicle functions (F0-F31; owner-only editing, lessee can only invoke) ---
GET /api/v1/vehicles/{addr}/functions * # resolved list (template OR vehicle rows; carries `source`)
PUT /api/v1/vehicles/{addr}/functions/{num} { name, icon, position } owner # upsert one slot; auto-detaches if linked
DELETE /api/v1/vehicles/{addr}/functions/{num} owner # remove one slot; auto-detaches if linked
POST /api/v1/vehicles/{addr}/functions/reorder { positions:[{num,position},…] } owner # auto-detaches if linked
POST /api/v1/vehicles/{addr}/functions/detach owner # explicit copy-on-write; idempotent
POST /api/v1/vehicles/{addr}/functions/attach { templateId } owner # drop local rows, re-link to template
GET /api/v1/function-icons * # closed catalogue of FunctionIcon values
# --- Vehicle templates (anyone creates; only owner or admin edits) ---
GET /api/v1/vehicle-templates *
GET /api/v1/vehicle-templates/{id} *
POST /api/v1/vehicle-templates { name, description } *
PUT /api/v1/vehicle-templates/{id} { name?, description? } owner OR admin
DELETE /api/v1/vehicle-templates/{id} owner OR admin # 409 unless ?cascade=true (§3a.6.4)
GET /api/v1/vehicle-templates/{id}/functions *
PUT /api/v1/vehicle-templates/{id}/functions/{num} owner OR admin
DELETE /api/v1/vehicle-templates/{id}/functions/{num} owner OR admin
POST /api/v1/vehicle-templates/{id}/functions/reorder owner OR admin
# --- Scripts (browser-side Python automation) ---
GET /api/v1/scripts * # lists scripts the caller can SEE: owned + those attached to a vehicle/train the caller can drive (lessee). For lessee-visible rows the `source` field is omitted.
GET /api/v1/scripts/{id} owner # full source; lessee gets 403 even with an active lease
POST /api/v1/scripts { name, source, runtime, icon, description? } * # creates a script owned by the caller; source ≤ 64 KiB, runtime ∈ {micropython, pyodide}
PUT /api/v1/scripts/{id} { name?, source?, runtime?, icon?, description? } owner # bumps `version`; fan-out `script.changed` to live throttles
DELETE /api/v1/scripts/{id} owner # also drops every ScriptAttachment row
# Script attachments – a script may be bound to a vehicle XOR a train.
GET /api/v1/scripts/{id}/attachments * # owner sees all; lessee sees only attachments to vehicles/trains they can drive
POST /api/v1/scripts/{id}/attachments { vehicleAddr? , trainId? , position? } owner # exactly one of vehicleAddr / trainId required; 422 otherwise
DELETE /api/v1/scripts/{id}/attachments/{attachmentId} owner
# Reverse listing: scripts visible on a given throttle (used by the UI to populate the script-button row alongside F0..F31)
GET /api/v1/vehicles/{addr}/scripts * (driving authority)
GET /api/v1/trains/{id}/scripts * (driving authority)
# --- Leasing ---
POST /api/v1/vehicles/{addr}/lease { toUserId, expiresAt } owner
DELETE /api/v1/vehicles/{addr}/lease owner # revoke active lease
POST /api/v1/trains { name, members:[{ vehicleId, reversed, speedMultiplier?, excludeFromSpeed?, startDelayMs?, accelRampMs?, accelRampMaxSteps?, brakeRampMs?, brakeRampMaxSteps? }] } * # only own vehicles
PUT /api/v1/trains/{id} { name?, members? } * # owner / admin mutate
PATCH /api/v1/trains/{id}/members/{memberId} {
speedMultiplier?, excludeFromSpeed?, startDelayMs?,
accelRampMs?, accelRampMaxSteps?, brakeRampMs?, brakeRampMaxSteps?
} owner # leading: multiplier + excludeFromSpeed immutable; timing fields editable; republishes defined_trains
POST /api/v1/trains/{id}/lease { toUserId, expiresAt } owner
DELETE /api/v1/trains/{id}/lease owner
# --- Interlockings ---
GET /api/v1/interlockings * # FILTERED to the caller's active layout (only whitelisted IDs). Each row includes `{ id, name, location, occupant?: { userId, login } }` when staffed.
POST /api/v1/interlockings/{id}/join { force?: bool } signalman # become active occupant; requires interlocking ∈ active layout. When already occupied: `409 interlocking_occupied` unless `force:true`, which ends the incumbent session (`reason:"displaced"`) and opens a new one for the caller.
POST /api/v1/interlockings/{id}/leave signalman # end own active session for this interlocking (idempotent if not occupying)
# --- Radio history replay (READ-ONLY; Redis-backed, 4h TTL, §4.4.4) ---
# Sending radio is WS-only (`radio.send`); these endpoints only SEED the
# chat surfaces on mount (the WS keeps them live afterwards). Each row is
# a RadioMessage projection: { id, from:{userId,login}, to:{userId?,interlockingId?},
# context:{ vehicle?:{id,name}, train?:{id,name} }, phrase, note?, sentAt }.
GET /api/v1/interlockings/{id}/radio signalman # group-chat replay for a box the caller occupies (all drivers in the layout, §4.4.3). 403 unless the caller is the active occupant. ?limit= (default 200).
GET /api/v1/radio/mine * # the caller's own conversations (their messages + messages addressed to them). Seeds the driver throttle chat overlay (§6.3b). ?limit= (default 200).
# --- Command Stations (catalogue of `centralki`) ---
GET /api/v1/command-stations * # list (name + connection type only; admin sees full Connection)
GET /api/v1/command-stations/{id} admin # full details incl. Connection
POST /api/v1/command-stations { name, connection } admin
PUT /api/v1/command-stations/{id} { name, connection } admin
DELETE /api/v1/command-stations/{id} admin # cascades: every LayoutCommandStation row pointing at it is removed and every live DriveSession pinned to it is detached (CommandStationID → nil + broadcast `session.commandStationChanged { commandStationId: null, reason:"deleted" }`). 409 layout_needs_at_least_one_command_station if removing the row would leave any non-system layout with zero attached stations.
# --- Layouts (modeling events) ---
# Note: there is no /layouts/{id}/join or /leave endpoint. The layout
# is picked on the login form and pinned to the drive session by the
# JWT (§7a.1); switching layout requires logout + login.
GET /api/v1/layouts * # full list (incl. locked rows); admin sees an `canEdit:bool` badge. Each row carries: { id, name, isSystem, locked, commandStations:[{id,name}] }. For isSystem rows commandStations mirrors the live `command_stations` catalogue. `AdminPINHash` is **never** included in the response – the page only shows whether a PIN is set (always true after bootstrap).
GET /api/v1/layouts/{id} *
POST /api/v1/layouts { name, commandStationIds:[id,...], adminPin } admin # commandStationIds REQUIRED and MUST contain at least one id; rejects with `layout_needs_at_least_one_command_station` otherwise. `adminPin` is REQUIRED (numeric, default 6 digits, configurable min/max length); rejects with `pin_missing` when empty and `pin_too_weak` when below the minimum length. The PIN is argon2id-hashed before insert. Trying to create a second `IsSystem=true` row is impossible (partial unique index). Sudo-elevated admins pass the same gate as permanent admins (§7a.7).
PUT /api/v1/layouts/{id} { name?, adminPin? } admin # rename and/or rotate the layout admin PIN (§7a.7). The two fields are independent – a request with `name` alone keeps the PIN, a request with `adminPin` alone keeps the name, a request with both does both. **`adminPin` is treated as "no change" when the field is missing or an empty string** so the UI's "blank field = don't reset" semantic is enforced server-side too. The system layout (isSystem) rejects `name` changes with `default_layout_immutable` but accepts `adminPin` (the system layout still needs a rotatable PIN). The attached command-station set is mutated through the dedicated subresource below. PIN rotation writes the `layout.admin_pin_changed` audit row (§3a.5).
DELETE /api/v1/layouts/{id} admin # 409 if any drive session is still pinned to it; the system layout (isSystem) always returns 422 default_layout_undeletable.
# Lock / unlock (admin only; hides the layout from /api/v1/layouts/login)
POST /api/v1/layouts/{id}/lock admin # 422 default_layout_cannot_be_locked when isSystem; idempotent on a non-system layout (returns 200 with `locked:true`); NEVER closes live drive sessions.
DELETE /api/v1/layouts/{id}/lock admin # unlock; idempotent (returns 200 with `locked:false`).
# Command-station attachment (admin only; not allowed on the system layout)
GET /api/v1/layouts/{id}/command-stations * # returns the current set: for non-system layouts the LayoutCommandStation rows, for the system layout the entire `command_stations` catalogue (virtual)
POST /api/v1/layouts/{id}/command-stations { commandStationId } admin # 422 default_layout_command_stations_immutable when isSystem; 404 command_station_not_found if the id is unknown; 409 already_attached when the row exists
DELETE /api/v1/layouts/{id}/command-stations/{commandStationId} admin # 422 default_layout_command_stations_immutable when isSystem; 422 layout_needs_at_least_one_command_station when it would leave the layout with zero rows; live sessions pinned to the detached station are detached (CommandStationID → nil) and re-gated.
# Layout-scoped signalmen
GET /api/v1/layouts/{id}/signalmen *
POST /api/v1/layouts/{id}/signalmen { userId, expiresAt? } admin # grant signalman role inside this layout
DELETE /api/v1/layouts/{id}/signalmen/{userId} admin
# Layout vehicle roster + live presence (dashboard data sources)
GET /api/v1/layouts/{id}/vehicles * # vehicles on the layout roster. JWT `layoutId` must match `{id}`.
GET /api/v1/layouts/{id}/vehicles/mine * # caller's registered vehicles with `onLayout: bool` per row ("Show my vehicles" / add picker)
POST /api/v1/layouts/{id}/vehicles { vehicleAddr } owner # add own vehicle to roster; 409 if already attached
DELETE /api/v1/layouts/{id}/vehicles/{vehicleAddr} owner # remove own vehicle from roster
GET /api/v1/layouts/{id}/presence * # online users in this layout: `[{ userId, login, role, occupiedInterlocking?: { id, name } }]`
# Layout-scoped interlocking whitelist
GET /api/v1/layouts/{id}/interlockings *
POST /api/v1/layouts/{id}/interlockings { interlockingId } admin OR signalman-of-this-layout
DELETE /api/v1/layouts/{id}/interlockings/{interlockingId} admin
# --- Audit log (admin only, append-only) ---
GET /api/v1/audit-log admin # filterable: ?action=&actor=&objectType=&objectId=&layoutId=&since=&until=&limit=&offset=
GET /api/v1/audit-log/{id} admin
# --- System ---
GET /api/v1/system/status * # command station info FOR THE CALLER'S CURRENTLY PICKED COMMAND STATION (resolved via the session's CommandStationID); returns `{ commandStationSelected:false }` until the user fires session.setCommandStation
Takeover, throttle and radio sending are WebSocket-only because
they are short, frequent, and event-driven. The only REST surface radio
exposes is the read-only history replay above (GET …/interlockings/{id}/radio
and GET /api/v1/radio/mine), which just seeds the chat panels from
Redis (§4.4.4); everything live still flows over the WebSocket.