{"id":50380857,"url":"https://github.com/ag-tech-group/aoe2-live-standings-api","last_synced_at":"2026-05-30T12:01:10.113Z","repository":{"id":358751699,"uuid":"1242910962","full_name":"ag-tech-group/aoe2-live-standings-api","owner":"ag-tech-group","description":"Open-source live-standings API for Age of Empires II tournaments. Multi-tournament management with real-time SSE updates.","archived":false,"fork":false,"pushed_at":"2026-05-26T04:05:28.000Z","size":691,"stargazers_count":1,"open_issues_count":3,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-26T06:11:51.358Z","etag":null,"topics":["aoe2","fastapi","live-standings","python","sse","tournaments"],"latest_commit_sha":null,"homepage":"https://aoe2-live-standings-api.criticalbit.gg","language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/ag-tech-group.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":".github/CODEOWNERS","security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-18T21:53:42.000Z","updated_at":"2026-05-26T04:05:30.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/ag-tech-group/aoe2-live-standings-api","commit_stats":null,"previous_names":["ag-tech-group/aoe2-live-standings-api"],"tags_count":0,"template":false,"template_full_name":"ag-tech-group/api-template","purl":"pkg:github/ag-tech-group/aoe2-live-standings-api","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ag-tech-group%2Faoe2-live-standings-api","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ag-tech-group%2Faoe2-live-standings-api/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ag-tech-group%2Faoe2-live-standings-api/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ag-tech-group%2Faoe2-live-standings-api/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/ag-tech-group","download_url":"https://codeload.github.com/ag-tech-group/aoe2-live-standings-api/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ag-tech-group%2Faoe2-live-standings-api/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33691312,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-05-30T02:00:06.278Z","response_time":92,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["aoe2","fastapi","live-standings","python","sse","tournaments"],"created_at":"2026-05-30T12:01:08.973Z","updated_at":"2026-05-30T12:01:10.088Z","avatar_url":"https://github.com/ag-tech-group.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"# AoE2 Live Standings API\n\n[![CI](https://github.com/ag-tech-group/aoe2-live-standings-api/actions/workflows/ci.yml/badge.svg)](https://github.com/ag-tech-group/aoe2-live-standings-api/actions/workflows/ci.yml)\n[![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE)\n[![Python](https://img.shields.io/badge/python-%3E%3D3.12-blue.svg)](https://www.python.org/)\n\nOpen-source live-standings API for AoE2: DE tournaments. One deployment serves multiple tournaments — each a named roster of players on a leaderboard — tracking current ratings, max ratings, recent match history, win/loss streaks, and live-match detection. Brackets and branding stay in consumers; rosters, dates, and teams live here, so every consumer reads consistent, denormalized standings.\n\nThe upstream data layer is documented in [`docs/data-sources.md`](docs/data-sources.md).\n\n\u003e Age of Empires II © Microsoft Corporation. AoE2 Live Standings API was created under Microsoft's [Game Content Usage Rules](https://www.xbox.com/en-us/developers/rules) using assets from Age of Empires II and it is not endorsed by or affiliated with Microsoft.\n\n## Table of Contents\n\n- [Architecture](#architecture)\n- [Tech Stack](#tech-stack)\n- [Requirements](#requirements)\n- [Quick Start](#quick-start)\n- [Running with Docker](#running-with-docker)\n- [API Documentation](#api-documentation)\n- [API Endpoints](#api-endpoints)\n- [Authentication](#authentication)\n- [Logging, Telemetry \u0026 Feature Flags](#logging-telemetry--feature-flags)\n- [Database Migrations](#database-migrations)\n- [Testing](#testing)\n- [Linting \u0026 Formatting](#linting--formatting)\n- [Git Setup \u0026 Pre-commit Hooks](#git-setup--pre-commit-hooks)\n- [Project Structure](#project-structure)\n- [Environment Variables](#environment-variables)\n- [License](#license)\n\n## Architecture\n\nTwo Cloud Run services share one Postgres database and one container image, differentiated only by env vars (`POLLING_ENABLED` / `LISTENER_ENABLED`):\n\n- **worker** — a pinned singleton (`min=max=1`, private — no public traffic). Polls the upstream Relic backend (`aoe-api.worldsedgelink.com/community/*`, see [`docs/data-sources.md`](docs/data-sources.md)) on three cadences (30 s / 60 s / 15 s), writes to Postgres, and emits a `pg_notify` inside the same transaction whenever data changes.\n- **api** — autoscaling read tier (`min=1 max=10`, public). Serves the `/v1/*` REST endpoints and the SSE `/v1/stream`. Runs a dedicated `LISTEN` connection that picks up the worker's NOTIFYs and fans nudges to its SSE subscribers.\n\n```\n   Relic backend (upstream)\n             ▲\n             │ poll\n             │\n   ┌─────────┴────────┐\n   │ worker service   │   singleton; writes + pg_notify\n   │ (private,        │\n   │  min=max=1)      │\n   └─────────┬────────┘\n             │ write + pg_notify (in transaction)\n             ▼\n   ┌──────────────────┐\n   │     Postgres     │\n   │   (snapshot)     │\n   └─────────┬────────┘\n             │ read + LISTEN\n             ▼\n   ┌──────────────────┐\n   │   api service    │   autoscaled; serves /v1/* + SSE\n   │ (public,         │\n   │  min=1 max=10)   │\n   └─────────┬────────┘\n             │ /v1/* + SSE nudges\n             ▼\n         consumers\n        (web client)\n```\n\nReads are denormalized: each response row carries everything a consumer needs to render it, so consumers never fan out or join across endpoints.\n\nIn local development (and tests) both flags default true, so a single uvicorn process runs everything — mono mode. Tests bypass the lifespan entirely via `ASGITransport`.\n\n## Tech Stack\n\n| Component          | Technology                                                                 |\n| ------------------ | -------------------------------------------------------------------------- |\n| Framework          | [FastAPI](https://fastapi.tiangolo.com/)                                   |\n| Database           | [PostgreSQL](https://www.postgresql.org/) (async via [asyncpg](https://magicstack.github.io/asyncpg/)) |\n| ORM                | [SQLAlchemy 2.0](https://www.sqlalchemy.org/)                              |\n| Migrations         | [Alembic](https://alembic.sqlalchemy.org/)                                 |\n| Rate Limiting      | [slowapi](https://slowapi.readthedocs.io/)                                 |\n| Logging            | [structlog](https://www.structlog.org/)                                    |\n| Telemetry          | [OpenTelemetry](https://opentelemetry.io/)                                 |\n| Package Manager    | [uv](https://docs.astral.sh/uv/)                                           |\n| Containerization   | [Docker](https://www.docker.com/) / [Docker Compose](https://docs.docker.com/compose/) |\n| Testing            | [Pytest](https://docs.pytest.org/) (async via [pytest-asyncio](https://pytest-asyncio.readthedocs.io/)) |\n| Linting/Formatting | [Ruff](https://docs.astral.sh/ruff/)                                       |\n| Git Hooks          | [pre-commit](https://pre-commit.com/)                                      |\n\n## Requirements\n\n- Python 3.12+\n- uv\n- Docker \u0026 Docker Compose (for local development)\n\n## Quick Start\n\n```bash\n# Copy environment file\ncp .env.example .env\n\n# Install dependencies\nuv sync\n\n# Start PostgreSQL\ndocker compose up -d db\n\n# Run migrations\nuv run alembic upgrade head\n\n# Start the API\nuv run uvicorn app.main:app --reload\n```\n\nThe API will be available at http://localhost:8000.\n\n## Running with Docker\n\n```bash\ndocker compose up        # foreground (API + PostgreSQL + Adminer)\ndocker compose up -d     # detached\n```\n\nAdminer is available at http://localhost:8080. Login: System=PostgreSQL, Server=db, User=postgres, Password=postgres, Database=aoe2_live_standings.\n\n## API Documentation\n\nOnce running, visit:\n\n- Scalar UI: http://localhost:8000/docs\n- OpenAPI JSON: http://localhost:8000/openapi.json\n\nThe OpenAPI spec is consumed by the companion consumer projects (see e.g. [`hera-streamer-invitational-2026-web`](https://github.com/ag-tech-group/hera-streamer-invitational-2026-web)) via [orval](https://orval.dev/) to generate type-safe React Query hooks. Run the generator from the consumer side after starting this API locally (or after pointing at a deployed instance).\n\n## API Endpoints\n\nAll application routes are served under the `/v1` prefix so the API surface can be versioned as a whole. Infrastructure routes (`/`, `/health`, `/docs`, `/openapi.json`, `/.well-known/security.txt`) stay unversioned. Routers are registered in the `ROUTERS` tuple in `app/main.py`, which loop-mounts each one with the `/v1` prefix.\n\n### Read endpoints (public)\n\nMost of the API is scoped to a tournament:\n\n- `GET /v1/tournaments` — list tournaments; `GET /v1/tournaments/{slug}` — one tournament\n- `GET /v1/tournaments/{slug}/standings` — the tournament's standings\n- `GET /v1/tournaments/{slug}/teams/standings` — the tournament's team standings\n- `GET /v1/tournaments/{slug}/matches`, `.../matches/{match_id}` — match feed and detail\n- `GET /v1/tournaments/{slug}/live` — the roster's live matches\n- `GET /v1/tournaments/{slug}/players`, `.../players/{profile_id}` — roster and player detail\n\nUnscoped: `GET /v1/leaderboards` (leaderboard metadata), `GET /v1/stream` (SSE refresh nudges), `GET /v1/flags` (feature flags).\n\n### Authenticated read\n\n- `GET /v1/me` — identity (`user_id`) plus the list of tournaments the caller owns. One round-trip lets the frontend gate admin UI without per-tournament probes. 401 when unauthenticated.\n\n### Write endpoints (authenticated)\n\nThe management API lets a tournament host edit configuration without a redeploy. Every write route is gated — see [Authentication](#authentication). Writes accept an optional `Idempotency-Key: \u003cuuid\u003e` header to dedupe retries (same key + same body → cached response).\n\n- `POST /v1/tournaments` — create a tournament. Any authenticated user may; the caller becomes the first owner. `DELETE /v1/tournaments/{slug}` — delete the tournament and everything tournament-scoped (cascades to roster, teams, owners).\n- `PATCH /v1/tournaments/{slug}` — edit a tournament's name, dates, or leaderboard\n- `GET /v1/tournaments/{slug}/owners` — list owners; `POST` to grant ownership to another criticalbit user; `DELETE .../owners/{user_id}` to revoke. Revoking the last owner is rejected (the tournament would become uneditable).\n- `POST /v1/tournaments/{slug}/players` — add a profile to the roster; `DELETE .../players/{profile_id}` — remove one\n- `POST /v1/tournaments/{slug}/teams` — create a team; `PATCH` / `DELETE .../teams/{team_id}` — edit or delete one\n- `POST /v1/tournaments/{slug}/teams/{team_id}/members` — add a team member; `DELETE .../members/{profile_id}` — remove one\n\nSee `/docs` or `/openapi.json` for the full, authoritative spec.\n\n## Authentication\n\nReads are public. The write/management API is authenticated against [criticalbit-auth-api](https://github.com/ag-tech-group/criticalbit-auth-api), the shared criticalbit.gg SSO service:\n\n- **Authentication** — a write request must carry a valid `criticalbit_access` cookie (an RS256 JWT issued by criticalbit-auth-api). The API verifies it against that service's public JWKS endpoint (`AUTH_JWKS_URL`); a missing or invalid token is a `401`.\n- **Authorization** — a verified token identifies a criticalbit user. To edit a tournament, that user must have a row in this service's `tournament_owners` table for it, or the request is a `403`. Ownership is per-tournament and modelled here — not in the auth service, which deliberately stays free of app-specific roles.\n\nOwner rows are inserted directly (SQL) for now; an API to grant and revoke ownership is planned. A roster edited through this API is picked up by the polling worker on its next cycle, with no redeploy.\n\n## Logging, Telemetry \u0026 Feature Flags\n\n### Structured Logging\n\nLogging uses [structlog](https://www.structlog.org/) for structured output. In development you get colored console logs; in production, JSON.\n\nEvery request is assigned a unique `X-Request-ID` header (or reuses one from the incoming request), and it's automatically bound to all log entries for that request.\n\nConfigure via `LOG_LEVEL` env var (default: `INFO`).\n\n### OpenTelemetry\n\nOpenTelemetry tracing is included but disabled by default. To enable, set `OTEL_ENABLED=true` and point `OTEL_EXPORTER_ENDPOINT` at your collector (e.g. Jaeger, Grafana Tempo). FastAPI is auto-instrumented — no code changes needed.\n\n### Analytics\n\n`app/analytics.py` provides an `AnalyticsBackend` protocol with `track()` and `identify()` methods. The default `LogAnalyticsBackend` writes events to structlog. Swap it out by replacing the `analytics` module-level instance with your own implementation (e.g. Segment, PostHog).\n\nUse the `get_analytics()` FastAPI dependency to access it in route handlers.\n\n### Feature Flags\n\nFeature flags are read from `FEATURE_*` environment variables at startup (no database required). Set `FEATURE_\u003cNAME\u003e=true` or `false` in your `.env`.\n\nThe `GET /v1/flags` endpoint returns all flags as a JSON object.\n\nUse the `get_feature_flags()` dependency in route handlers to check flags server-side via `flags.is_enabled(\"flag_name\")`.\n\n## Database Migrations\n\nThis project uses Alembic for database migrations.\n\n### Workflow\n\n1. Edit a model in `app/models/`\n2. Make sure the model is imported in `alembic/env.py` (so autogenerate can detect it)\n3. Generate a migration:\n   ```bash\n   uv run alembic revision --autogenerate -m \"description of change\"\n   ```\n4. Review the generated file in `alembic/versions/` (autogenerate can miss some changes)\n5. Apply the migration:\n   ```bash\n   uv run alembic upgrade head\n   ```\n6. Commit both the model change and the migration file\n\n### Common Commands\n\n```bash\n# Apply all pending migrations\nuv run alembic upgrade head\n\n# Rollback one migration\nuv run alembic downgrade -1\n\n# See current migration status\nuv run alembic current\n\n# See migration history\nuv run alembic history\n\n# Generate a migration without applying\nuv run alembic revision --autogenerate -m \"description\"\n```\n\n## Testing\n\nTests use SQLite in-memory for speed and isolation.\n\n```bash\n# Run all tests\nuv run pytest\n\n# Verbose\nuv run pytest -v\n\n# With coverage\nuv run pytest --cov=app\n```\n\nThe test harness provides a `client` fixture (an unauthenticated async HTTP client), a `session` fixture (a direct async SQLAlchemy session for test setup), and an `auth_as` fixture that authenticates the client as a given user for write-endpoint tests.\n\n## Linting \u0026 Formatting\n\nThis project uses [Ruff](https://docs.astral.sh/ruff/) for linting and formatting.\n\n```bash\n# Lint\nuv run ruff check .\n\n# Auto-fix\nuv run ruff check --fix .\n\n# Format\nuv run ruff format .\n\n# Check formatting without changes\nuv run ruff format --check .\n```\n\n## Git Setup \u0026 Pre-commit Hooks\n\nInstall pre-commit hooks so ruff runs automatically on every commit:\n\n```bash\nuv run pre-commit install\nuv run pre-commit run --all-files  # one-time run across the repo\n```\n\n## Project Structure\n\n```\naoe2-live-standings-api/\n├── app/\n│   ├── auth/                  # JWT verification + tournament-owner authorization\n│   ├── models/                # SQLAlchemy models\n│   ├── routers/               # FastAPI routers, mounted under /v1\n│   ├── schemas/               # Pydantic request/response schemas\n│   ├── analytics.py           # Analytics event abstraction\n│   ├── config.py              # Settings (env-backed) + production validation\n│   ├── database.py            # Async SQLAlchemy setup\n│   ├── features.py            # Feature flags (env-var backed) + /v1/flags\n│   ├── logging.py             # structlog configuration\n│   ├── telemetry.py           # OpenTelemetry setup\n│   └── main.py                # App entry point, middleware, infra routes\n├── alembic/\n│   ├── versions/              # Migration files\n│   └── env.py                 # Alembic configuration\n├── docs/\n│   └── data-sources.md        # Upstream data source notes\n├── tests/\n│   ├── conftest.py            # Fixtures (client, session)\n│   └── test_app.py            # security.txt + rate-limit-exemption tests\n├── .env.example\n├── .pre-commit-config.yaml\n├── .python-version            # pyenv Python version\n├── docker-compose.yml         # API + PostgreSQL + Adminer\n├── Dockerfile\n└── pyproject.toml\n```\n\n## Environment Variables\n\n\"Required\" means the value **must be set when `ENVIRONMENT=production`** — production config validation rejects defaults for these. Local development runs out of the box with no env vars set.\n\n| Variable                 | Required | Description                                       | Default                                                                     |\n| ------------------------ | -------- | ------------------------------------------------- | --------------------------------------------------------------------------- |\n| `ENVIRONMENT`            | Optional | `development` or `production`                     | `development`                                                               |\n| `DATABASE_URL`           | Required | PostgreSQL connection string                      | `postgresql+asyncpg://postgres:postgres@localhost:5432/aoe2_live_standings` |\n| `CORS_ORIGINS`           | Required | Comma-separated allowed origins                   | (empty — dev uses `localhost:5100-5199`)                                    |\n| `LOG_LEVEL`              | Optional | Logging level                                     | `INFO`                                                                      |\n| `OTEL_ENABLED`           | Optional | Enable OpenTelemetry tracing                      | `false`                                                                     |\n| `OTEL_SERVICE_NAME`      | Optional | Service name for traces                           | `aoe2-live-standings-api`                                                   |\n| `OTEL_EXPORTER_ENDPOINT` | Optional | OTLP gRPC collector endpoint (used when `OTEL_USE_CLOUD_TRACE` is false) | `http://localhost:4317`                              |\n| `OTEL_USE_CLOUD_TRACE`   | Optional | Export spans directly to Google Cloud Trace via the native exporter (prod) | `false`                                            |\n| `OTEL_TRACES_SAMPLE_RATIO` | Optional | Fraction of incoming traces to sample (1.0 = 100%, 0.1 = 10%) | `1.0`                                                          |\n| `SENTRY_DSN`             | Optional | Sentry project DSN. Empty disables Sentry init entirely | (empty)                                                              |\n| `FEATURE_*`              | Optional | Feature flags (e.g. `FEATURE_ERROR_ENVELOPE_V2=true`) | (none)                                                                  |\n| `POLLING_ENABLED`        | Optional | Start the three upstream pollers in this process (worker service) | `true`                                                  |\n| `LISTENER_ENABLED`       | Optional | Start the LISTEN/NOTIFY consumer in this process (api service)    | `true`                                                  |\n| `UPSTREAM_BASE_URL`      | Optional | Relic upstream base URL                           | `https://aoe-api.worldsedgelink.com`                                        |\n| `TRACKED_PROFILE_IDS`    | Optional | Comma-separated profile IDs for the seed tournament's roster — used only to bootstrap a tournament when the database has none | (empty) |\n| `TOURNAMENT_*`           | Optional | Seed tournament's `SLUG` / `NAME` / `LEADERBOARD_ID` / `START_DATE` / `GRAND_FINALS_DATE` (see `app/config.py`) | (see config) |\n| `AUTH_JWKS_URL`          | Optional | JWKS endpoint used to verify the write API's access tokens | `https://auth-api.criticalbit.gg/auth/jwks` |\n| `AUTH_TOKEN_ISSUER`      | Optional | Expected JWT `iss` claim; when set, tokens with a different issuer are rejected | (empty — issuer not checked) |\n\nBefore deploying to production, replace the placeholder `Contact:` in the `SECURITY_TXT` constant (`app/main.py`) with a real security-disclosure address and bump `Expires:` if it's close.\n\n## License\n\nApache 2.0 — see [LICENSE](LICENSE).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fag-tech-group%2Faoe2-live-standings-api","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fag-tech-group%2Faoe2-live-standings-api","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fag-tech-group%2Faoe2-live-standings-api/lists"}