This repository has been archived on 2026-07-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
reuse-surface/reuse_surface/hub/store.py
tegwick e0a4de3310
Some checks failed
ci / validate-registry (push) Has been cancelled
REUSE-WP-0019-T02: hub recompose staleness tracking + Forgejo webhook
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>
2026-07-07 18:24:55 +02:00

204 lines
7.4 KiB
Python

from __future__ import annotations
import json
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import yaml
from jsonschema import Draft202012Validator
SCHEMA_PATH = Path(__file__).resolve().parent / "hub-registration.schema.yaml"
def _utc_now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
def _load_schema() -> dict[str, Any]:
return yaml.safe_load(SCHEMA_PATH.read_text(encoding="utf-8"))
def _validate(payload: dict[str, Any], schema_ref: str) -> list[str]:
schema = _load_schema()
subschema = schema["$defs"][schema_ref]
validator = Draft202012Validator(subschema)
return [error.message for error in validator.iter_errors(payload)]
def _row_to_registration(row: sqlite3.Row) -> dict[str, Any]:
data = json.loads(row["payload"])
data["registered_at"] = row["registered_at"]
data["updated_at"] = row["updated_at"]
return data
def _public_registration(registration: dict[str, Any]) -> dict[str, Any]:
return {key: value for key, value in registration.items() if key != "auth_env"}
class HubStore:
def __init__(self, db_path: Path) -> None:
self.db_path = db_path
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._init_db()
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
return conn
def _init_db(self) -> None:
with self._connect() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS registrations (
repo TEXT PRIMARY KEY,
payload TEXT NOT NULL,
registered_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
# 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:
rows = conn.execute(
"SELECT * FROM registrations ORDER BY repo"
).fetchall()
return [_public_registration(_row_to_registration(row)) for row in rows]
def get_repo(self, repo: str) -> dict[str, Any] | None:
with self._connect() as conn:
row = conn.execute(
"SELECT * FROM registrations WHERE repo = ?", (repo,)
).fetchone()
if row is None:
return None
return _public_registration(_row_to_registration(row))
def get_repo_internal(self, repo: str) -> dict[str, Any] | None:
with self._connect() as conn:
row = conn.execute(
"SELECT * FROM registrations WHERE repo = ?", (repo,)
).fetchone()
if row is None:
return None
return _row_to_registration(row)
def create_repo(self, payload: dict[str, Any]) -> dict[str, Any]:
errors = _validate(payload, "registration_request")
if errors:
raise ValueError("; ".join(errors))
now = _utc_now()
record: dict[str, Any] = {
"repo": payload["repo"],
"url": payload["url"],
"enabled": payload.get("enabled", True),
"required": payload.get("required", False),
"domain": payload["domain"],
"cache_ttl_seconds": payload.get("cache_ttl_seconds", 86400),
"auth_header": payload.get("auth_header", "Authorization"),
}
for optional in ("description", "auth_env", "registered_by"):
if payload.get(optional) is not None:
record[optional] = payload[optional]
validator = Draft202012Validator(_load_schema())
full_errors = [
error.message
for error in validator.iter_errors(
{**record, "registered_at": now, "updated_at": now}
)
]
if full_errors:
raise ValueError("; ".join(full_errors))
with self._connect() as conn:
try:
conn.execute(
"""
INSERT INTO registrations (repo, payload, registered_at, updated_at)
VALUES (?, ?, ?, ?)
""",
(record["repo"], json.dumps(record), now, now),
)
except sqlite3.IntegrityError as exc:
raise FileExistsError(f"repo already registered: {record['repo']}") from exc
return _public_registration({**record, "registered_at": now, "updated_at": now})
def update_repo(self, repo: str, payload: dict[str, Any]) -> dict[str, Any]:
errors = _validate(payload, "registration_update")
if errors:
raise ValueError("; ".join(errors))
existing = self.get_repo_internal(repo)
if existing is None:
raise KeyError(f"repo not found: {repo}")
updated = {**existing, **{k: v for k, v in payload.items() if v is not None}, "repo": repo}
now = _utc_now()
updated["updated_at"] = now
with self._connect() as conn:
conn.execute(
"""
UPDATE registrations
SET payload = ?, updated_at = ?
WHERE repo = ?
""",
(json.dumps(updated), now, repo),
)
return _public_registration(updated)
def delete_repo(self, repo: str) -> bool:
with self._connect() as conn:
cursor = conn.execute(
"DELETE FROM registrations WHERE repo = ?", (repo,)
)
return cursor.rowcount > 0
def list_repos_internal(self) -> list[dict[str, Any]]:
with self._connect() as conn:
rows = conn.execute(
"SELECT * FROM registrations ORDER BY repo"
).fetchall()
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"])}