Detour

Detour backend — behavioural specification

What the sync and social service does, described as behaviour and rules rather than code. This is the document to read before changing the backend, and the one to check a change against afterwards; backend comments cite its sections by number (spec §11), so section numbers are stable — add at the end, don’t renumber.

Scope: the single service the Detour app authenticates against. Identity lives in Keycloak and is described here only where the service depends on it. Routing and geocoding are separate off-the-shelf products the app calls directly — see §14.

This file replaced a pre-rewrite specification of the Python sync server it grew out of. Where the two differ, the differences are collected in §17.


1. Actors and credential types

Three credential paths. They never substitute for one another.

Actor Credential Can do Cannot do
Rider Access token minted by the realm, bearer on every request Everything under their own account, plus friend and group interactions Read another rider’s trips, ever
Dashboard reader Read-only API key, as a header or a query parameter Read only its own owner’s rides, traces, stats, badges Write anything; read anyone else’s data
Administrator An ordinary rider token carrying the detour-admin realm role Account metadata, row counts, deleting an account, revoking its dashboard keys Read anyone’s trips, traces, places or routes

The live relay is not a fourth credential: it authenticates with the same rider token over a WebSocket upgrade.

An account may hold several at once — a phone session, two dashboard keys. Revoking one class does not disturb the others. Sessions and passwords belong to the realm; dashboard keys belong to this service.

2. Privacy invariants

These are the product’s promises. Each is enforced in exactly one place, and a change must keep that property.

  1. Trip records are never returned to anyone but their owner. No capability in the system reads another rider’s trips.
  2. Traces (the fog-of-war lines) leave their owner through exactly one capability, and only when both parties have opted into fog sharing. Sharing is off by default, reciprocal, and revocable — clearing it stops traces being served from the next request onwards.
  3. Friends otherwise see only aggregate numbers the owner’s own app computed (total distance, top speed, badges, …).
  4. Live position and voice are relayed only to a group’s accepted members, and only while a connection is actively joined to that group.
  5. Voice broadcast is rejected for any group that is not a convoy, server side, regardless of what a client asks for.
  6. A member who pauses sharing has their live position dropped at the relay, not merely suppressed on their own device, so an outdated client build cannot keep broadcasting after the user believes they stopped.
  7. A route can only be shared with an accepted friend, and ending a friendship deletes every route shared between those two people in either direction.
  8. A place shared into a circle is revoked when its owner leaves that circle.
  9. Administrators see account metadata and row counts only. The rules are not relaxed for them, and the guarantee is that no such capability exists — not that a permission withholds it.
  10. Credentials are never logged. Anything resembling a key or a token is redacted on its way to a log, on both the access and the error path.

3. Domain concepts

Concept Meaning
User A local account keyed on the realm’s subject identifier: handle, optional address, a fog-sharing preference, plus the latest aggregate stats and badge map their app uploaded. Created the first time a token for an unseen subject arrives.
Trip One recorded journey, identified by its start instant. Stored opaquely — the service does not interpret its contents beyond the start instant and, for dashboards, a handful of well-known fields (end instant, mode, distance, top speed, peak g-force).
Trace One fog-of-war line: an ordered list of recorded points. Deduplicated by content, so re-uploading the same line is a no-op.
Track point A single recorded position with an instant and, optionally, speed and lean angle. Unpacked from trace lines that carry timestamps; the unit dashboards read. Associated with a trip by instant, not by an identifier.
Saved place A rider’s own shortcut (home, work, a favourite viewpoint). Opaque, keyed by a client-assigned identifier.
Badge An achievement identifier plus the instant it was first earned.
Friendship A symmetric relationship between two riders, either pending (one side must accept) or accepted.
Shared route A planned route one rider sent to a friend. Opaque, replaces an earlier copy of the same route.
Group One entity with two kinds: a convoy (a live ride together, ephemeral) and a circle (a standing “who’s where” map, persistent). Membership is the access gate for every live feature.
Circle place A named point with a radius, owned by one member, shared into one circle.
Arrival/departure event A record that a member entered or left a circle place. Detected on the device; the service records and fans out.
API key A read-only credential for a dashboard. The one credential this service issues.

4. Accounts

4.1 Where identity lives

Registration, sign-in, sign-out, password reset, lockout and who may register are the realm’s, not this service’s. There is no sign-in endpoint here, no password anywhere in this schema, and no invite-code system. The service only ever sees a token the realm minted.

docker/dev/config/keycloak/REALM.md records how the realm is configured and which of the old server’s rules each setting carries forward.

4.2 Token requirements

A token is accepted when it:

The handle requirement is load-bearing rather than cosmetic: it is what other riders search for when adding a friend, and a realm that permits an email address as a username produces accounts this service refuses to provision.

4.3 Provisioning on first sight

The first request bearing a token for an unseen subject creates the local account. There is nothing to call and nothing to confirm; a rider who signs in on a new deployment simply exists there. Handle and address are refreshed from the token on later requests, so a change made in the realm propagates without a second mechanism.

Deleting the account here and removing the rider from the realm are two separate acts. Doing only the first leaves them able to sign in and start again; doing only the second leaves their data here.

4.4 Own profile

A rider can read their own handle, address, fog-sharing preference, aggregate stats and badge map, and can set the fog-sharing preference.

5. Device synchronisation

One capability performs a bidirectional merge of everything the device holds and returns the merged result. It is idempotent: syncing twice with the same input yields the same state.

Accepted from the device (all optional):

Payload Merge rule
Trips Keyed on (owner, start instant). A re-upload replaces the stored copy, so an edit — a corrected vehicle mode — propagates rather than being ignored.
Deleted trips Start instants the device has deleted. Applied after the upserts, so a trip in both lists ends up deleted and the deletion propagates to every other device instead of the trip resurrecting.
Traces Deduplicated on content. A genuinely new line is additionally unpacked into track points; a line already held is not re-unpacked, because every sync re-sends the whole history.
Saved places Keyed on the client-assigned identifier; a rename replaces the stored copy.
Badges The earliest earned instant wins, so a reinstall cannot move a date forward.
Ride titles Keyed on (owner, start instant), each with the instant it was edited. The newest edit wins, a tie keeps the stored copy, and an empty title is kept as a tombstone so a cleared rename does not come back from a device still holding the old one.
Aggregate stats Absent means “no update”, not “clear” — a client that syncs only trips must not blank the numbers its friends read.
Fog-sharing preference Absent means “leave it alone”, so an older client cannot silently flip the setting.

Validation:

Returned: the merged union of trips (newest first), traces, badges, saved places and ride titles.

6. Friends

7. Fog sharing

8. Shared routes

9. Groups: convoys and circles

Convoys and circles are the same entity distinguished by kind. Shared behaviour:

Differences:

  Convoy Circle
Lifetime Deleted when the last member leaves Persists while one member remains, and while alone
Size cap None 15 members
Pause switch Not applicable Per member, per circle
Position persistence Never stored; live only Latest fix per member, overwritten in place — no history, no trail
Voice (push-to-talk) Allowed by the rules; not carried today, see §11.3 Rejected
Destination voting Allowed Rejected

9.1 Circle pause

9.2 Circle position, low cadence

10. Circle places and presence events

10.1 Places

10.2 Arrival and departure

11. Live relay

A persistent duplex channel alongside the request/response API, at /api/live, authenticated with the same rider token on the upgrade request. It is an ordinary endpoint on the ordinary port, not a second listener.

11.1 Connection model

11.2 Frames

Client to server:

Type Groups Rules
join any Must be an accepted member. Names the group.
location any Position must be finite and in range; heading and speed are optional and silently dropped if out of range. Relayed to every other member of every group the sender shares with them. In a circle it also overwrites the sender’s stored last fix. Dropped entirely if the sender has paused. Deliberately carries no group id — one fix is one fix, and fanning it out per group is the relay’s job, not the phone’s.
spin_offer convoy only 1–3 candidates, each with a valid position and optional distance, duration and name. One invalid candidate voids the whole frame rather than silently relaying a shorter list.
spin_vote convoy only Index 0–2. Anything else is dropped rather than relayed as a vote for a candidate that was never offered.

Server to client: joined, error, positions, left, place_event. place_event is server-originated only — a client cannot cause one by sending it, which is why there is no inbound counterpart.

Wherever one of these frames names a rider — a peer inside positions, or the subject of left, spin_offer, spin_vote and place_event — the field is an account id, never a handle. The JSON keys carrying it did not change when this moved (u on a peer, user everywhere else); only the type behind them did.

The wire format of each frame, key by key, is documented with the feature it serves in CIRCLES_AND_CONVOYS.md §6. That table and LiveFrames.cs are the two halves of one contract.

A spin_offer carrying three candidates means “vote on these”; one candidate means “this won”. The relay treats both identically and holds no round state; the convention lives in the clients, and the single-candidate closing frame is what stops a convoy splitting across two destinations.

11.3 Voice

Push-to-talk is part of the rules above — convoy only, rejected for circles, one chunk bounded in size — but the relay does not carry it today. Voice frames are accepted off the wire and dropped, the same as any unknown type, so a client that still sends them stays connected and everything else keeps working.

What returns will be Opus over binary frames: raw PCM base64’d into JSON cost roughly 40 KB/s per talker per listener, which is what made it worth deferring rather than porting. Clients read this state from a shared feature flag so the two apps cannot disagree about it.

11.4 Revocation

Prompt, and surviving process boundaries:

Degradation: if the live channel cannot start, everything else — group creation, invitations, membership management, low-cadence circle positions, the presence feed — keeps working. Only live position goes away.

12. Read-only dashboard API

A separate read surface, authenticated by API key rather than by a rider token, intended for a home-automation dashboard. The key may be a header (for polling sensors) or a query parameter (an embedded frame cannot send a header). Every capability reads only the key owner’s own data.

Capability Returns
Stats Lifetime aggregates, corrected where the service knows better; ride count; badge map; and a badge catalogue scoring every defined badge, earned or not, so a card can show progress toward the next tier without knowing the tiers.
Rides Rides newest first, each with start and end, mode, distance, top speed, peak lean, peak g-force and point count.
Ride geometry One ride as geographic features — a segment per band, each carrying the speed and lean recorded there, because a single line can only be one colour and colouring by lean is the point. No ride named means the newest ride, so a polling sensor needs no second request to discover “latest”.
Traces The caller’s own trace lines, positions only, optionally thinned, for an all-rides heatmap.
Coverage Every trace as one aggressively-thinned geometry plus a per-cell visit heat banding. Simplification runs per line so one long trace cannot eat the whole budget; the point budget is a total across all lines, not per line.

Behavioural rules:

The service renders no pages. Geometry is served as JSON and drawing it is the dashboard’s job — see server/homeassistant/README.md.

12.1 Badge catalogue

The tiers are product content:

Family Measured on Tiers
Distance Total distance 100 km, 500 km, 1 000 km, 5 000 km, 10 000 km, 25 000 km
Top speed Top speed 100, 130, 160, 200, 250 km/h
Single ride Longest single trip 100 km, 250 km, 500 km
Places Municipalities visited 3, 10, 25, 50
Coverage Best coverage percentage 10, 25, 50, 100

Each entry reports its threshold, the rider’s current value, when it was earned (if it was), and progress toward it.

13. Administration

Gated on the detour-admin realm role, carried by an ordinary rider token. There is no separate admin credential, no admin session, and no browser dashboard: creating accounts, resetting passwords, granting the role and locking an account out are realm operations, done in the Keycloak console against the realm’s own audit trail.

What remains is what the realm cannot know — how much a rider stored here, and removing it:

Action Rules
Overview Per account: handle, address, administrator flag, fog-sharing flag, created and last-seen instants, counts of trips, traces, badges and dashboard keys, and total distance. No trip, trace, place or route content is readable, and no capability exists that would make it readable.
Delete an account Refused for the caller’s own account. Deletes the account and every row it owns: trips, ride titles, traces, points, shortcuts, badges, friendships, group memberships, shared routes, circle places and keys. Removing the rider from the realm is a separate act.
Revoke dashboard keys All API keys for one account — the lost-device remedy for the one credential the realm does not issue. Does not touch the rider’s session, which is the realm’s to end.

Role changes take effect on the next token the realm issues, which is the 15-minute access-token lifetime rather than the next click.

14. Out of scope

Two adjacent self-hosted services are not part of this backend — the app calls them directly, not through the API, so that boundary is unchanged. docker/prod/ now ships them as optional compose overlays (docker-compose.routing.yml, docker-compose.search.yml); they run as separate containers on their own upgrade cadence, and the API neither proxies nor depends on them. See backend/INSTALL.md for what each costs to run and README.md for what breaks in the app without them.

Service Function
Routing engine (GraphHopper) Curvy motorcycle round trips, plus car routes, from an offline map extract
Geocoder (Photon) Address and place search, self-hosted so it is fast, private and not rate-limited

Also deliberately absent, and a new decision rather than a port if it is ever added: mobile push notifications. No device tokens, no vendor push service anywhere in the system.

Deferred rather than dropped: voice (§11.3), background jobs (every retention cap is enforced at write time, where the row that would exceed it is created, so there is nothing for a sweep to do), and an audit trail — the obvious next security addition.

15. Cross-cutting behaviour

15.1 Transport and payloads

15.2 Rate limiting

Token buckets, chained so one caller cannot exhaust everyone else’s budget:

Tier Keyed on Why it is separate
Per address Client IP The cheap first gate, and the only gate anonymous traffic meets
Per rider Authenticated account One rider’s runaway client does not throttle the rest
Per dashboard key The key itself Smaller than a rider’s: otherwise a runaway dashboard poll throttles the owner’s own phone and starves their other keys
Anonymous Client IP, on endpoints reachable without a token Deliberately tiny — the old server capped auth attempts at 10 per 5 minutes per address, and the realm’s own brute-force detection is the other half of that promise

The client address is taken from a proxy header only when the deployment is explicitly configured to trust one; otherwise anyone could reset the limiter per request by spoofing it. Naming a proxy under ForwardedHeaders:KnownProxies or ForwardedHeaders:KnownNetworks trusts exactly one hop, so the address is the one the nearest trusted proxy reported rather than the furthest entry a caller managed to prepend. See backend/INSTALL.md.

15.3 Errors

15.4 Health

/api/health is unauthenticated on purpose, so an orchestrator can probe it, and answers with a per-dependency breakdown: Postgres is critical, Redis is degraded-only.

15.5 Capabilities

/api/capabilities is unauthenticated, for the same reason /api/health is: a client needs it before it can hold a token, because one of the things it answers is which realm mints them.

{
  "schema": 1,
  "features": ["idp-discovery", "push-android", "routing-discovery", "geocoder-discovery", "cameras-discovery", "speedlimits-discovery", "roads-discovery", "municipality-discovery", "pois-discovery"],
  "idp": { "issuer": "https://idp.example/realms/detour" },
  "routing": { "baseUrl": "https://example.com/gh" },
  "geocoder": { "baseUrl": "https://example.com/photon" },
  "cameras": { "baseUrl": "https://example.com" },
  "speedLimits": { "baseUrl": "https://example.com" },
  "roads": { "baseUrl": "https://example.com" },
  "municipality": { "baseUrl": "https://example.com" },
  "pois": { "baseUrl": "https://example.com" }
}

features is where a deployment states what it was actually configured to do, which is not the same question as what this software supports:

Feature Present when
idp-discovery Always.
push-android An FCM gateway is configured — Notifications:FirebaseCredentialsPath is set and loaded.
push-ios An APNs gateway is configured — the four Notifications:Apns* keys are set and the .p8 loaded.
routing-discovery Routing:BaseUrl is set.
geocoder-discovery Geocoder:BaseUrl is set.
cameras-discovery Camera:BaseUrl is set.
speedlimits-discovery SpeedLimit:BaseUrl is set.
roads-discovery Road:BaseUrl is set.
municipality-discovery Municipality:BaseUrl is set.
pois-discovery Poi:BaseUrl is set.

The two push strings are per-platform rather than one push, because having Firebase credentials and no APNs key is an ordinary state and an iOS client must not read Android’s answer as its own. routing, geocoder, cameras, speedLimits, roads, municipality and pois are configured the same way — independently, all blank by default — because a self-hoster routinely runs some of these and not the others.

routing, geocoder, cameras, speedLimits, roads, municipality and pois are absent, not present with a blank baseUrl, when their env keys (Routing__BaseUrl, Geocoder__BaseUrl, Camera__BaseUrl, SpeedLimit__BaseUrl, Road__BaseUrl, Municipality__BaseUrl, Poi__BaseUrl) are unset — the same “absent means not announced” rule idp does not get to use only because a realm is mandatory. Unlike idp.issuer, a client is not free to trust any of them verbatim: each is server-supplied and moves rider data (a destination typed into search, an origin/destination pair sent for a route) or a network request (the bbox fetch for /api/cameras, /api/speedlimits, /api/roads or /api/pois, or the point lookup for /api/municipality) to wherever it names, so the client validates it — HTTPS only, and a host that differs from the API’s own asks the rider before it is used. See RoutingServer.kt’s discovery pair in shared/ for the mirror of the idp.issuer discovery this reuses the shape of, extended with that consent step; SpeedCameras.near, RoadRoulette.speedLimitWays, RoadRoulette.fetchRoads/RoadTypeTracker.fetchWays, MunicipalityStore.fetch and PoiRoulette.randomPoi (also shared/) are cameras’s, speedLimits’s, roads’s, municipality’s and pois’s equivalent callers.

They exist because a client cannot work this out for itself. An Android build with a google-services.json baked in registers a token successfully against a server that has no Firebase key and will never send to it — see CircleNotifyService.pushCovers for what that costs if the client guesses.

idp.issuer is Idp:Authority verbatim — the same string §4.2 requires as iss, exactly and not as a prefix. Stating it unchanged is what makes it impossible for a deployment to advertise a realm whose tokens it would then refuse.

Two rules make the document usable by clients both newer and older than the server, which matters because anyone self-hosting updates on their own schedule and there is no coordination point with the app:

#133 changed every payload that names a rider — friends, group members, positions, circle places, presence events and the live relay’s frames — to identify them by account id. On most of those an existing field’s meaning broke outright (a Username or Owner string renamed and retyped to a Guid id; a relay frame’s key kept its name but not its type). Group membership is the one exception: it already carried the handle and simply gained the id beside it, so that shape only grew. None of it is /api/capabilities — its schema, features and idp fields never changed — so schema correctly stayed at 1: this field versions the shape of this one document, not the API as a whole, and a breaking change to a payload outside it has no version counter today. The two rules above are a promise about this endpoint only.

It is a version-fingerprinting surface. That is accepted — the realm address is already public, and a self-hosted open-source server’s version is discoverable regardless — and what follows from it is a content rule: this document carries feature names and the values a client needs to configure itself against, and nothing else. No dependency versions, no build strings, no counts, no dependency health. §15.4 is where operational detail lives, and it stays there.

15.6 Camera data

GET /api/cameras is unauthenticated, for the same reason /api/capabilities is — camera locations are public safety information, not rider data, and gating them behind a token would cost every self-hoster a round trip for no privacy gain (issue #303). It is covered by the global per-IP limiter only, not the tighter anonymous policy §15.2 lists — which is why the bbox span below is capped: an unauthenticated caller must not be able to force the whole table to materialise and serialise in one request.

Query parameters are a bounding box: minLat, minLon, maxLat, maxLon. Each must be within its valid range (-90/90 for latitude, -180/180 for longitude), minLat may not exceed maxLat, minLon may not exceed maxLon, and the box may not span more than 2° on either axis — comfortably larger than the ~0.15° span the client’s own prefetch radius (SpeedCameras.PREFETCH_RADIUS_M in shared/) ever requests. A request that fails any of these checks is a 400.

{
  "cameras": [
    {
      "id": "3e1a2b4c-....-....-....-............",
      "kind": "FixedSpeed",
      "lat": 50.85,
      "lon": 4.35,
      "polyline": null,
      "maxSpeedKmh": 50,
      "roadRef": "N9"
    }
  ]
}

kind is one of FixedSpeed, MobileHotspot, RedLight, SpeedAndRedLight, Section or AverageSpeedZone — a client that doesn’t recognise one falls back to treating it as a plain speed camera, the “unknown reads as the safe default” rule this document uses elsewhere. A point camera (FixedSpeed, MobileHotspot, RedLight, SpeedAndRedLight) carries lat/lon and no polyline; an enforcement section (Section, AverageSpeedZone) carries a polyline (an ordered list of [lat, lon] pairs tracing the section, at least two points) and no lat/lon. maxSpeedKmh and roadRef are optional on either.

This endpoint is read-only: data is loaded by an operator running dotnet Detour.Api.dll import-cameras <path.json> on the box that hosts the database — see backend/INSTALL.md.

15.7 Speed-limit data

GET /api/speedlimits is unauthenticated, for the same reason §15.6 is — posted speed limits are public safety information, not rider data. It is covered by the global per-IP limiter only, for the same reason and with the same span cap as §15.6’s /api/cameras.

Query parameters are the same bounding box: minLat, minLon, maxLat, maxLon, validated the same way, capped at the same 2° span — comfortably larger than the ~0.03° span RoadRoulette.SPEED_PREFETCH_RADIUS_M (shared/) ever requests. A request that fails any of these checks is a 400.

{
  "ways": [
    {
      "id": "3e1a2b4c-....-....-....-............",
      "maxSpeedKmh": 70,
      "polyline": [[49.6, 6.1], [49.61, 6.11]]
    }
  ]
}

polyline is the way’s own node order, at least two points — there is no point-camera-style lat/lon shorthand here, a way is always a line.

This endpoint is read-only: data is loaded by an operator running dotnet Detour.Api.dll import-speedlimits <path.json> on the box that hosts the database — see backend/INSTALL.md. Unlike /api/cameras, a re-import never deletes a way this run didn’t see — see ISpeedLimitWayRepository.UpsertAsync’s doc in Detour.Domain for why a per-region import run cannot safely do that yet.

15.8 Municipality boundaries

GET /api/municipality?lat=&lon= is unauthenticated, for the same reason §15.6 is — administrative boundary geometry is public OSM data, not rider data. Unlike §15.6/§15.7’s bbox endpoints there is no span to cap: a query here is always a single point, so no request can force the whole table to materialise, and the endpoint is covered by the global per-IP limiter only.

{
  "municipality": {
    "id": 1234,
    "name": "Esch-sur-Alzette",
    "rings": [[[49.6, 6.1], [49.61, 6.11], [49.60, 6.12]]]
  }
}

municipality is null when no admin_level=8 boundary contains the point — the sea, or outside the imported region — rather than a 404, so the client can read the answer the same way whether or not one was found. id is the bare OSM relation id, not a database row id: it is what the client persists as Municipality.id (shared/), and it has to match the id an Overpass-sourced lookup already cached for the same relation, or a boundary already known from before this endpoint existed would duplicate rather than merge. rings carries outer and inner rings alike, each closed implicitly (the first point is not repeated as the last) — an enclave subtracts for free under the even-odd ray cast both the backend (MunicipalityBoundary.Contains) and the client (Municipality.contains, shared/) run.

This endpoint is read-only: data is loaded by an operator running dotnet Detour.Api.dll import-municipalities <path.json> on the box that hosts the database — see backend/INSTALL.md. Same no-deletion-on-re-import rule as /api/speedlimits and /api/roads, for the same reason.

15.9 Point-of-interest data

GET /api/pois is unauthenticated, for the same reason §15.6 is — POI locations are public OSM data, not rider data. It is covered by the global per-IP limiter only, for the same reason and with the same span cap as §15.6’s /api/cameras.

Query parameters are the same bounding box as §15.6/§15.7 (minLat, minLon, maxLat, maxLon, validated the same way, capped at the same 2° span), plus an optional kind: a comma-separated list of viewpoint/food/sight — absent means every kind this table holds, the same shape as /api/roads’s classes parameter. A request that fails a bbox check is a 400.

{
  "pois": [
    {
      "id": "3e1a2b4c-....-....-....-............",
      "kind": "viewpoint",
      "name": "Belvedere",
      "lat": 49.6,
      "lon": 6.1
    }
  ]
}

name is an empty string, not absent, when the OSM element carried no name tag — plenty of small cafes don’t. PoiRoulette.randomPoi (shared/) falls back to a kind-specific label for those, the same rule it already applies to an Overpass element with no name.

This endpoint is read-only: data is loaded by an operator running dotnet Detour.Api.dll import-pois <path.json> on the box that hosts the database — see backend/INSTALL.md. Same no-deletion-on-re-import rule as /api/speedlimits, /api/roads and /api/municipality, for the same reason.

16. Limits and defaults

Setting Default
Access token lifetime 15 minutes (realm)
Session idle / max 90 days (realm)
Handle 3–24 characters, ^[A-Za-z0-9_.-]{3,24}$
Email ≤ 254 characters
Request body 64 MB, compressed and decompressed
Group name 1–40 characters
Circle members 15
Badges per account 200
Badge id ≤ 40 characters, ^[a-z]+_[0-9]+$
Shared route payload 512 KB
Shared routes per (recipient, sender) 50
Route inbox page 100 newest
Route stops ≥ 2
Circle place payload 64 KB
Circle place radius > 0 m and ≤ 50 000 m
Circle places per (circle, owner) 50
Presence events per circle 500 newest
Voice chunk ≈20 000 base64 characters
Destination candidates per offer 1–3
Destination name ≤ 80 characters
Dashboard: rides per request ≤ 500
Dashboard: trace thinning 1 in 1 … 1 in 50
Cache duration 120 s, fail-safe 600 s

Every cap in the second half of that table lives in DetourLimits — one file, because each exists so that one client cannot grow another’s data without bound. Changing one is a product decision, and the entity that enforces it, the column sized for it and the test that pins it all read from there.

17. What changed when identity moved to Keycloak

Recorded because the app, the docs and half the deployment advice predate it.

  1. Passwords, sessions, resets, invites and admin sessions are gone from this service. The rules they encoded did not disappear — fail-closed registration, uniform answers, a single live reset link, a reset that revokes everything — they are realm configuration now, and REALM.md is where each one is accounted for.
  2. Dashboard API keys stayed, because nothing in the realm issues a read-only, owner-scoped credential for a home dashboard.
  3. Administration shrank to metadata and deletion (§13).
  4. The live relay became an ordinary endpoint under /api/live instead of a second listener on its own port — which removed the separate hostname and access rule it used to need.
  5. Fog sharing, group membership and friendship stayed here. They are authorisation decisions but they are domain state, not identity, and they belong in the application regardless of who issues tokens.
  6. One large opaque blob per trip, route and place survived the port deliberately: the service cannot read what it does not parse.
  7. Trips and track points are still joined by instant, not by identifier, because a trace line carries no trip reference.
  8. Deletion is still user-driven and propagates through the sync merge. There is no server-side retention policy on trips or traces.
  9. There is no importer for the old detour.db, and passwords cannot be carried across at all — the realm never saw the old hashes.