REUSE-WP-0019-T02: hub recompose staleness tracking + Forgejo webhook
Some checks failed
ci / validate-registry (push) Has been cancelled

reuse_surface/hub/store.py: compose_state table tracking composed_at/stale,
updated only on a *forced* recompose (refresh=true, webhook, future
scheduled fallback) -- a plain GET still serves current best-effort data
but never silently reports itself as freshly composed.

reuse_surface/hub/webhooks.py: constant-time HMAC-SHA256 signature
verification (fails closed on an empty/unconfigured secret) and
path-only push-payload inspection (never parses file content, per design
principle 2 -- webhook only decides whether to trigger a pull-based
recompose).

New endpoint POST /v1/webhooks/forgejo, accepting both
X-Forgejo-Signature and X-Gitea-Signature headers since sibling repos
migrate independently. GET /v1/federated and POST /v1/federated/compose
now share an asyncio.Lock with the webhook so concurrent recompose
triggers coalesce instead of overlapping.

No separate /v1/recompose route was added -- POST /v1/federated/compose
already did that job from earlier hub work; specs/FederationHubAPI.md now
documents this explicitly instead of duplicating it.

28 new pytest cases (128 total pass). Live-verified: ran the actual hub
service locally and sent a real HMAC-signed webhook over HTTP, confirming
the full webhook-to-recompose path, signature rejection, and the
irrelevant-path no-op.

Does NOT deploy to the live reuse.coulomb.social hub -- that needs a
container rebuild/push/k8s rollout, a separate production-deployment
action out of scope here without explicit sign-off.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 18:24:55 +02:00
parent 443510276b
commit e0a4de3310
7 changed files with 520 additions and 25 deletions

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
import asyncio
import json
import os
from pathlib import Path
from typing import Any
@@ -10,6 +12,11 @@ from fastapi.responses import JSONResponse, Response
from reuse_surface.hub.compose import compose_from_store, DEFAULT_DOMAIN
from reuse_surface.hub.store import HubStore
from reuse_surface.hub.webhooks import (
SIGNATURE_HEADERS,
push_touches_registry_index,
verify_signature,
)
HUB_VERSION = "0.1.0"
@@ -30,6 +37,10 @@ def _store() -> HubStore:
return HubStore(_db_path())
def _webhook_secret() -> str:
return os.environ.get("REUSE_SURFACE_FORGEJO_WEBHOOK_SECRET", "")
def _http_error(status: int, error: str, message: str) -> HTTPException:
return HTTPException(
status_code=status,
@@ -53,6 +64,10 @@ def _require_auth(authorization: str | None = Header(default=None)) -> None:
def create_app() -> FastAPI:
app = FastAPI(title="reuse-surface federation hub", version=HUB_VERSION)
store = _store()
# Serializes concurrent recompose triggers (manual POST, webhook, future
# scheduled fallback) so a burst of pushes coalesces into one compose
# pass instead of overlapping ones (T02 design principle 2 debounce).
compose_lock = asyncio.Lock()
@app.get("/health")
def health() -> dict[str, str]:
@@ -96,20 +111,34 @@ def create_app() -> FastAPI:
raise _http_error(404, "not_found", f"repo not found: {repo}")
return Response(status_code=204)
def _federated_response(
async def _federated_response(
refresh: bool,
accept: str | None,
format_param: str,
) -> Response:
try:
federated, warnings = compose_from_store(
store, refresh=refresh, cache_dir=_cache_dir(), domain=DEFAULT_DOMAIN
)
except FileNotFoundError as exc:
raise _http_error(502, "compose_error", str(exc)) from exc
# composed_at/stale track *forced* recomposes (refresh=True: manual
# POST, webhook, scheduled fallback), not every plain GET -- a plain
# GET still serves current best-effort data (compose_from_store's own
# per-source cache_ttl_seconds still applies) but must not silently
# clear a staleness signal nothing actually refreshed.
async with compose_lock:
try:
federated, warnings = compose_from_store(
store, refresh=refresh, cache_dir=_cache_dir(), domain=DEFAULT_DOMAIN
)
except FileNotFoundError as exc:
raise _http_error(502, "compose_error", str(exc)) from exc
if refresh:
store.record_compose()
compose_state = store.get_compose_state()
federated["composed_at"] = compose_state["composed_at"]
federated["stale"] = compose_state["stale"]
use_yaml = format_param == "yaml" or (accept and "yaml" in accept.lower())
headers: dict[str, str] = {}
if compose_state["composed_at"]:
headers["X-Composed-At"] = compose_state["composed_at"]
if warnings:
headers["X-Federation-Warnings"] = "; ".join(warnings)
if use_yaml:
@@ -118,19 +147,58 @@ def create_app() -> FastAPI:
return JSONResponse(content=federated, headers=headers)
@app.get("/v1/federated", response_model=None)
def get_federated(
async def get_federated(
request: Request,
refresh: bool = Query(default=False),
format: str = Query(default="json"),
) -> Response:
return _federated_response(refresh, request.headers.get("accept"), format)
return await _federated_response(refresh, request.headers.get("accept"), format)
@app.post("/v1/federated/compose", response_model=None, dependencies=[Depends(_require_auth)])
def compose_federated(
async def compose_federated(
request: Request,
format: str = Query(default="json"),
) -> Response:
return _federated_response(True, request.headers.get("accept"), format)
return await _federated_response(True, request.headers.get("accept"), format)
@app.post("/v1/webhooks/forgejo", response_model=None)
async def forgejo_webhook(request: Request) -> Response:
body = await request.body()
signature = None
for header_name in SIGNATURE_HEADERS:
signature = request.headers.get(header_name)
if signature:
break
secret = _webhook_secret()
if not secret:
raise _http_error(
503, "misconfigured", "REUSE_SURFACE_FORGEJO_WEBHOOK_SECRET is not configured"
)
if not verify_signature(secret, body, signature):
raise _http_error(401, "unauthorized", "invalid or missing webhook signature")
try:
payload = json.loads(body)
except json.JSONDecodeError as exc:
raise _http_error(400, "validation_error", f"invalid JSON payload: {exc}") from exc
if not push_touches_registry_index(payload):
return JSONResponse(content={"accepted": False, "reason": "no registry/indexes/ change"})
async with compose_lock:
store.mark_stale()
try:
compose_from_store(
store, refresh=True, cache_dir=_cache_dir(), domain=DEFAULT_DOMAIN
)
except FileNotFoundError as exc:
# Recompose failed -- leave stale=1 so the next GET/scheduled
# trigger reports it honestly rather than silently swallowing
# the failure.
raise _http_error(502, "compose_error", str(exc)) from exc
composed_at = store.record_compose()
return JSONResponse(content={"accepted": True, "composed_at": composed_at})
return app

View File

@@ -61,6 +61,23 @@ class HubStore:
)
"""
)
# Single-row table tracking the composed federated index's
# freshness (REUSE-WP-0019-T02). composed_at is set whenever a
# real compose (refresh=True) completes; stale is set by the
# Forgejo webhook receiver when a registry/indexes/ change is
# pushed, and cleared on the next successful compose.
conn.execute(
"""
CREATE TABLE IF NOT EXISTS compose_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
composed_at TEXT,
stale INTEGER NOT NULL DEFAULT 0
)
"""
)
conn.execute(
"INSERT OR IGNORE INTO compose_state (id, composed_at, stale) VALUES (1, NULL, 0)"
)
def list_repos(self) -> list[dict[str, Any]]:
with self._connect() as conn:
@@ -160,4 +177,28 @@ class HubStore:
rows = conn.execute(
"SELECT * FROM registrations ORDER BY repo"
).fetchall()
return [_row_to_registration(row) for row in rows]
return [_row_to_registration(row) for row in rows]
def record_compose(self) -> str:
"""Marks the federated index freshly composed (stale cleared).
Returns the recorded timestamp."""
now = _utc_now()
with self._connect() as conn:
conn.execute(
"UPDATE compose_state SET composed_at = ?, stale = 0 WHERE id = 1",
(now,),
)
return now
def mark_stale(self) -> None:
with self._connect() as conn:
conn.execute("UPDATE compose_state SET stale = 1 WHERE id = 1")
def get_compose_state(self) -> dict[str, Any]:
with self._connect() as conn:
row = conn.execute(
"SELECT composed_at, stale FROM compose_state WHERE id = 1"
).fetchone()
if row is None:
return {"composed_at": None, "stale": False}
return {"composed_at": row["composed_at"], "stale": bool(row["stale"])}

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
import hashlib
import hmac
from typing import Any
# Forgejo (Gitea-compatible) signs webhook payloads with HMAC-SHA256 over the
# raw request body, sent as a hex digest in X-Forgejo-Signature (Forgejo) or
# X-Gitea-Signature (Gitea) -- both forges use the same scheme during the
# transition, so both header names are accepted.
SIGNATURE_HEADERS = ("X-Forgejo-Signature", "X-Gitea-Signature")
RELEVANT_PATH_PREFIX = "registry/indexes/"
def verify_signature(secret: str, body: bytes, signature: str | None) -> bool:
"""Constant-time HMAC-SHA256 verification. Returns False (never raises)
for a missing/malformed signature or a misconfigured (empty) secret --
callers must treat False as 'reject', not 'skip verification'."""
if not secret or not signature:
return False
expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature.strip())
def push_touches_registry_index(payload: dict[str, Any]) -> bool:
"""True if any commit in a Forgejo/Gitea push-event payload adds,
modifies, or removes a path under registry/indexes/. Only inspects
paths -- never parses file content (design principle 2: no
push-parsing of payloads into registry state, the webhook only
triggers a pull-based recompose)."""
for commit in payload.get("commits") or []:
for key in ("added", "modified", "removed"):
for path in commit.get(key) or []:
if path.startswith(RELEVANT_PATH_PREFIX):
return True
return False