krz/hutch-stats

Server-side utility for calculating contributions for sourcehut users.

clone: git clone https://gitbay.org/krz/hutch-stats.git

1878dff520ee2e424b088a963c8f6417f5106ce9

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-11T17:34:11Z

feat: harden contribution api for production use
 README.md                                    | 16 +++++++---------
 src/srht_contrib/api/routes_contributions.py |  4 ++--
 tests/test_contributions_api.py              |  8 ++++++--
 3 files changed, 15 insertions(+), 13 deletions(-)

diff --git a/README.md b/README.md
index d9baf54..b05e71a 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@ The current V1 is intentionally narrow and production-oriented:
 - polling-based ingestion
 - complete `todo.sr.ht` ingestion path
 - practical `git.sr.ht` commit ingestion for tracked repositories
-- API-key protection for `/api/*`
+- public read-only contribution endpoints plus API-key protection for mutating/admin routes
 - Alembic-managed schema migrations
 
 ## What It Does
@@ -75,7 +75,7 @@ Tracked git repositories are persisted in the `tracked_repositories` table and s
 
 Environment variables:
 
-- `API_KEY`: required header token for all `/api/*` routes via `X-API-Key`
+- `API_KEY`: required header token for mutating/admin routes via `X-API-Key`
 - `ENABLE_SCHEDULER`: defaults to `false`; enables in-process polling when set to `true`
 - `SRHT_TOKEN`: bearer token for SourceHut GraphQL
 - `TODO_SRHT_ENDPOINT`: defaults to `https://todo.sr.ht/query`
@@ -189,15 +189,13 @@ Response:
 ### Contribution Calendar by Year
 
 ```bash
-curl "http://127.0.0.1:8000/api/contributions/~ccleberg?year=2026" \
-  -H "X-API-Key: replace-me"
+curl "http://127.0.0.1:8000/api/contributions/~ccleberg?year=2026"
 ```
 
 ### Contribution Calendar by Date Range
 
 ```bash
-curl "http://127.0.0.1:8000/api/contributions/~ccleberg?from=2026-01-01&to=2026-03-30" \
-  -H "X-API-Key: replace-me"
+curl "http://127.0.0.1:8000/api/contributions/~ccleberg?from=2026-01-01&to=2026-03-30"
 ```
 
 Example response:
@@ -218,8 +216,7 @@ Example response:
 ### Contribution Stats
 
 ```bash
-curl "http://127.0.0.1:8000/api/contributions/~ccleberg/stats?year=2026" \
-  -H "X-API-Key: replace-me"
+curl "http://127.0.0.1:8000/api/contributions/~ccleberg/stats?year=2026"
 ```
 
 Example response:
@@ -292,7 +289,8 @@ pytest
 Covered areas:
 
 - health endpoint
-- API key enforcement
+- public read-only contribution endpoints
+- API key enforcement for mutating/admin routes
 - calendar aggregation
 - zero-filled ranges
 - stats calculations
diff --git a/src/srht_contrib/api/routes_contributions.py b/src/srht_contrib/api/routes_contributions.py
index 0682aa6..c682553 100644
--- a/src/srht_contrib/api/routes_contributions.py
+++ b/src/srht_contrib/api/routes_contributions.py
@@ -13,7 +13,7 @@ from srht_contrib.services.srht_client import SourceHutClientError
 from srht_contrib.utils.dates import parse_date, year_bounds
 from srht_contrib.utils.identity import ActorIdentityResolver
 
-router = APIRouter(prefix="/api/contributions", tags=["contributions"], dependencies=[Depends(require_api_key)])
+router = APIRouter(prefix="/api/contributions", tags=["contributions"])
 
 
 def _resolve_range(year: int | None, from_date: str | None, to_date: str | None) -> tuple[date, date]:
@@ -63,7 +63,7 @@ def get_contribution_stats(
     return ContributionAggregator().build_stats(db, canonical_actor, start, end)
 
 
-@router.post("/poll", response_model=PollResponse)
+@router.post("/poll", response_model=PollResponse, dependencies=[Depends(require_api_key)])
 def trigger_manual_poll(
     actor: str,
     poller: PollerService = Depends(get_poller),
diff --git a/tests/test_contributions_api.py b/tests/test_contributions_api.py
index d90aceb..e221602 100644
--- a/tests/test_contributions_api.py
+++ b/tests/test_contributions_api.py
@@ -6,14 +6,18 @@ from srht_contrib.main import create_app
 from srht_contrib.models import ContributionEvent
 
 
-def test_api_routes_require_api_key(settings, db_engine, session_factory) -> None:
+def test_read_only_contribution_routes_are_public_and_write_routes_require_api_key(settings, db_engine, session_factory) -> None:
     app = create_app(settings, engine=db_engine, session_factory=session_factory)
     with TestClient(app) as open_client:
         response = open_client.get("/health")
+        public_contributions = open_client.get("/api/contributions/~ccleberg?from=2026-03-28&to=2026-03-30")
+        public_stats = open_client.get("/api/contributions/~ccleberg/stats?from=2026-03-28&to=2026-03-30")
     assert response.status_code == 200
+    assert public_contributions.status_code == 200
+    assert public_stats.status_code == 200
 
     with TestClient(app) as unauthorized:
-        unauthorized_response = unauthorized.get("/api/contributions/~ccleberg?from=2026-03-28&to=2026-03-30")
+        unauthorized_response = unauthorized.post("/api/contributions/poll?actor=~ccleberg")
     assert unauthorized_response.status_code == 401
 
     with TestClient(app) as invalid: