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.
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.
These are the product’s promises. Each is enforced in exactly one place, and a change must keep that property.
| 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. |
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.
A token is accepted when it:
Idp:Authority, not a prefix match);detour-api);preferred_username matching
^[A-Za-z0-9_.-]{3,24}$.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.
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.
A rider can read their own handle, address, fog-sharing preference, aggregate stats and badge map, and can set the fog-sharing preference.
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:
dist_100000) and are capped per account.Returned: the merged union of trips (newest first), traces, badges, saved places and ride titles.
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 |
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.
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.
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.
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.
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.
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.
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.
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.
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.
/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.
/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:
schema bumps only on a breaking change to an existing field. Adding a
feature string or a field is additive and does not bump it. A client seeing
a schema higher than it knows still reads the fields it recognises rather
than refusing the document.#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.
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.
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.
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.
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.
| Setting | Default |
|---|---|
| Access token lifetime | 15 minutes (realm) |
| Session idle / max | 90 days (realm) |
| Handle | 3–24 characters, ^[A-Za-z0-9_.-]{3,24}$ |
| ≤ 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.
Recorded because the app, the docs and half the deployment advice predate it.
/api/live instead of a
second listener on its own port — which removed the separate hostname and
access rule it used to need.detour.db, and passwords cannot be
carried across at all — the realm never saw the old hashes.