This repo's API contract is defined by the committed OpenAPI spec:
openapi/transcendence.v1.json
If you change endpoints, request/response shapes, or auth semantics, update the spec (see README.md and docs/DEVELOPMENT.md) in the same PR.
High-level model:
AppOnly:X-API-Key: <key>UserOnly:Authorization: Bearer <jwt>AdminOnly:Authorization: Bearer <jwt>withadminrole claimAppOrUser: accepts either
The Next.js web frontend uses route handlers as a BFF:
- Browser talks to Next under
/api/session/*and/api/trn/* - Next talks to the backend at
TRN_BACKEND_BASE_URL - Tokens live in HttpOnly cookies on the web domain (never exposed to browser JS)
- AppOnly calls attach
X-API-Keyserver-side fromTRN_BACKEND_API_KEY /api/trn/app/*is allowlisted to approved AppOnly routes and exact authenticated operation GETs (not a generic passthrough)- Proxy route handlers reject invalid path segments (
./..)
Read-heavy endpoints are protected by server-side fixed-window rate limiting and may return:
429 Too Many Requests
Auth endpoints use dedicated per-client rate limits: /api/auth/register, /api/auth/password-reset, and /api/auth/password-reset/complete share the conservative auth-register policy; /api/auth/login uses auth-login; /api/auth/refresh + /api/auth/logout share auth-refresh.
All error responses use RFC 7807 ProblemDetails (application/problem+json):
- Empty-body
4xx/5xx(e.g.NotFound()), model-validation failures, and unhandled exceptions are ProblemDetails automatically. - Authentication challenges may return an empty
401; clients must rely on status, not a required error body. - Body-carrying errors are normalized: a bare string body (
BadRequest("…")) is rewrapped as ProblemDetailsdetail, and admin operations returnProblem(title, detail, status)rather than the legacy{ message, detail }object. - Model-validation failures (e.g.
POST /api/lol/summoners/multi-search) returnValidationProblemDetails— ProblemDetails plus a per-fielderrorsmap. The schema is published in the OpenAPI contract. - The OpenAPI document declares only
application/problem+jsonfor these error schemas, matching the runtime response rather than the ordinary JSON/text formatter list.
The service's routes are intentionally unversioned because the API is an internal contract consumed
by the lock-step web app and generated client. The OpenAPI document's v1 label versions the
published schema snapshot; introduce negotiated URL or header versioning before supporting an
independently deployed external client.
Side-effecting operations that acknowledge success with a message return a typed OperationResult ({ message, id? }) instead of an anonymous body, so the shape is documented in the contract and typed in the generated client. Pure side-effect operations may return 204 No Content.
This is a navigational summary; the OpenAPI spec is the source of truth.
GET /api/lol/summoners/{region}/{name}/{tag}GET /api/lol/summoners/searchPOST /api/lol/summoners/multi-search(AppOnly)POST /api/lol/summoners/{region}/{name}/{tag}/refresh(UserOnly)GET /api/lol/summoners/{summonerId}/stats/overviewGET /api/lol/summoners/{summonerId}/stats/championsGET /api/lol/summoners/{summonerId}/stats/rolesGET /api/lol/summoners/{summonerId}/stats/rank-historyGET /api/lol/summoners/{summonerId}/matches/recentGET /api/lol/summoners/{summonerId}/matches/{matchId}GET /api/lol/summoners/{summonerId}/matches/{matchId}/timeline(public,expensive-readrate limit; per-minute team gold/XP and difference curves, or404before timeline ingestion)
Default stats scope:
- The Riot-ID lookup always returns
200withSummonerLookupResponse, whosestatusisready,refreshing, ormissing.profileis populated only forready;refreshingincludes the poll URL and retry hint. This keeps the read response single-typed. The separate signed-in refresh POST retains202 Acceptedbecause it queues work. stats/overview,stats/champions, andstats/rolesare computed from ranked solo/duo sample data.GET /api/lol/summoners/{region}/{name}/{tag}uses the active season for profile overview and champion stats. When a signed-in manual refresh has produced full-history facts, those profile stats use the durable active-season aggregate; otherwise they fall back to retained match detail currently present in the database.matches/recentdefaults to full stored history and can be filtered by queue metadata.stats/rank-historyis app-observed history from stored snapshots. Riot League-V4 exposes current league entries, not an official per-account past-season rank history endpoint. The web profile converts tier + division + LP into a monotonic ladder-points series so promotions do not look like LP resets, labels it as observed (not per-game Riot history), and appends the current rank only when it differs from the latest snapshot.- Profile champion entries (
topChampions[],topMastery[]) carrychampionIdonly; champion display names are resolved client-side from static (DDragon) data, sochampionIdis the single source of truth.
Profile responses include additional season/history metadata:
activeSeason:{ seasonKey, displayName, queueScope }fullHistory: nullable status/coverage object with backfill status, scan counters, stored completed solo/duo count, current Riot ranked wins/losses/total, count delta, coverage status, and classifier version
GET /api/lol/summoners/{summonerId}/matches/recent supports:
page/pageSizequeueFamily(optional; e.g.ALL,RANKED_SOLO_DUO,RANKED_FLEX,NORMAL_SR,ARAM,CLASH,ARENA,ROTATING,BOT,CUSTOM,OTHER)queueIds(optional repeated query param for explicit queue IDs)championId(optional; filters before pagination)- Responses include stable
facets.queuesandfacets.championIdscollected across the summoner's full stored history, independent of the current page and active filters. - Each match includes
performance, a team-relative impact readout:scoreis a deterministic1.0to10.0weighted percentile score within that match and teamteamRank/teamSizemake the comparison scope explicitlabelisMVPfor the top-ranked player on the winning team,ACEfor the top-ranked player on the losing team, andnullotherwisekillParticipation,damageShare,goldShare,visionShare, andcsPerMinexpose the real inputs used for UI explanations- weights are kill participation 30%, champion damage 25%, vision 15%, gold 10%, farm 10%, and survival 10%; each input is percentile-ranked within the participant's team before weighting
- the score is computed from existing participant rows when the cached recent-history response is built. It does not require a migration, stored label, or background precomputation.
GET /api/lol/summoners/search supports:
region(required; platform route or alias such asNA1orna)q(required; min length 2, supportsgameNameorgameName#tagprefix forms)limit(optional; default8, max10)- Autosuggest only returns summoners with at least one stored match participant (to avoid low-signal entries)
- The name prefix is matched through
IX_Summoners_SearchNamePattern(text_pattern_ops), the only index that can serveLIKE 'PREFIX%'under the database'sen_US.utf8collation
POST /api/lol/summoners/multi-search supports:
region(required; platform route or alias such asNA1orna)summoners(required array; min1, max5)- Each
summoners[]entry requiresgameNameandtagLine - Returns only already-stored data (no refresh side effects); includes per-summoner stats plus team insights in a single response
Stats and profile read surfaces now fail closed on backend errors:
GET /api/lol/summoners/{summonerId}/stats/*andGET /api/lol/summoners/{summonerId}/matches/*return500ProblemDetails on internal failures.GET /api/lol/summoners/{region}/{name}/{tag}also returns500ProblemDetails when dependent stats aggregation fails.
GET /api/lol/summoners/{summonerId}/matches/recentrunesremains a compact summary (primaryStyleId,subStyleId,keystoneId)runesDetailnow includes full selections:primarySelections(4)subSelections(2)statShards(3)
queueIdis included alongsidequeueType
GET /api/lol/summoners/{summonerId}/matches/{matchId}- Participant runes continue to return full selections (
primarySelections,subSelections,statShards) - Every participant includes the same
performancereadout used by recent history, allowing the expanded scoreboard to show team rank and MVP/ACE labels without a second scoring implementation. - Match payload includes
queueIdandqueueType
- Participant runes continue to return full selections (
POST /api/lol/summoners/{region}/{name}/{tag}/refreshrequires a signed-in user (UserOnly) and returns401when no user JWT is present.- The Next.js app calls this through
/api/trn/user/lol/summoners/{region}/{name}/{tag}/refresh; the anonymous/api/trn/public/*proxy does not forward refresh POSTs. - The refresh is implicitly treated as a high-priority refresh request.
- There is no priority request parameter. Accepted responses use the owned operation contract below.
- When high-priority refresh demand is active, lower-priority Riot-calling background jobs are temporarily paused.
- After the normal quick refresh completes, signed-in manual refreshes enqueue a full-history profile backfill. The backfill scans all Riot-searchable queues for that PUUID and persists compact per-summoner facts/season aggregates independently of the raw match-detail retention window.
POST /api/lol/summoners/{region}/{name}/{tag}/refresh and POST /api/admin/pro-summoners/{id}/refresh share deterministic 202 Accepted semantics:
- Both newly queued and coalesced requests return required
OperationAcceptedResponse. - Coalescing shares execution, not authorization: each authenticated user/application receives an opaque owner-scoped request ID. Both-header requests resolve each auth scheme independently.
Example (OperationAcceptedResponse):
{
"operationId": "11111111-1111-1111-1111-111111111111",
"statusUrl": "/api/lol/operations/11111111-1111-1111-1111-111111111111",
"retryAfterSeconds": 2
}GET /api/lol/operations/{operationId} requires AppOrUser, returns no-store, and uses
401 for anonymous requests and 404 for unknown or another owner's request. Web clients
reach it through exact GET-only user/app BFF routes; the public BFF denies access. Construct
allowlisted paths from the ID rather than forwarding credentials to arbitrary returned URLs.
OperationStatusResponse includes the request ID, kind, canonical target, status, retry hint,
queued/updated/completed timestamps, sanitized error code, phases, and OperationCompletionResult.
Statuses are queued, running, retrying, succeeded, partial, or failed.
Recent refresh succeeds only after profile and configured recent imports persist and relevant
caches are invalidated. partial identifies incomplete imports; optional mastery failures are
warning codes. Match counts are unique within the operation, not per-scope attempts; unknown
deferred match-ID pages yield a nullable deferred count, never a fabricated number of matches.
Full-history phase IDs identify independently polled child operations; parent success does not
certify child completion. Stored profile history coverage remains a separate read projection.
Successful probes include snapshotId, observedAtUtc, and the exact persisted liveGame
payload. A different snapshot or newer read timestamp does not complete a requested probe.
Only a verified Spectator 404 certifies offline; 204/422, null/malformed 200, auth/rate/network
failures cannot publish a fresh offline observation. This is a coordinated current contract,
without legacy accepted DTOs or cross-version fallbacks.
GET /api/lol/analytics/tierlistGET /api/lol/analytics/regionsGET /api/lol/analytics/statusGET /api/lol/analytics/datasetGET /api/lol/analytics/patchesGET /api/lol/analytics/champions/{championId}/winratesGET /api/lol/analytics/champions/{championId}/profileGET /api/lol/analytics/champions/{championId}/buildsGET /api/lol/analytics/champions/{championId}/pro-buildsGET /api/lol/analytics/champions/{championId}/matchupsGET /api/lol/analytics/champions/{championId}/synergiesGET /api/lol/analytics/pro/championsGET /api/lol/analytics/pro/playersGET /api/lol/analytics/itemsGET /api/lol/analytics/items/{itemId}GET /api/lol/analytics/runesGET /api/lol/analytics/runes/{runeId}
Analytics cache invalidation is intentionally exposed only through the audited
POST /api/admin/cache/invalidate operation.
Public, anonymous, search-read rate limit. Returns DatasetStatsDto: the scale and freshness of
the stored match corpus, shown on the home page and /about.
matchesStored: exact count of successfully fetched matches, all queues.matchesLast24Hours: matches fetched in the 24 hours beforecomputedAtUtc.matchesPerDayLast7Days: matches fetched per day, averaged over the last 7 complete UTC days (today is excluded because it is still filling).activePatch/activePatchMatches: the active analytics patch and the stored matches on it.playersIndexedEstimate: approximate summoner row count from PostgreSQL planner statistics (pg_class.reltuples), not acount(*).nullwhen the table has never been analyzed.databaseSizeBytes:pg_database_size, ornull.crawledPlatforms: enabled ingestion platforms;platforms: stored and last-24-hour matches per platform, largest first.lastMatchIngestedAtUtc,computedAtUtc: freshness of the data and of the snapshot.
The response is a snapshot the worker recomputes every 5 minutes (refresh-dataset-stats); a request
never counts rows. 200 carries Cache-Control: public, max-age=60, stale-while-revalidate=300.
404 (ProblemDetails) means no snapshot has been computed yet, for example on a fresh database before
the job's first run; clients hide the figures rather than showing zeros.
Display metadata for champions, items, runes and summoner spells, so clients do not fetch Riot's CDN themselves.
GET /api/lol/static/versionsGET /api/lol/static/{version}/championsGET /api/lol/static/{version}/itemsGET /api/lol/static/{version}/runesGET /api/lol/static/{version}/spells
{version} is a Data Dragon version (16.17.1) or the literal latest. Anything
that is not version-shaped is a 400 — the value reaches an upstream URL, so it is
validated rather than trusted.
Every response carries an absolute iconUrl. Clients must not construct CDN
paths. That is the whole point of these endpoints: the URLs happen to point at Data
Dragon today, and moving the bytes behind this API later becomes a server-side
change with no client release. Champion responses also carry splashUrl.
Three details these endpoints exist to stop every client re-learning:
- Champion
idis the NUMERIC id that match data carries;aliasis Data Dragon's string handle and is what icon filenames use. They differ for some champions (WukongisMonkeyKing). - Summoner spell
idis likewise the numeric id, not the handle — Data Dragon's own payload inverts these. runesreturns individual runes, the top-level STYLES (a rune page'sprimaryStyleId/subStyleIdpoint at those), and stat shards. Riot does not publish shards inrunesReforged.jsonat all, so a client without them renders three of every rune page's nine slots as bare numbers.
Responses are cached server-side, so the CDN is hit roughly once per patch for the
whole user base. The version LIST has a short TTL because it is how a new patch is
discovered; per-patch content is cached for 24h since a shipped patch never changes.
A 503 means Data Dragon is unreachable, as distinct from a 400 for a bad
request — the desktop client classifies those differently to decide whether to show
an outage screen.
These are read-only and served from the CDN rather than the ChampionVersion /
ItemVersion / RuneVersion tables. Those tables exist for analytics (balance
hashes, role pooling) and carry neither summoner spells nor rune icon paths.
GET /api/lol/leaderboards
Returns a public ranked leaderboard for one platform region. Query parameters:
region: public region slug or platform token such asna,NA1,euw, orEUW1(defaultna)queue:soloorflex(defaultsolo)championId: optional positive champion ID; when present, ranks tracked champion specialists from the active ranked seasonrole: optionalTOP|JUNGLE|MIDDLE|BOTTOM|UTILITY; applies only withchampionIdlimit:1to100(default100)minimumChampionGames:1to100(default5)
Regional boards are ordered by tier, league points, wins, and losses. Champion boards are ordered by champion-game sample, ranked tier, league points, and champion win rate. Responses include the resolved platform region and queue, generation time, profile identity, current rank and record, plus champion games, wins, win rate, and KDA when champion filters are active. Champion-board region filtering uses the match's recorded platform region, so an account transfer does not move historical games between regional boards.
Early-patch semantics:
- Analytics endpoints default to the active patch and support a
patchquery parameter for stored historical patches. - Historical patch requests do not fall back to another patch; unknown patch values return empty
200 OKpayloads with the requested patch echoed. - Responses now include
samplemetadata for UI messaging:sampleStatus(sufficient,low_sample,no_data)sampleSizeminimumRecommendedSampleSizepatchAgeHoursisEarlyPatchWindowpatchPhase(bootstrap,provisional,maturing,steady)isProvisional
low_sampleandno_dataare expected during early patch windows while ingestion ramps up.- Tier-list entries carry
movement/previousTierfor the persisted region=ALL default scopes (rank scopeallorEMERALD_PLUS); they are omitted (movementSAME/null) for specific-region or exact-tier views (computed live) and when no previous patch exists.
queue query semantics across tier list, patch options, champion profile, win rates, builds, and matchups:
solo(or omitted): Ranked Solo/Duo (RANKED_SOLO_DUO)flex: Ranked Flex (RANKED_FLEX)aram: ARAM (ARAM)arena: Arena (ARENA)- Unsupported values return
400 Bad Requestinstead of silently falling back to Solo/Duo. - Solo/Duo and Flex retain lane roles. ARAM and Arena use the synthetic role
ALL; their champion pages hide lane-only matchup UI and return an empty matchup collection because those modes have no stable lane pairing. - Flex rank scopes use current Flex rank. ARAM/Arena rank scopes use current Solo/Duo rank as a player-skill segment.
Tier methodology (GET /api/lol/analytics/tierlist):
- Tiers are per-role-first: a champion is graded only against same-role peers. The unified ("All Roles",
roleomitted) list shows each champion at its primary (most-played) role;roleon each entry is that graded role. - The grade is driven by strength = win-rate delta vs the role baseline, with empirical-Bayes shrinkage toward that baseline (low-sample champions shrink to ~0 delta). Tiers are absolute cutoffs on that delta (config-driven), so
Smeans a real, sample-resolvable edge andSmay be empty on a balanced patch. The prior-fit and tier eligibility gates scale with the selected role volume between calibrated safety bounds;isLowSample=truechampions are clamped toBso thin evidence cannot produce an extreme grade in either direction. - Pick rate and ban rate are not in the strength score — they feed a separate popularity axis (
contestedScore). TierListResponse.confidencereports whether the selected scope has a meaningful tier spread:RESOLVED(multiple tiers and at least one champion over the adaptive floor),FLAT(adequate samples but one tier), orINSUFFICIENT(no champion clears the floor / no data).TierListEntryfields includestrengthScore(signed delta vs role baseline),contestedScore(popularity/meta-presence index),roleBaseline(the role's baseline win rate), andisLowSample.compositeScoreis retained as a back-compat alias ofstrengthScoreand is slated for removal.
rankTier query semantics across tier list, win rates, builds, and matchups:
all(or omitted): no rank filter- Exact tier token:
IRON|BRONZE|SILVER|GOLD|PLATINUM|EMERALD|DIAMOND|MASTER|GRANDMASTER|CHALLENGER - Tier scope token:
EMERALD_PLUS(aliasEMERALD+) =EMERALDand above
region query semantics across tier list, win rates, builds, and matchups:
ALL(or omitted): global aggregate across enabled ingestion regions- Concrete platform region token: for example
NA1|EUW1|EUN1|KR. Historical analytics are keyed by the match's recorded platform region, not a participant account's current region after a transfer. - Supported public region tokens are discoverable via
GET /api/lol/analytics/regions - Tier list, builds, and matchup responses now echo the resolved
regionfield so the UI can badge active scope without guessing
patch query semantics across tier list, win rates, builds, matchups, and pro builds:
- Omitted: use the backend-owned active analytics patch
- Exact patch token: query that patch's persisted match/static-data slice
- Available patch options are discoverable via
GET /api/lol/analytics/patches
GET /api/lol/analytics/status returns the backend-owned active LoL analytics patch metadata:
patchactivePatchReleasedAtUtcactivePatchDetectedAtUtc
GET /api/lol/analytics/patches returns public patch options:
patchreleasedAtUtcdetectedAtUtcisActivematchCountqueueFamilyrankedSoloDuoMatchCount(backward-compatible alias ofmatchCount; usematchCountfor new clients)
Item and rune analytics are public Ranked Solo/Duo corpus reads. They accept optional region
and patch filters with the same semantics as champion analytics. Index responses rank resources
by observed player-games and include pick rate, win rate, and the three most common champion-role
pairs. Detail responses expand that breakdown to the top 100 champion-role samples. Item rows are
deduplicated per participant and restricted to completed build-impact items or upgraded boots;
rune rows exclude stat shards. All rates are 0..1 ratios. Champion-level pickRate uses that
champion-role's total games as its denominator, while shareOfResourceUses uses all observed uses
of the selected item/rune. These are descriptive correlations, not causal item/rune power scores.
GET /api/lol/analytics/champions/{championId}/synergies accepts role, rankTier,
region, queue, and patch. It measures actionable same-team role pairs: Bottom+Utility,
Jungle+lane, and lane+Jungle. Each partner includes games, wins, pair win rate, pick rate within
the focal champion-role sample, raw win-rate delta from that focal baseline, and a Wilson
confidence score. bestPartners is ordered by confidence-adjusted lift so tiny lucky samples do
not outrank supported pairings. The same synergies payload is included by the aggregate
/profile response; roleless queues return an empty pairing set. In /profile the field is
null when the scope's synergies are not cached and their computation does not finish within 2s
(see the profile notes below).
GET /api/lol/analytics/champions/{championId}/builds includes full rune setup per build:
builds[]is ordered bygames × winRate(observed wins), balancing sample support and results rather than sorting by raw win rate alone. A later variant can therefore have a higher rate on fewer games.primaryStyleId,subStyleIdprimaryRunes(4),subRunes(2),statShards(3)- Each build (and each build-path variant below) carries
games,winRate, andpickRate.pickRateis the share of scoped games using that variant (0–1): mainbuilds[]vs the champion+role+scope total; build-path sections vs their own section denominator. It is additive and defaults to0for snapshots computed before the field existed (clients hide a0pick rate); real values populate on the next analytics refresh. - Build item lists include only completed, build-impact items (no components, trinkets, wards, or consumables).
- If patch item metadata is temporarily incomplete, the service uses a legacy exclusion fallback so builds still render while metadata refresh catches up.
- The response also carries a sectioned, timeline-derived build path (all optional —
null/empty when timeline data has not been ingested for the champion/patch):summonerSpells[]— top normalized spell pairs withgames/winRate/pickRateskillOrder—{ firstThree, maxOrder, games, winRate, pickRate }(e.g.firstThree: "QWE",maxOrder: "Q>E>W")startingItems[]— top opening item sets withgames/winRate/pickRateboots[]— boots options withgames/winRate/pickRatecoreBuildPath[]— the ordered 1st→2nd→3rd core items, each withgames,winRate,pickRate, andavgCompletionMinutesituationalSlots[]— 4th/5th/6th slots, each with the top itemoptions[](each option carriesgames/winRate/pickRate)
- These sections degrade gracefully: champions/patches without ingested timeline build data return the existing build rows with the new fields null.
GET /api/lol/analytics/champions/{championId}/profile returns the champion detail payload in one request:
- Query filters:
role,rankTier,region,queue,patch - Response:
{ championId, effectiveRole, winRates, builds, matchups, grade, queueFamily, trend, synergies, recommendation } synergiesisnullwhen the scope's synergies are not cached and their live computation does not finish within 2s of the request's fan-out. The computation keeps running in its own scope and caches its result, so a later request includes it; the web hides the section while it isnull. The default lane of every champion is kept cached by the hourly default-profile warm. A synergy failure that arrives within the 2s still fails the request, as before.grade(ChampionGradeDto, nullable) is the champion's tier grade for the resolvedeffectiveRole+ scope — the same grade the tier list shows for that champion in that role (so the detail page hero is consistent with the list). It carriestier,strengthScore,winRate,pickRate,banRate,contestedScore,games,roleBaseline,isLowSample,movement,previousTier,role,rankScope. Null when the champion is not graded in scope (render "Unrated").- The endpoint reuses the cached winrate, build, matchup, and tier-list aggregates. For Solo/Duo and Flex, when
roleis omitted it chooses the most-played role from winrates; if a scoped rank filter has no winrate rows, it uses all-rank winrates only to choose the role while keeping the requested rank filter for build and matchup data. ARAM/Arena resolveeffectiveRole=ALL. - The build and matchup reads run in separate backend scopes so their cached aggregate reads can execute concurrently without sharing an EF
DbContext. trendis the last 12 durable patch-grade points for the same champion, queue, role, and rank scope at the global region grain. Each point carriespatch,releasedAtUtc,tier,games,winRate,pickRate,banRate,strengthScore, andisLowSample; it is empty for exact-tier scopes that are not persisted. The champion page renders a real patch-over-patch win-rate chart only when at least two points exist.
Additional analytics fields:
- Tier list and champion winrate surfaces include queue-scoped
banRate. - Champion winrate rows include
roleRankandrolePopulationwhen resolvable. - Matchups include timeline-derived
avgGoldDiffAt15, optionalavgXpDiffAt15, andallMatchups[]in addition tocounters[]andfavorableMatchups[]. - Matchup responses include timeline quality metadata:
timelineCoverageRatiotimelineSampleSizetimelineDataFreshnessUtc
GET /api/lol/analytics/champions/{championId}/pro-builds supports optional filters:
region(ALLor supported platform-region token such asNA1|EUW1|EUN1|KR)role— when omitted (orALL), the champion's most-played lane is resolved from the cached win-rate aggregate (mirrors the profile endpoint) and echoed back asrole, so the landing view is lane-scoped instead of the heavier cross-role aggregate. Any other unrecognized role is rejected with400.scope:pro(reviewed professional accounts,IsPro),highelo(verified Master+ one-tricks,IsHighEloOtp), orall(either). Defaults topro.patch
The cross-role aggregate (no resolvable lane) bounds its participant scan to the most-recent Analytics:Compute:ProBuildMaxParticipantRows rows (default 1500) so the wide role=ALL + scope=all + region=ALL pool cannot command-timeout.
Response includes:
scoperecentProMatches[]— items are returned in purchase order (timeline-derived, final inventory as fallback); each match also carriesspell1Id/spell2Idand an optionalskillOrdertopPlayers[]commonBuilds[]— non-empty item sets in purchase order, ranked by games and then win rate (empty inventories remain available only on their rawrecentProMatches[]rows)
GET /api/lol/analytics/pro/champions (public) returns champions ranked by pick/play frequency among tracked pro / high-elo players (the "Pro Solo Queue Builds" home ranking). These are ranked solo-queue observations, not tournament drafts, esports schedules, or official match results. Optional filters:
region(ALLor supported platform-region token such asNA1|EUW1|EUN1|KR)scope:pro(reviewed professional accounts,IsPro),highelo(verified Master+ one-tricks,IsHighEloOtp), orall(either). Defaults topro.patch
Response: { patch, region, scope, champions[], sample } where each champion entry is { championId, games, wins, winRate, uniquePlayers }, ordered by games descending. Cached 24h (analytics + proplayrate tags).
GET /api/lol/analytics/pro/players (public) returns the public tracked-pro roster (IsActive && IsPro). Optional region filter. Response: { region, players[] } where each player is { proName, teamName, platformRegion, gameName, tagLine } (no internal identifiers). Cached 24h (analytics + proroster tags).
GET /api/lol/summoners/{region}/{gameName}/{tagLine}/live-gamePOST /api/lol/summoners/{region}/{gameName}/{tagLine}/live-game/probe- Returns the latest worker-observed snapshot.
lastUpdatedUtcanddataAgeSecondsexpose freshness; the Web API does not call Riot directly. - The probe endpoint queues a fresh Spectator-V5 check on the credentialed worker and returns
202 OperationAcceptedResponse. A fenced lease coalesces repeated checks; clients poll the owned operation for its exact saved observation, not the latest-snapshot GET. Live routes and exact operation GETs use the narrow AppOnly BFF allowlist. - Active-game participants include champion, summoner spells, selected perk IDs/styles, and a stored-data analysis projection with Solo/Duo rank, recent-20 win rate/KDA, signed current streak (positive wins, negative losses), and the three most-played champions in that recent window.
- Team summaries are directional scouting signals derived from the stored participant sample, not predictions or live objective telemetry. Offline responses contain an empty participant list.
GET /health/liveGET /health/ready
POST /api/auth/registerPOST /api/auth/loginPOST /api/auth/refreshPOST /api/auth/logoutPOST /api/auth/password-reset(anonymous; returns the same generic200 OKfor existing and unknown accounts;503when SMTP recovery is disabled/unconfigured)POST /api/auth/password-reset/complete(anonymous; consumes a one-time token and returns204; invalid/expired tokens return400)POST /api/auth/riot/authorize(anonymous; returns the configured Riot OAuth authorization URL for a caller-generated state value;503while RSO is disabled/unconfigured)POST /api/auth/riot/complete(anonymous; exchanges a one-time Riot code, signs in an existing linked account or creates a Riot-only account, and returns site tokens)GET /api/auth/me(AppOrUser)GET /api/auth/keys(AdminOnly)POST /api/auth/keys(AdminOnly)POST /api/auth/keys/{id}/revoke(AdminOnly)POST /api/auth/keys/{id}/rotate(AdminOnly)
Riot account linking (UserOnly):
GET /api/users/me/riot-accountreturns the verified main or404when none is linked.POST /api/users/me/riot-account/completeexchanges a one-time Riot code and links its verified PUUID to the signed-in account. A PUUID can belong to only one account.DELETE /api/users/me/riot-accountunlinks only when the user has an email/password credential; Riot-only accounts cannot remove their sole sign-in method.- Riot access/refresh tokens are never persisted. Only PUUID, Riot ID, selected platform region, and link/verification timestamps are stored.
Auth behavior notes:
- Registration duplicate-email responses are intentionally generic (
Registration failed.). - Password minimum length is 12 characters.
- Login performs a current-cost dummy PBKDF2 verification when the email is unknown, so the invalid-credential response does not reveal account existence through a cheap early return.
- Registration still returns
409 Conflictfor an existing address. This is an intentional product tradeoff until an email-verification flow can provide a genuinely uniform accepted response without returning a session for an existing account. - Password-reset tokens are random, stored only as SHA-256 hashes, expire after the configured lifetime (30 minutes by default), and are single-use. Completing a reset revokes every active refresh token for the account.
GET /api/admin/overviewGET /api/admin/metrics/analysisGET /api/admin/jobs/recurringPOST /api/admin/jobs/recurring/{id}/triggerPOST /api/admin/jobs/recurring/{id}/pausePOST /api/admin/jobs/recurring/{id}/resumeGET /api/admin/jobs/queuesGET /api/admin/jobs/listGET /api/admin/jobs/inspect/{jobId}POST /api/admin/jobs/inspect/{jobId}/deletePOST /api/admin/jobs/bulk-deleteGET /api/admin/jobs/failedGET /api/admin/jobs/failed/{jobId}POST /api/admin/jobs/failed/{jobId}/retryPOST /api/admin/cache/invalidateGET /api/admin/audit-logGET /api/admin/logs/services
GET /api/admin/overview now includes:
- queue totals plus deleted-job count
- active Hangfire server snapshots (
name,workersCount,queues, heartbeat) effectiveConcurrencyas the sum of active worker counts
GET /api/admin/metrics/analysis returns:
- active patch metadata
- global database/analysis summary cards
- per-region ingestion health rows including fetch-status counts, timeline coverage, tracked pro-summoner counts, and queue backlog by region
GET /api/admin/jobs/list query params:
state(enqueued,processing,scheduled,failed)queue,type,region,q(optional filters)olderThanMinutes(optional age filter)from,count(paged response)scanLimit(optional admin scan cap)
GET /api/admin/jobs/queues returns queue snapshots plus grouped backlog contributors by state, queue, job type, method, and inferred region.
GET /api/admin/jobs/inspect/{jobId} returns deep diagnostics for any job, including:
- invocation type/method
- serialized arguments
- state history timeline
- queue, server id, inferred region, and state timestamps
- exception type/message/details (when available)
POST /api/admin/jobs/inspect/{jobId}/delete accepts:
expectedState(optional state assertion such asProcessingorFailed)reason(optional audit metadata)
POST /api/admin/jobs/inspect/{jobId}/delete returns:
deletedto distinguish a successful state transition from a no-opexpectedStateecho when providedcurrentStatewhen Hangfire can still resolve the job after the attemptmessagealways included with an operator-facing outcome summary for stale-state / already-missing jobs
POST /api/admin/jobs/bulk-delete accepts:
states[]restricted to backlog states (enqueued,scheduled,failed)- optional filters:
queues[],jobType,region,query,olderThanMinutes limit,scanLimitdryRun
GET /api/admin/jobs/recurring now distinguishes:
- configured vs present-in-storage recurring jobs
- pause state for producer jobs
- whether a recurring job is pausable from admin
GET /api/admin/logs/services query params:
service(webapiorservice)level(optional; e.g.ERROR,WARNING,INFORMATION)q(optional case-insensitive search over category/message/exception)sinceUtc/untilUtc(optional timestamp window filters)limit(optional; min1, max500)
GET /api/admin/logs/services returns:
source.availableto distinguish missing log files from empty filtered resultssource.filesScannedandsource.latestTimestampUtcfor diagnosticssource.truncatedwhen the response hit the current row limititems[]with the matching structured log rows
GET /api/admin/pro-summonersPOST /api/admin/pro-summonersGET /api/admin/pro-summoners/{id}PUT /api/admin/pro-summoners/{id}DELETE /api/admin/pro-summoners/{id}POST /api/admin/pro-summoners/{id}/refreshGET /api/admin/pro-summoners/candidates?status=pending|approved|rejectedPOST /api/admin/pro-summoners/candidates/{id}/approvePOST /api/admin/pro-summoners/candidates/{id}/reject
The candidate endpoints expose staged Leaguepedia directory rows. Approval requires a confirmed Riot game name, tag line, and platform region (plus optional PUUID), creates the durable tracked professional account, and records the source identity. Candidate rows never appear in public pro-build analytics before approval.
- Favorites and preferences under
/api/users/me/*.GET /api/users/me/favoritesincludes the latest stored live-game observation (liveState,liveGameId,liveObservedAtUtc) and anisLiveconvenience flag.isLiveis true only for anin_gameobservation no more than ten minutes old, so a stale worker snapshot cannot present a player as currently live.
GET /api/lol/analytics/build-lab/{championId} is the public decision-analytics surface (anonymous,
expensive-read limiter). role is required. Optional context is opponentChampionId, patch, and
region (ALL/GLOBAL or omitted means every region); section=items|runes|spells|skills picks the
decision family and mode=supported|impact|common the ranking. Repeated itemPath values lock
completed legendaries in order (max 5) and a single runeSelections value locks a keystone, so the
whole state is permalinkable. Invalid context answers 400 ProblemDetails.
The numbers are plain win/game counts per build decision, maintained by the worker (see
docs/ARCHITECTURE.md → Build Lab), so there is nothing to promote: the response grows as matches
are counted.
coveragesays what was counted:includedPatcheswith theirpatchWeights,countedMatches,lastCountedAtUtc,includedRegions, andrankScope(ALL_TRACKED— every tracked rank, not a rank floor). Withoutpatchthe active patch and the two before it are pooled: an older patch's row counts at full weight when its items/runes and champion are unchanged since, and at 0.25 when the patch changed one of them (chosen byscripts/analysis/build-lab-pooling-backtest.sql);patchWeightsreports that changed-row weight per patch. An explicitpatchanswers from that patch alone, oravailable: falsewhen it was never counted.stages[]carriesfamily(STARTER,ITEM,BOOTS,RUNE_PAGE,RUNE,SPELLS),stage,label, the decision's (patch-weighted)gamesandwinRate, and itsoptions[].section=itemsreturns Starter and Boots (unconditioned) plus the item stage after the locked path;section=runesreturns the complete page plus the keystone stage, or the later slots conditioned on a locked keystone;section=spellsreturns the order-independent pair.- Each option has
actionKey/actionIds,games,pickRate, the rawwinRate,adjustedWinRate,lift, a 95%confidenceLow/confidenceHigh,averageTimingMinutes(items and boots only), andisLowSample. adjustedWinRatestandardizes over team gold difference at the decision minute:liftis the mean, over the option's games, of (won − the decision's win rate in that game's gold bucket), andadjustedWinRateis the decision's win rate pluslift. An item mostly bought while ahead is judged against other ahead games instead of being credited for the lead.- Every
liftis then shrunk toward its parent's with 1,000 pseudo-games (lift × games / (games + 1000)): toward 0 for the all-games scope, toward the all-games lift for a matchup or region. The value was chosen by backtest (scripts/analysis/build-lab-backtest.sql): scored on the next patch's games, unshrunk lifts predicted worse than ignoring the choice (−104bp Brier skill 16.17→16.18, −124bp 16.18→16.19), and 1,000 was best of 30–3,000 on both. The interval is the empirical-Bayes posterior, so a thin option sits near the decision average with a narrow band -- readisLowSamplefor how much evidence stands behind it. section=skillsreturns the ability max order (stage 0) and the first three levels (stage 1);actionIdsencode abilities as Q=1, W=2, E=3.scopeisMATCHUP,REGION, orALL. A matchup or region is used only when that stage has at least 150 weighted games there; otherwise the stage answers from all games withisFallback: true. Only Starter, the first item, Boots, the rune page, and spells are counted per matchup and per region; a matchup or regional request for any later stage always falls back toALL, and opponent-by-region is never counted (a request with an opponent ignoresregion).- Options under 5 games are hidden; under 100 games they are returned with
isLowSample: trueand always ranked last.supportedorders byconfidenceLow,impactbylift,commonbypickRate, at most 15 options per stage. unavailableReasonexplains an empty answer (feature disabled, nothing counted yet, or no counted game followed the exact locked path). Responses are cached for 10 minutes under theanalytics:build-labHybridCache tag.
Each available response also carries summary (BuildLabSummaryDto { starter, items[], boots, runePage, spellPair, skillPriority }): the recommended choice at every decision, picked by
BuildLabEstimator.Recommend -- the highest adjusted win rate among choices with at least 5% of the
decision's games and no low-sample flag. items follows the path: the second item is the
recommendation given the first, up to three, stopping at the first step with nothing to recommend.
GET /api/lol/analytics/champions/{championId}/profile includes an optional recommendation
(ChampionRecommendationSummary { available, coverage, summary, unavailableReason }) for Ranked
Solo/Duo only -- the same summary for the champion and role, so the champion page needs no second
request.
The repo keeps the exported spec committed and uses it to generate the TypeScript client during build/check flows.
The spec is OpenAPI 3.0 with C# nullable-reference-type fidelity: Swashbuckle is configured with SupportNonNullableReferenceTypes + NonNullableReferenceTypesAsRequired + UseAllOfToExtendReferenceSchemas (Transcendence.WebAPI/Program.cs), so always-present properties are required/non-null and nullable reference properties emit T | null in the generated client.
- Export spec:
scripts/openapi/export.sh(invoked viapnpm api:spec) - Generate client package from the spec:
packages/api-client(invoked viapnpm api:client)
See root package.json scripts:
api:genapi:check
Regional leaderboards keep their existing response shape and expose the successful snapshot time in
generatedAtUtc. Background refresh replaces the full top hundred without a cache gap; cold reads
may reuse a durable snapshot up to 24 hours old, plus the normal five-minute cache TTL. Champion-filtered
boards keep their current query behavior. Default synergy snapshots target six-hour refresh and may
serve a previous successful snapshot accepted within 24 hours, plus the normal six-hour cache TTL.
Compact synergy facts preserve the existing queue/patch/region and current-rank semantics; incomplete
backfills use raw data. These are eventual consistency bounds, not guarantees of successful refresh.
The web BFF applies TRN_BACKEND_TIMEOUT_MS through the complete finite response body, including JSON
consumption, and returns the existing structured 504 on timeout. An early HTTP 200 loading shell does
not establish that the backend data succeeded. Endpoint contracts and OpenAPI shapes are unchanged.