Kiwi 1.0 is a developer-API platform. Developers sign up in a browser portal, mint scoped, IP-restricted, rate-limited API tokens, and authenticate data endpoints with those tokens. A master/admin oversees every account, token, and request.
Built as a reusable platform (accounts, tokens, scopes, rate limiting, usage metrics, email, admin, observability) with product endpoints added on top. The first data surface is live: Trove game data under
/v1/rotations/*(server time, bonuses, merchant timers, and a news relay).GET /openapi.jsonis always current.
Fully dockerized; Mongo and Redis persist to local folders via bind mounts (no Docker named volumes). Includes a developer-portal SPA and a static docs site.
- Python 3.13 (Beanie 2.1 does not yet support 3.14) · FastAPI + Uvicorn
- Beanie 2.x ODM on PyMongo's native async client · MongoDB 7
- Redis 7 - sliding-window rate limiting, login lockout, OAuth state, write coalescing, idempotency
- Argon2 password hashing · PyJWT sessions + refresh rotation · hashed API tokens
- Captcha: Cloudflare Turnstile (default) or hCaptcha · Email via Postfix (Jinja2 + durable outbox)
cryptography(GitHub secret-scanning signature verification)
- Accounts - signup (captcha + anti-spam + disposable-email block + HIBP breach check), email verification, password reset, change email/password, profile edit, GDPR export + account deletion.
- Sessions - short-lived access JWT + rotating single-use refresh tokens;
token_versionfor instant global invalidation; view/revoke active sessions; "log out everywhere". - GitHub OAuth - optional "Sign in with GitHub" (verified-email linking, one-time code exchange).
- API tokens -
kiwi_<body>_<checksum>format (self-validating + secret-scanning friendly), Discord-style bitmask scopes, IP allowlist (exact + CIDR), expiry, rotation, revoke-with-reason, 3/day creation cap, 6-month inactivity auto-revoke, expiry-warning emails. - Secret scanning -
POST /secret-scanning/github(ECDSA-verified) auto-revokes leaked tokens. - Rate limiting - Redis sliding-window (Mongo fallback), per-endpoint overrides,
X-RateLimit-*Retry-Afterheaders, daily admin digest of 429s.
- Usage metrics - every token-authenticated request recorded (buffered), TTL-expired; per-user, per-token, and global activity aggregations; cursor-paginated raw event feed.
- Admin - superuser-only (
/admin/*), enforced server-side: users, tokens, usage, revoke-any. - Platform plumbing for new endpoints - cursor pagination + standard
Pageenvelope,Idempotency-Keyreplay safety, request-id correlation (X-Request-ID) + structured logs, consistent error envelope.
Live Trove game data, ported from BetterTroveTools, grouped by function (most of the API is
Trove, so it's organized by what the data is, not the game). All GET, read-only; timestamps are
real-UTC unix seconds. Full schema in /openapi.json.
rotations,feeds,codexesandbttare public - callable with no token at a stricter per-IP rate limit (30 req/min/IP by default). Send a token carrying the scope to get the full per-token limit (120 req/min).codexesgets 5× both budgets (150 req/min/IP anonymous, 600 req/min/token) since it's lightweight reference data. A revoked/malformed token still 401s; a valid token lacking the scope falls back to the anonymous per-IP budget. Every other category still requires a token with the matching scope.
rotations category - scope rotations:read (public - token optional, see above)
| Endpoint | Returns |
|---|---|
/v1/rotations/server-time |
server time, in-game day, next daily + weekly resets |
/v1/rotations/daily-buffs |
today's daily buff + full Mon→Sun rotation |
/v1/rotations/weekly-buffs |
this week's weekly buff + 4-week rotation |
/v1/rotations/corruxion |
Corruxion merchant: live timer + upcoming schedule |
/v1/rotations/fluxion |
Fluxion merchant: voting/selling timer + schedule |
/v1/rotations/gardening |
2-day / 3-day plant harvest windows |
/v1/rotations/chaos-chest |
weekly Chaos Chest: featured item (bot-captured ▸ falls back to Trovesaurus relay) + window + countdown |
/v1/rotations/chaos-chest/history?limit=&offset= |
past chaos-chest captures, newest week first |
POST /v1/rotations/chaos-chest/insert |
master-only body {name} - bot ingest, server anchors to current Tue-11:00-UTC week |
/v1/rotations/challenge/current |
hourly challenge active right now (or last window during a gap); cadence drops to half-hourly on trove Fridays |
/v1/rotations/challenge/history?limit=&offset= |
past challenge captures, newest window first |
POST /v1/rotations/challenge/insert |
master-only body {name} - bot ingest, server anchors to the active 20-min window |
/v1/rotations/calendar |
yearly calendar: all recurring rotations (buffs, merchants, gardening, biomes) as one ±365-day timeline |
/v1/rotations/delves?week= |
a week's delve rotation - floor records relayed from a community delve source (default current week; /delves/weeks lists available weeks) |
/v1/rotations/biomes |
3-hour adventure biome rotation (current + upcoming) |
/v1/rotations/wild-mana |
weekly Wild Mana biome rotation |
/v1/rotations/stampy |
weekly Stampy event biome (48h) |
feeds category - scope feeds:read (public - token optional; fetched/relayed from upstream + cached in Mongo)
| Endpoint | Returns |
|---|---|
/v1/feeds/news?limit= |
latest Trove news relayed from trovegame.com/feed (small live view; full archive at /v1/misc/news-history) |
/v1/feeds/twitch |
live Trove Twitch streams |
/v1/feeds/youtube |
recent Trove YouTube videos |
/v1/feeds/bilibili |
recent Trove Bilibili videos |
/v1/feeds/events |
ongoing Trovesaurus events (filter ?category=) |
/v1/feeds/events/categories |
distinct event categories (discovered dynamically) |
/v1/feeds/events/upcoming · /history |
events not yet started / already ended |
(Twitch/YouTube/Bilibili are fetched at source - Twitch Helix via a client-credentials app token,
the YouTube Data API, and a Bilibili search-page scrape - then cached in FeedCache. Set
TWITCH_CLIENT_ID/TWITCH_CLIENT_SECRET/YT_API_KEY in the env; the filter knobs (search query,
excluded channels/titles, per-channel cap, cutoff, count) are admin runtime_config tunables under
community_feeds.) Events are relayed from trovesaurus.com/calendar/feed and
stored (kept after they leave the upstream feed) so history/upcoming work; status is
upcoming/ongoing/ended, categories are free-form and discovered via a distinct query.
stats category - scope stats:read (raw game data, transmitted as-is - the tables do no calculation)
| Endpoint | Returns |
|---|---|
/v1/stats/power-rank |
Power Rank stat table - each source and the PR it contributes |
/v1/stats/magic-find |
Magic Find stat table |
/v1/stats/light |
Light stat table (step / permanent per source) |
/v1/stats/classes |
all 18 classes as full objects, keyed by tech_name |
/v1/stats/classes/{tech_name} |
one class by its tech_name token (e.g. knight) |
POST /v1/stats/coefficient |
(tokenless) stateless calculator → { coefficient, damage_used, formula }. Body { physical_damage?, magic_damage?, critical_damage }. Computes the in-game Coefficient = floor(max(physical, magic) damage * (1 + critical_damage/100)) (the higher damage stat is used). Needs critical_damage + ≥1 damage stat (else 400). Shares one formula with the OCR Coefficient derivation |
Each class carries a stable tech_name token (the canonical id - display name differs, e.g.
adventurer → "Boomeranger"); store it and look the class up by that token later. The Coefficient
calculator and OCR derivation call the same app.trove.stats.compute_coefficient, so they can't drift.
gems category - scope gems:read (stateless calculators - gem objects round-trip through the client)
| Endpoint | Returns |
|---|---|
GET /v1/gems/lookups |
every valid gem field value (tiers, types, elements, stats, augments, abilities) |
POST /v1/gems/generate |
roll a gem (omit any field for random) → a gem object |
POST /v1/gems/augment |
apply a focus augment to a stat by position → updated gem |
POST /v1/gems/spark · /flare |
reroll a stat type / move a proc, by stat position → updated gem |
POST /v1/gems/level-up · /set-level |
raise or set a gem's level → updated gem |
POST /v1/gems/evaluate |
score a typed-in gem: quality %, Power Rank, cost to perfect |
GET /v1/gems/stat-range |
plausible (min, max) value a stat can roll at |
GET /v1/gems/builds/options |
valid build-config field values (classes, allies, foods, flags) |
POST /v1/gems/builds/calculate |
top gem proc layouts for a build, ranked by damage coefficient |
The simulator is stateless: generate returns a gem object, the client holds it and POSTs it back to
an action endpoint with a stat_position (0/1/2) to mutate it. Nothing gem-related is stored server-side.
misc category - scope misc:read
| Endpoint | Returns |
|---|---|
GET /v1/misc/news-history?limit=&offset= |
the full Trove news archive (never pruned), newest first, paginated |
GET /v1/misc/software |
(tokenless) third-party Trove modding software, grouped by category |
GET /v1/misc/timezones |
(tokenless) timezones supported by the converter and clocks |
GET /v1/misc/time/now |
(tokenless) current time across every zone, incl. Trove server (reset) time |
POST /v1/misc/time/convert |
(tokenless) convert a time + zone (or a unix) → every zone + Discord timestamp codes |
GET /v1/misc/trove-status |
(tokenless) live Trove server status from a 60s background prober. overall rolls up the Live regions EU+US (online/maintenance/down/unknown). auth = HTTPS liveness of auth.trionworlds.com; environments.{eu,us,pts} each carry a game probe of the glsserver port (6560). The probe is deep: a region in maintenance still completes the TCP handshake (and answers the glsserver hello) before dropping the connection, so connect-only would read a false online; instead it replays the captured glsserver hello and counts the region online only if the server holds the connection open, flagging maintenance when the server drops right after the hello (or refuses/times out). Live-tunable + degrades to connect-only on any anomaly (trove_status_game_deep_probe) |
GET /v1/misc/trove-status/history?env=&days= |
(tokenless) status timeline for one environment (eu/us/pts, default eu; days 1–90) - segments (continuous status periods, open one has ended_at=null), outages, and an uptime fraction. Backs the /status page downtime history |
GET /v1/misc/supporters |
(tokenless) the project's supporters credits list {supporters:[name], count}, in display order. Same list shown on /support; admin-managed via /admin/supporters (master panel) |
Trove "server"/reset time is a fixed UTC−11. The converter takes a naive datetime interpreted in
the given timezone (trove / UTC / any IANA id) or an absolute unix, and returns the instant in
every zone plus Discord <t:unix:style> codes.
btt category - scope btt:read (public - token optional; drives BetterTroveTools' in-app update checks)
| Endpoint | Returns |
|---|---|
GET /v1/btt/releases?channel=&limit=&offset= |
BetterTroveTools GitHub releases, newest first; optional ?channel=release|beta filter |
GET /v1/btt/latest?channel= |
latest BTT version per platform (windows/linux/android) on a channel; each platform walks back independently until a release ships an asset for it |
GET /v1/btt/latest/{platform}?channel= |
latest BTT version for a single platform |
GET /v1/btt/check?installed=&platform=&channel= |
"is there an update?" - server-side version compare; returns { update_available, comparable, latest } so the client just reads a bool |
GET /v1/btt/changelog?limit_groups=&commits_per_group= |
commits grouped by tag (mirrors BTT's "Show changelog" button), newest first, "Unreleased" group leads when there are post-tag commits |
leaderboards category - scope leaderboards:read (read side; POST /insert is master-only, requires a superuser API token)
| Endpoint | Returns |
|---|---|
GET /v1/leaderboards/timestamps?limit=60 |
recent dump anchors (unix seconds at 11:00 UTC), newest first |
GET /v1/leaderboards?created_at= |
boards present at that anchor; each carries contest_type for THIS anchor + reset_kind / player_board flags |
GET /v1/leaderboards/{uuid} |
one board's metadata + full contests list |
GET /v1/leaderboards/{uuid}/entries?created_at=&limit=&offset= |
top-N entries for one board at one anchor, ranked, with day-over-day rank_delta/score_delta (when the board didn't reset since the prior snapshot) |
GET /v1/leaderboards/players/{name}/history?uuid=&limit= |
recent appearances of one player across boards (case-insensitive on name) |
GET /v1/leaderboards/players/{name}/profile |
(tokenless) public profile aggregate: recent appearances (board names + day-over-day deltas), a summary (boards, best rank, last seen), and a verified flag (true when a site account claimed + was master-approved for this name). Powers the /player/<name> page |
GET /v1/leaderboards/{uuid}/health |
board health: roster turnover + day-over-day score inflation (when comparable - no reset crossed) + competitiveness (top-N score concentration: leader share, #1/last ratio, Gini), latest snapshot vs the previous trove-day |
GET /v1/leaderboards/cheaters |
tokenless statistical-outlier flagging: MAD-Z + rank-gap + velocity. Per-evidence + per-player confidence; cached 30 min; pre-warmed at boot |
POST /v1/leaderboards/insert?timestamp=&backfill=&sync=&warm= |
master-only ingest: multipart file with the raw LeaderBot.cfg text. Default returns 202 and parses/persists in the background (so a multi-second insert can't time out the bot); counts/errors land in the ingest log. The parsed snapshot is bulk-loaded into Postgres via COPY → staging temp table → player-upsert → INSERT…SELECT (sub-second for ~720k rows); the row lands in the anchor's partition. Idempotent for a given anchor (delete-by-anchor then reload). backfill=true lifts the 14-day anchor limit (bulk re-seed from saved <unix>.cfg files). sync=true processes inline → 200 with real counts (backpressure: one dump in memory at a time). warm=false defers cache-warming. Subject to the ingest cooldown when called with an API token |
POST /v1/leaderboards/reset?drop_boards= |
master-only, destructive: TRUNCATE … RESTART IDENTITY the Postgres entry/player/activity tables (not row-by-row), and drop every cheater/activity/Redis cache + the ready pointer — a clean slate before a full re-ingest. Board metadata (incl. admin reset-cadence overrides) is kept unless drop_boards=true. Returns the deletion tallies; logged at WARNING |
POST /v1/leaderboards/warm |
master-only: wake the cache warmer to recompute the latest anchor's cheaters + activity + page snapshots. Call once after a bulk back-fill (which uploads with warm=false) |
POST /v1/leaderboards/cheaters/recompute |
master-only: drop the cached cheater results (in-process + Redis snapshots) and recompute the latest anchor from scratch — independent of the activity/class-activity histories, so a cheater-config tweak is re-evaluated without rerunning the long backfills. Inline; returns {anchor, total_flagged, boards_analyzed} |
POST /v1/leaderboards/views/recompute |
master-only: drop the page-view caches (anchor list, board lists, entry pages, board-history charts, ready pointer) and re-warm the latest captures + every board's default chart — independent of the cheaters/activity data. Inline; returns {keys_cleared, board_charts_warmed, anchor} |
POST /v1/leaderboards/reingest-backlog?clear_first= |
master-only: replay the server-side backlog — every dump the API received is auto-gzip-saved to {backlog_dir}/leaderboards/<anchor>.cfg.gz, so this re-ingests the whole history with no upload, server-paced one dump at a time, heavy compute run once at the end. clear_first=true resets first. Drop <unix>.cfg files into the folder to seed it by hand. Poll /reingest-status for progress |
GET /v1/leaderboards/reingest-status |
master-only: live re-ingest progress {running, total, done, ok, failed, last_anchor, phase, errors[], backlog_files} for the admin poll |
The bot dumps the game's LeaderBot.cfg hourly and POSTs the file. Full history is preserved in a
dedicated PostgreSQL database (separate from the app's Mongo): the entry table is RANGE-partitioned
by anchor (one partition per trove-day, 11:00-UTC boundary), so old data is just old partitions — no
hot/cold collection split. Partitioned-parent indexes (board_uuid, anchor, rank) and (player_id, anchor)
serve board-at-time and player-over-time reads; player names are normalised into a player dimension table.
leaderboards_hot_retention_days (default 3 days; runtime-tunable from the master admin panel) now just
controls how many recent days the warmer pre-warms and the page surfaces.
Archive rate limit - queries with ?created_at= older than leaderboards_archive_query_threshold_days
(default 3 - same window as the hot/warm window by convention, so "old/cold lookup" and "pays archive
rate limit" line up; runtime-tunable from the master admin panel) pay a SECOND, tighter per-token bucket
(default 10 req/min) on top of the standard per-token cap. The bucket's state is surfaced via
X-RateLimit-Archive-Limit / X-RateLimit-Archive-Remaining / X-RateLimit-Archive-Reset response headers
so clients can self-throttle. Recent queries (≤ threshold) cost only the standard cap.
activity category - scope activity:read (public - token optional; the reads are free/tokenless. Derived from the leaderboard captures; the showcase /activity page is the main consumer)
| Endpoint | Returns |
|---|---|
GET /v1/activity/current |
(tokenless) lower-bound active-player count in the most recent capture window + distinct estimate_24h / estimate_7d rollups. "Active" = a player whose score rose on some board vs the previous capture, or who newly appears with a non-zero score (a reset drop isn't a rise). Each capture's active set is materialized (activity_active table) once, so the 24h/7d figures are an indexed COUNT(DISTINCT) UNION of the windows in range - the true distinct union, monotonic (7d ⊇ 24h ⊇ 1h), not a re-scan. estimate null until two captures exist |
GET /v1/activity/history?days= |
(tokenless) time-series of activity estimates, one point per capture pair, with estimate_per_hour normalisation so missed-capture gaps don't spike the line |
GET /v1/activity/series?period= |
(tokenless) bucketed activity-level series for one period (1d/7d/1m/3m/6m/1y/all) with peak/average/latest - backs the /activity page charts |
POST /v1/activity/backfill?total_days=&chunk_days=&force=&reset= |
master-only: seeds the /series history from all stored Postgres captures so the multi-period charts have data before the hourly forward-fill. total_days=0 rebuilds ALL stored history (no lower bound); else the trailing N days. 202 + runs in the background, streaming one capture at a time (memory-safe regardless of range); coverage capped by stored history depth. The gap cutoff (windows that span a missed capture, skipped from the per-hour series) is derived from the actual median capture cadence, not a hardcoded 1h, so a non-hourly/jittery archive isn't dropped. reset=true is destructive - wipes the activity_estimate table and recomputes from scratch (implies force), to flush miscalculations from older runs |
class-activity category - scope activity:read (public - token optional. Per-Trove-class activity from the Effort 4000+i leaderboards, class_index = uuid % 1000; the showcase /class-activity page is the main consumer. Paragon 5000+i is excluded as ambiguous — neither counted nor filtered on). Two views in every payload: RAW counts everyone; CLEAN (the page default, "established", *_clean fields) keeps only players who clear BOTH per-class floors (snapshot at the window end): Power Rank (1000+i) ≥ class_activity_power_rank_threshold (default 25000) and Effort (4000+i) ≥ class_activity_effort_threshold (default 50) — both master-tunable (set either to 0 to drop that gate) — filtering new characters + alts. clean is null where unmeasurable (no Power Rank snapshot)
| Endpoint | Returns |
|---|---|
GET /v1/class-activity/current |
(tokenless) direct headcount of the latest snapshot (NOT the activity pipeline — no score-rose step): players present on a class's Effort board at the newest capture (Paragon excluded as ambiguous). Both views: active_players/share/total_active (raw) + active_players_clean/share_clean/total_active_clean (established: clears both floors) + power_rank_threshold/effort_threshold. Each class also carries effort_added/effort_added_clean (+ totals total_effort_added/total_effort_added_clean) — Effort added to that board in the latest hour (this capture vs the previous; Σ positive per-player gains, per view; null when no previous capture or the pair crosses the weekly reset). share sums to 1 but counts a multi-class player in each class (share-of-players, not distinct). window_start=window_end=snapshot anchor, duration_hours null. (Powers the donut; the time-series below stays activity-based.) |
GET /v1/class-activity/series?period= |
(tokenless) bucketed per-class series for one period (1d…all) - a shared buckets x-axis + per class values[] (raw) and values_clean[] (established) aligned to it (null where that view had no measurable window, e.g. across the weekly reset) + power_rank_threshold/effort_threshold. Backs the /class-activity multi-line chart + its Established/All toggle |
POST /v1/class-activity/backfill?total_days=&force=&reset= |
master-only: seeds the per-class history (raw + clean) from the stored captures (same memory-safe streaming as /v1/activity/backfill; the 18 Effort boards + 18 Power Rank boards load per anchor). total_days=0 = all history; reset=true wipes the class_activity_estimate table first. 202 + background. Run after changing either clean-view threshold to apply it to history (the warmer applies it to the latest window automatically) |
giveaways category - scope giveaways:read (public - token optional; the showcase /giveaways page is the main consumer. Entering is a signed-in site-user action, not part of the public API)
| Endpoint | Returns |
|---|---|
GET /v1/giveaways/ongoing |
(tokenless) currently-open giveaways (accepting entries), soonest-ending first: [{ id, title, description, prize_name, status, starts_at, ends_at, entry_count, winner_username }]. winner_username is null until drawn; the prize code is never exposed. Compute odds from entry_count. Cached 30s |
GET /v1/giveaways/upcoming |
(tokenless) scheduled giveaways not yet open, soonest-starting first (same shape; status="scheduled", entry_count 0 until it opens). Cached 30s |
GET /v1/giveaways/ended?days= |
(tokenless) giveaways ended in the last days days (default 7, max 30), most-recently-ended first (same shape; status "drawn"/"closed", cancelled excluded). Cached 30s |
events category - scope events:read (public - token optional). A push stream so consumers stop polling rotations at the top of the hour.
| Endpoint | Returns |
|---|---|
GET /v1/events/stream |
(tokenless) a long-lived text/event-stream (SSE). On connect: a snapshot of the current state of every registered source; then one message per change the instant it happens. Each message is event: <type> + JSON data: {type, data, ts}. type ∈ challenge/chaos/corruxion/fluxion/longshade/wild_mana/stampy/daily_bonuses/activity/server_status/trove_news/giveaways/game_update (a new live build was mirrored). : ping keep-alive ~20s. Fans out across uvicorn workers via Redis pub/sub; exactly-once via a SET-GET dedup guard |
market category - scope market:read (read side; POST /insert is master-only)
| Endpoint | Returns |
|---|---|
GET /v1/market/listings?name=&price_min=&price_max=&last_seen_after=&hide_expired=true&sort=&limit=&offset= |
paginated marketplace listings (default sort newest-last_seen first; hide_expired filters past 7d / stale 3h+) |
GET /v1/market/items |
item names that currently have a stored listing (sorted) |
GET /v1/misc/interest-items |
(lives under misc, tokenless) the full allow-list of items the bot tracks; admin-managed via the master panel at /admin/market/interest-items |
GET /v1/market/items/{name}/summary |
min/max/avg/median price-each + listing count for one item |
POST /v1/market/insert?timestamp= |
master-only ingest: multipart file with the raw GrainusMod.cfg text. Listings upserted by UUID - re-scrapes bump last_seen, never duplicate. Subject to the ingest cooldown (see below) when called with an API token |
Bot scrapes the in-game marketplace hourly. Listings live in Postgres (market_listing table, alongside
leaderboards); each listing's UUID v1 is the primary key; created_at is decoded from the UUID's timestamp
(so it matches when the player posted in-game); last_seen is bumped on every re-scrape (UPSERT). Only items
on gamedata/market_items.json are persisted; the rest are dropped at ingest. (The interest allow-list itself
stays in Mongo.)
Archive rate limit - passing hide_expired=false on /listings (i.e. asking for the historical
tail past the 7-day in-game lifetime) pays a SECOND, tighter per-token bucket (default 10 req/min) on
top of the standard per-token cap. Same X-RateLimit-Archive-* headers as the leaderboards archive
throttle. Market doesn't use a day-count threshold because the 7-day listing lifetime already defines
fresh-vs-historical; the limit fires on the opt-in flag instead.
Ingest cooldown (all POST /insert endpoints) - backstop against a misbehaving bot
resubmitting the same dump every few seconds. Per-token, per-endpoint bucket (default
1 submit per 5 min - runtime-tunable as ingest_cooldown_max / ingest_cooldown_window_seconds
in the master admin panel). Bucketed independently per endpoint - a leaderboards push doesn't share
a budget with a market push. Returns 429 with Retry-After once exhausted. API-token auth only:
session-JWT calls from the portal "Manual cfg ingest" card bypass this cap, so the master can replay
captured cfgs / back-fills without waiting out the window. Bots should honor Retry-After and
suppress duplicate-anchor submissions client-side.
ocr category - scope ocr:read (token required - the OCR model is CPU-heavy, so it isn't a tokenless freebie)
| Endpoint | Returns |
|---|---|
POST /v1/ocr/character |
reads the in-game character stat sheet from a screenshot (multipart file; PNG/JPEG/WebP/GIF/BMP, ≤12 MB) → { stats: { <key>: { value, unit, raw, confidence, in_range, type_match, derived } }, matched, total_known, lines }. stats keyed by canonical key (physical_damage, critical_hit, power_rank, …); value int for counts / float for percents. derived:true = computed, not read. Returns 503 if the OCR engine isn't installed, 400 on an unreadable image |
Self-hosted OCR (RapidOCR / ONNX - no external service, no LLM). The moddable UI varies wildly
(themes, fonts, columns, language), so raw OCR text isn't trusted: every recognized label is fuzzy-matched
against a CLOSED multilingual vocabulary (English + French — app/trove/gamedata/character_stats.json) so a
garbled or translated label still snaps to the right stat, and every value is sanity-checked against the
stat's expected type (int vs percent) + plausible range. in_range/type_match flag a value that parsed
but looks implausible (don't trust a stat with either false); confidence folds match-quality with those
checks. Labels whose value renders on a separate line (Power Rank in the equipment view, a detached
Coefficient) are paired across lines within a small window. Coefficient is special: it's often not shown,
so it's derived from the game's own formula — floor(max(physical, magic) damage * (1 + critical_damage/100)) —
and derived:true marks a computed value; a shown Coefficient that agrees is a confirmed read, one that
contradicts the formula is surfaced but flagged low-confidence (a cheap consistency check on the damage reads).
The accuracy core (app/trove/ocr/parse.py + vocabulary.py) is engine-agnostic and unit-tested
without any OCR dependency — the engine (engine.py) just turns pixels into the text lines it consumes, and
is swappable (Tesseract/EasyOCR) behind the same interface. The image is processed in memory, never stored.
The engine import is guarded, so the app still boots without the optional rapidocr-onnxruntime dependency
(the endpoint then returns 503); the Docker image installs it plus the libgl1/libglib2.0-0 runtime libs.
The api container ALSO serves the BTT marketing/manual site out of site/
(templates + static + ~20 MB of screenshots). Routes:
| Path | Returns |
|---|---|
GET / |
the BTT landing page (index.html) |
GET /documentation |
the user manual |
GET /commands |
searchable in-game slash-command reference |
GET /leaderboards |
hourly in-game leaderboard browser (charts, cheaters) |
GET /activity |
Player Activity - live active-player estimate + multi-period trend charts (1D…all-time) |
GET /player/<name> |
public player profile - leaderboard appearances + verified-claim badge (consumes /site/leaderboards/players/<name>/profile) |
GET /updates |
per-server (Live US / PTS) game-update file explorer + version diff + in-browser preview of small text files |
GET /support |
"support the project" landing for the navbar heart icon |
GET /status |
Trove server-status page - live EU/US/PTS state + downtime-history timeline |
GET /static/* |
site assets (bind-mounted from site/static/) |
GET /site/* |
page-side JSON proxies (leaderboards, updates) - same-origin, no token |
GET /api-info |
the old developer-card landing (lives here so / is free for the site) |
Point your reverse proxy: trove.aallyn.net → the api container's :15546,
forward all paths. api.aallyn.net keeps its existing filter to /v1/* +
/health. The site's CSP is broader than the API's (loads FontAwesome + Google
Fonts from CDN, calls api.aallyn.net for release data) - middleware picks
the right CSP per path.
Removed 2026-06:
/unlock_debugand/unlock_fpsbyte-patcher routes were deleted after Trion shipped anti-cheat. Any binary tampering is now grounds for a ban; the tools shouldn't exist anymore.
A background relayer polls the configured GitHub repo every 30 min and stores releases in Mongo, so the
endpoints serve from cache. Channels are detected from GitHub's prerelease flag (release/beta).
Platform assets are detected by file extension - .msi/.exe for windows (msi prioritized), .AppImage/
.deb/.rpm/.tar.gz for linux, .apk for android.
mods category - scope mods:read (stateless .tmod tooling - 20 MB body cap on /v1/mods/*)
| Endpoint | Returns |
|---|---|
POST /v1/mods/read |
decompile a .tmod (POST raw bytes) → header properties + file table; ?metadata_only= omits contents |
POST /v1/mods/build |
build a .tmod from header fields + files (base64) → raw bytes |
The .tmod binary format (little-endian header + LEB128 + zlib file stream + Trove's FNV-1a-variant
checksum) is ported in pure Python (app/trove/tmod.py) - no native lib. build stamps the modLoader
header KiwiAPI (where BetterTroveTools uses BTT); nothing is stored - built in memory and discarded
after sending.
updates category - scope updates:read (browse the archived game files - latest version)
| Endpoint | Returns |
|---|---|
GET /v1/updates/branches |
tracked branches (live-us, pts) with current version + file count |
GET /v1/updates/{branch}/versions |
captured version history, newest first |
GET /v1/updates/{branch}/changes?version=&ordinal=&type= |
per-file diff a version introduced (added/modified/removed paths); latest if unpinned |
GET /v1/updates/{branch}/tree?prefix= |
one directory level (ls-style); empty prefix = root |
GET /v1/updates/{branch}/file?path= |
(tokenless) a single file's bytes, streamed from the blob store (/file/meta for hash+size) |
GET /v1/updates/{branch}/file/view?path= |
preview payload for the in-browser viewer: UTF-8 text when the file is small (≤512 KB) + text-like, else viewable:false with a reason (too_large/binary/missing) so the client falls back to the raw /file download |
Kiwi mirrors Trove's update CDN into a content-addressed, deduped store (see "Game-file archive" below); these endpoints serve the latest captured version. Loose files and TFA-extracted files are browsed identically. Historical-version querying is the next layer.
codexes category - scope codexes:read (public - token optional; structured game data parsed from the archive)
| Endpoint | Returns |
|---|---|
GET /v1/codexes/types |
the codex types present for a branch, each with its entry count |
GET /v1/codexes/search?q=&type=&category=&tradable=&sort= |
cross-type search/filter (the unified search surface); each result carries its type |
GET /v1/codexes/{type}?search=&category=&tradable=&sort=&limit=&offset= |
entries of one type - filterable, sortable, paginated |
GET /v1/codexes/{type}/categories |
distinct categories (+ counts) in a type, for filter dropdowns |
GET /v1/codexes/{type}/entry?path= |
a single entry by its source prefab path |
Eight typed datasets - ally, mount, dragon, memento, recipe, item, fish, badge - parsed
from Trove's prefabs/*.binfab files (a protobuf-like wire format) with names/descriptions resolved via
the languages/ locale tables. Parsed rows live in Postgres (codex_entry table, alongside
leaderboards/market), keyed by (branch, path) - the two modes (live-us + pts) are just rows with a
different branch, and content_sha256 ties each row back to the exact source binfab. The indexer runs
after each archive sync: a full build the first time (or after switching to Postgres), then only the
changed prefabs (driven by the version delta), so a routine patch never re-parses the rest of the game.
The table is disposable - rebuildable from the archive at any time. All endpoints default to the live-us
branch (?branch=pts for PTS). Each entry carries identity (name, category, description, tradability)
plus decoded collectible bonuses: mastery (normal, from meta/multipliers.binfab), mastery_geode
(geode-mode, from meta/geode_multipliers.binfab), power_rank, and a data JSONB object of numeric
stat bonuses, visible/hidden ability refs, and (for geode companions) per-level upgrade-tree bonuses.
More are added following the conventions in "Adding the real endpoints" below.
| Public domain | Local target | What it is |
|---|---|---|
https://api.aallyn.net |
127.0.0.1:15546 |
Production API: /v1/* (e.g. /v1/rotations/*) + /health |
https://dev.aallyn.net |
127.0.0.1:25470 |
Developer portal SPA (login, tokens, activity, account, admin) |
https://docs.aallyn.net |
127.0.0.1:25468 |
Static documentation site |
The portal (dev.aallyn.net) is a no-build vanilla-JS SPA that calls the API
cross-origin (CORS-enabled). Programs authenticate the API with an API token
only - there is no programmatic login; humans use the portal.
Feature modules grouped by endpoint path (vertical slices); cross-cutting
infrastructure in app/core/.
app/
├── main.py # app assembly: routers, middleware order, lifespan
├── core/ # config, database, security, errors, redis, mailer,
│ # ratelimit, limits, pagination, idempotency,
│ # observability (request-id), scopes, maintenance, …
├── auth/ # /auth/* signup, login, sessions, oauth, account - owns User, Session
├── tokens/ # /tokens/* mint/list/edit/rotate/revoke - owns ApiToken
├── usage/ # UsageEvent model + buffered recorder + aggregations
├── admin/ # /admin/* superuser metrics + revoke + events feed
└── scanning/ # /secret-scanning/github partner webhook
portal/ # developer-portal SPA (nginx + static app.js/styles.css)
docs/ # static docs site (guide + Redoc reference + llms.txt)
scripts/ # backup-mongo.sh / restore-mongo.sh
tests/ # unit (no deps) + integration (testcontainers Mongo+Redis)
The platform is built so a new endpoint is a small, consistent addition:
- Create
app/<feature>/withrouter.py(+models.pyif it stores data). - Auth + scope:
Depends(require_scope("<resource>:<action>")). Append the new scope toapp/core/scopes.py(bits are permanent - never renumber/reuse). - Lists:
Depends(list_params)+paginate_newest_first(...)→ returnPage{items, next_cursor, has_more}. - Writes return
201/200/204; any write honours anIdempotency-Keyheader automatically. - Register the router in
app/main.pyand any newDocumentinapp/core/database.py. - Errors:
raise APIError(status, ErrorCode.x, "msg")- never a bareHTTPException.
cp .env.example .env # then set SECRET_KEY, MONGO_*, REDIS_PASSWORD (+ captcha/SMTP)
docker compose up -d --build
curl http://127.0.0.1:15546/health # {"status":"ok"}Mongo lives in ./data/mongo, both bound from the host. Whenever a model's
indexes change, wipe the data dir (no migration code is carried): docker compose down && rm -rf data/mongo && docker compose up -d --build.
| Variable | Required | Notes |
|---|---|---|
SECRET_KEY |
yes | JWT signing key - python -c "import secrets;print(secrets.token_urlsafe(48))" |
MONGO_ROOT_PASSWORD / MONGO_APP_PASSWORD |
yes | Mongo root + least-privilege app user |
REDIS_PASSWORD |
yes | Redis auth |
CAPTCHA_SECRET / CAPTCHA_SITEKEY |
prod | Captcha is enforced only when both are set |
ADMIN_EMAIL / ADMIN_PASSWORD |
no | Bootstraps/promotes the master superuser on startup |
SMTP_HOST (+ SMTP_*, MAIL_FROM) |
prod | Postfix relay; if unset, email is logged instead of sent |
GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET |
no | Enables "Sign in with GitHub" |
All services bind to 127.0.0.1; the proxy enforces the api/dev split (only
/v1/* + /health are routed on api.aallyn.net). Uvicorn runs with
--proxy-headers so client IPs (used for rate limiting) reflect the real client.
server { # production API - only /v1 + /health
server_name api.aallyn.net;
client_max_body_size 8m; # default cap (matches the app)
location /v1/ { proxy_pass http://127.0.0.1:15546; }
location /v1/mods/ { client_max_body_size 20m; proxy_pass http://127.0.0.1:15546; } # .tmod tools
location = /v1/leaderboards/insert { client_max_body_size 20m; proxy_pass http://127.0.0.1:15546; } # bot cfg dump (~16 MB at ~20k entries/board)
location = /v1/market/insert { client_max_body_size 20m; proxy_pass http://127.0.0.1:15546; } # bot cfg dump
location = /health { proxy_pass http://127.0.0.1:15546; }
location / { return 404; }
}
server { server_name dev.aallyn.net; location / { proxy_pass http://127.0.0.1:25470; } }
server { server_name docs.aallyn.net; location / { proxy_pass http://127.0.0.1:25468; } }The app caps request bodies at 8 MB, except /v1/mods/*, /v1/leaderboards/insert, and
/v1/market/insert at 20 MB (the .tmod tools and the bot's raw cfg dumps). The proxy must
allow at least as much on those paths or it rejects large uploads before they reach the app.
Every error uses one envelope (branch on code, not message); each carries a
request_id (also X-Request-ID):
{ "error": { "code": "rate_limited", "message": "…", "details": null, "request_id": "req_…" } }Default limits: signup 5/h/IP · login 10/5min/IP · API 120/min/token · token
creation 3/day. Responses carry X-RateLimit-Limit/Remaining/Reset; a 429 adds
Retry-After.
python -m venv .venv && . .venv/bin/activate # 3.11–3.13
pip install -r requirements-dev.txt
ruff check app tests # lint
pyright # types (advisory)
pytest tests/unit # unit tests (no services needed)
pytest tests/integration -m integration # needs Docker (testcontainers spin up Mongo + Redis)CI (.github/workflows/ci.yml): ruff · pyright (advisory) · unit · integration · pip-audit · docker build.
scripts/backup-mongo.sh (gzip mongodump, prune, creds from the container env)
and scripts/restore-mongo.sh (--drop restore). Schedule the backup via cron and
add an off-box copy (a local-only backup won't survive disk loss).
./docs is a static site (getting-started guide, a Redoc API reference rendered
from the live OpenAPI spec, and an llms.txt reference for AI assistants).