hub-core/hub_core/runtime/compat.py
tegwick 8ab1d0c09a
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / pytest-smoke (push) Failing after 2s
feat: add durable Core Hub absorption runtime
2026-08-21 16:16:42 +02:00

605 lines
22 KiB
Python

from __future__ import annotations
import hashlib
import hmac
import secrets
from datetime import datetime, timezone
from typing import Annotated, Any
from uuid import uuid4
import sqlalchemy as sa
from fastapi import APIRouter, Header, HTTPException, Request, status
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from hub_core.runtime.compat_catalogs import (
ANNOTATION_CATEGORIES,
EVENT_TYPES,
POLICY_SCOPES,
WIDGET_TYPES,
)
from hub_core.runtime.models import EventCommand
from hub_core.runtime.postgres_store import PostgresPortStore
from hub_core.runtime.tables import (
compat_api_consumers,
compat_api_keys,
compat_hubs,
compat_manifests,
compat_widgets,
runtime_interaction_events,
)
from hub_core.utils.slugs import slugify_or_default
AuthorizationHeader = Annotated[str | None, Header()]
class SQLCompatibilityStore:
def __init__(self, ports: PostgresPortStore) -> None:
self.ports = ports
self.sessions = ports.sessions
async def validate_token(self, token: str) -> bool:
token_hash = _hash_token(token)
async with self.sessions() as session:
result = await session.execute(
sa.select(compat_api_keys.c.id).where(
compat_api_keys.c.key_hash == token_hash,
compat_api_keys.c.status == "active",
)
)
return result.scalar_one_or_none() is not None
async def list_hubs(self) -> list[dict[str, Any]]:
async with self.sessions() as session:
rows = (
await session.execute(sa.select(compat_hubs).order_by(compat_hubs.c.slug))
).mappings()
return [_hub(row) for row in rows]
async def create_hub(self, body: dict[str, Any]) -> dict[str, Any]:
now = _now()
row = {
"id": str(uuid4()),
"slug": body["slug"],
"name": body["name"],
"domain": body.get("domain"),
"hub_kind": body.get("hubKind"),
"hub_family": body.get("hubFamily"),
"vsm_function": body.get("vsmFunction"),
"vsm_system": body.get("vsmSystem"),
"status": body.get("status", "active"),
"description": body.get("description"),
"body": body,
"created_at": now,
"updated_at": now,
}
async with self.sessions.begin() as session:
await session.execute(compat_hubs.insert().values(**row))
await self.ports._audit(
session,
action="compat.hub.created",
subject_type="hub",
subject_id=row["id"],
correlation_id=None,
value=body,
)
return _hub(row)
async def list_manifests(self, hub_id: str | None = None) -> list[dict[str, Any]]:
statement = sa.select(compat_manifests).order_by(compat_manifests.c.created_at)
if hub_id:
statement = statement.where(compat_manifests.c.hub_id == hub_id)
async with self.sessions() as session:
rows = (await session.execute(statement)).mappings()
return [_manifest(row) for row in rows]
async def create_manifest(self, body: dict[str, Any]) -> dict[str, Any]:
now = _now()
hub_id = body.get("hubId")
hub_slug = None
if hub_id:
async with self.sessions() as session:
hub_slug = (
await session.execute(
sa.select(compat_hubs.c.slug).where(compat_hubs.c.id == hub_id)
)
).scalar_one_or_none()
row = {
"id": str(uuid4()),
"hub_id": hub_id,
"hub_slug": hub_slug,
"manifest_version": body.get("manifestVersion", "0.1.0"),
"status": body.get("status", "draft"),
"body": body,
"created_at": now,
"updated_at": now,
}
async with self.sessions.begin() as session:
await session.execute(compat_manifests.insert().values(**row))
await self.ports._audit(
session,
action="compat.manifest.created",
subject_type="manifest",
subject_id=row["id"],
correlation_id=None,
value=body,
)
return _manifest(row)
async def update_manifest(
self, manifest_id: str, body: dict[str, Any], *, activate: bool = False
) -> dict[str, Any]:
async with self.sessions.begin() as session:
current = (
await session.execute(
sa.select(compat_manifests).where(compat_manifests.c.id == manifest_id)
)
).mappings().one_or_none()
if current is None:
return {"id": manifest_id, "status": "missing"}
payload = dict(current["body"] or {})
payload.update(body)
values = {
"body": payload,
"manifest_version": payload.get(
"manifestVersion", current["manifest_version"]
),
"status": "active" if activate else current["status"],
"updated_at": _now(),
}
await session.execute(
compat_manifests.update()
.where(compat_manifests.c.id == manifest_id)
.values(**values)
)
await self.ports._audit(
session,
action="compat.manifest.activated" if activate else "compat.manifest.updated",
subject_type="manifest",
subject_id=manifest_id,
correlation_id=None,
value=payload,
)
return _manifest({**dict(current), **values})
async def list_consumers(self) -> list[dict[str, Any]]:
async with self.sessions() as session:
rows = (
await session.execute(
sa.select(compat_api_consumers).order_by(compat_api_consumers.c.name)
)
).mappings()
return [_consumer(row) for row in rows]
async def create_consumer(self, body: dict[str, Any]) -> dict[str, Any]:
row = {
"id": str(uuid4()),
"slug": body.get("slug") or slugify_or_default(body["name"]),
"name": body["name"],
"description": body.get("description"),
"hub_capability_manifest_id": body.get("hubCapabilityManifestId"),
"rate_limit_per_minute": body.get("rateLimitPerMinute"),
"quota_per_day": body.get("quotaPerDay"),
"key_prefix": None,
"status": body.get("status", "active"),
"body": body,
"created_at": _now(),
}
async with self.sessions.begin() as session:
await session.execute(compat_api_consumers.insert().values(**row))
await self.ports._audit(
session,
action="compat.consumer.created",
subject_type="api_consumer",
subject_id=row["id"],
correlation_id=None,
value=body,
)
return _consumer(row)
async def create_key(self, consumer_id: str, scopes: str | None) -> dict[str, Any]:
full_key = f"ch_{secrets.token_urlsafe(32)}"
prefix = full_key[:12]
row = {
"id": str(uuid4()),
"api_consumer_id": consumer_id,
"key_prefix": prefix,
"key_hash": _hash_token(full_key),
"scopes": scopes,
"status": "active",
"created_at": _now(),
}
async with self.sessions.begin() as session:
await session.execute(compat_api_keys.insert().values(**row))
await session.execute(
compat_api_consumers.update()
.where(compat_api_consumers.c.id == consumer_id)
.values(key_prefix=prefix)
)
await self.ports._audit(
session,
action="compat.api_key.created",
subject_type="api_key",
subject_id=row["id"],
correlation_id=None,
value={"apiConsumerId": consumer_id, "keyPrefix": prefix, "scopes": scopes},
)
return {
"fullKey": full_key,
"apiKey": {
"id": row["id"],
"apiConsumerId": consumer_id,
"keyPrefix": prefix,
"scopes": scopes,
"status": "active",
},
}
async def list_widgets(self) -> list[dict[str, Any]]:
async with self.sessions() as session:
rows = (
await session.execute(sa.select(compat_widgets).order_by(compat_widgets.c.name))
).mappings()
return [_widget(row) for row in rows]
async def create_widget(self, body: dict[str, Any]) -> dict[str, Any]:
row = {
"id": str(uuid4()),
"hub_id": body["hubId"],
"name": body["name"],
"widget_type": body.get("widgetType"),
"capability_ref": body.get("capabilityRef"),
"view_context": body.get("viewContext"),
"policy_scope": body.get("policyScope"),
"status": body.get("status", "active"),
"body": body,
"created_at": _now(),
}
async with self.sessions.begin() as session:
await session.execute(compat_widgets.insert().values(**row))
await self.ports._audit(
session,
action="compat.widget.created",
subject_type="widget",
subject_id=row["id"],
correlation_id=None,
value=body,
)
return _widget(row)
async def list_interactions(self) -> list[dict[str, Any]]:
async with self.sessions() as session:
rows = (
await session.execute(
sa.select(runtime_interaction_events).order_by(
runtime_interaction_events.c.recorded_at,
runtime_interaction_events.c.id,
)
)
).mappings()
values: list[dict[str, Any]] = []
for row in rows:
legacy = dict((row["payload"] or {}).get("legacy") or {})
if legacy:
legacy["id"] = row["id"]
values.append(legacy)
return values
async def create_interaction(self, body: dict[str, Any]) -> dict[str, Any]:
correlation_id = uuid4()
command = EventCommand(
schema_version="0.1.0",
correlation_id=correlation_id,
event_type=body["eventType"],
occurred_at=_now(),
subject_refs={"widget": body["widgetId"]},
payload={"legacy": body},
)
accepted = await self.ports.append_interaction(command)
return {"id": accepted.id, **body}
def create_compatibility_router() -> APIRouter:
router = APIRouter()
@router.get("/api/v2/widget-types")
async def widget_types(request: Request) -> list[dict[str, str]]:
_enabled(request, "system")
return WIDGET_TYPES
@router.get("/api/v2/event-types")
async def event_types(request: Request) -> list[dict[str, str]]:
_enabled(request, "system")
return EVENT_TYPES
@router.get("/api/v2/annotation-categories")
async def annotation_categories(request: Request) -> list[dict[str, str]]:
_enabled(request, "system")
return ANNOTATION_CATEGORIES
@router.get("/api/v2/policy-scopes")
async def policy_scopes(request: Request) -> list[dict[str, str]]:
_enabled(request, "system")
return POLICY_SCOPES
@router.get("/api/v2/hubs")
async def list_hubs(request: Request, authorization: AuthorizationHeader = None) -> dict:
await _protected(request, "registry", authorization)
return _page(await _store(request).list_hubs())
@router.post("/api/v2/hubs", status_code=status.HTTP_201_CREATED)
async def create_hub(
body: dict[str, Any], request: Request, authorization: AuthorizationHeader = None
) -> dict:
await _protected(request, "registry", authorization, write=True)
return await _store(request).create_hub(body)
@router.get("/api/v2/hub-capability-manifests")
async def list_manifests(
request: Request,
authorization: AuthorizationHeader = None,
hubId: str | None = None,
) -> dict:
await _protected(request, "registry", authorization)
return _page(await _store(request).list_manifests(hubId))
@router.post("/api/v2/hub-capability-manifests", status_code=status.HTTP_201_CREATED)
async def create_manifest(
body: dict[str, Any], request: Request, authorization: AuthorizationHeader = None
) -> dict:
await _protected(request, "registry", authorization, write=True)
return await _store(request).create_manifest(body)
@router.patch("/api/v2/hub-capability-manifests/{manifest_id}")
async def patch_manifest(
manifest_id: str,
body: dict[str, Any],
request: Request,
authorization: AuthorizationHeader = None,
) -> dict:
await _protected(request, "registry", authorization, write=True)
return await _store(request).update_manifest(manifest_id, body)
@router.post("/api/v2/hub-capability-manifests/{manifest_id}/activate")
async def activate_manifest(
manifest_id: str, request: Request, authorization: AuthorizationHeader = None
) -> dict:
await _protected(request, "registry", authorization, write=True)
return await _store(request).update_manifest(manifest_id, {}, activate=True)
@router.get("/api/v2/hub-registry")
async def hub_registry(
request: Request, authorization: AuthorizationHeader = None
) -> dict:
await _protected(request, "registry", authorization)
store = _store(request)
return {
"data": {
"hubs": await store.list_hubs(),
"hubCapabilityManifests": await store.list_manifests(),
}
}
@router.get("/api/v2/api-consumers")
async def list_consumers(
request: Request, authorization: AuthorizationHeader = None
) -> dict:
await _protected(request, "credentials", authorization)
return _page(await _store(request).list_consumers())
@router.post("/api/v2/api-consumers", status_code=status.HTTP_201_CREATED)
async def create_consumer(
body: dict[str, Any], request: Request, authorization: AuthorizationHeader = None
) -> dict:
await _protected(request, "credentials", authorization, write=True)
return await _store(request).create_consumer(body)
@router.post(
"/api/v2/api-consumers/{consumer_id}/api-keys",
status_code=status.HTTP_201_CREATED,
)
async def create_key(
consumer_id: str,
body: dict[str, Any],
request: Request,
authorization: AuthorizationHeader = None,
) -> dict:
await _protected(request, "credentials", authorization, write=True)
return await _store(request).create_key(consumer_id, body.get("scopes"))
@router.post("/api/v2/token")
async def token(request: Request, authorization: AuthorizationHeader = None) -> dict:
await _protected(request, "credentials", authorization)
return {"access_token": "already-authenticated", "token_type": "bearer"}
@router.get("/api/v2/widgets")
async def list_widgets(
request: Request, authorization: AuthorizationHeader = None
) -> dict:
await _protected(request, "interaction", authorization)
return _page(await _store(request).list_widgets())
@router.post("/api/v2/widgets", status_code=status.HTTP_201_CREATED)
async def create_widget(
body: dict[str, Any], request: Request, authorization: AuthorizationHeader = None
) -> dict:
await _protected(request, "interaction", authorization, write=True)
return await _store(request).create_widget(body)
@router.get("/api/v2/interaction-events")
async def list_interactions(
request: Request, authorization: AuthorizationHeader = None
) -> dict:
await _protected(request, "interaction", authorization)
return _page(await _store(request).list_interactions())
@router.post("/api/v2/interaction-events", status_code=status.HTTP_201_CREATED)
async def create_interaction(
body: dict[str, Any], request: Request, authorization: AuthorizationHeader = None
) -> dict:
await _protected(request, "interaction", authorization, write=True)
return await _store(request).create_interaction(body)
for index, path in enumerate((
"annotations",
"requirement-candidates",
"decision-records",
"deployment-records",
"outcome-signals",
)):
async def empty_collection(
request: Request, authorization: AuthorizationHeader = None
) -> dict:
await _protected(request, "deferred", authorization)
return {"data": []}
stem = path.replace("-", "_")
router.add_api_route(
f"/api/v2/{path}",
empty_collection,
methods=["GET"],
operation_id=f"compat_list_{stem}_{index}",
)
router.add_api_route(
f"/api/v2/{path}",
empty_collection,
methods=["POST"],
status_code=201,
operation_id=f"compat_create_{stem}_{index}",
)
@router.get("/api/v2/openapi.json", include_in_schema=False)
async def openapi_json(request: Request) -> dict[str, Any]:
_enabled(request, "system")
document = request.app.openapi()
paths = document.setdefault("paths", {})
for path, operations in list(paths.items()):
if path.startswith("/api/v2/"):
paths.setdefault(path.removeprefix("/api/v2"), operations)
return document
@router.get("/api/v2/openapi.yaml", include_in_schema=False)
async def openapi_yaml(request: Request) -> PlainTextResponse:
document = await openapi_json(request)
info = document.get("info", {})
return PlainTextResponse(
"openapi: 3.1.0\ninfo:\n"
f" title: {info.get('title', 'Hub Core Runtime')}\n"
f" version: {info.get('version', '0.2.0')}\n",
media_type="application/yaml",
)
@router.get("/api/v2/docs", include_in_schema=False)
async def docs(request: Request) -> RedirectResponse:
_enabled(request, "system")
return RedirectResponse(url="/docs")
@router.get("/console", response_class=HTMLResponse)
async def console(
request: Request, authorization: AuthorizationHeader = None
) -> HTMLResponse:
await _protected(request, "operator", authorization)
return HTMLResponse(
"<!doctype html><title>Hub Core Operator Console</title>"
"<h1>Hub Core Operator Console</h1>"
"<p>Compatibility runtime candidate; projections are authoritative.</p>"
)
return router
def _enabled(request: Request, group: str) -> None:
if group not in request.app.state.settings.v2_groups:
raise HTTPException(status_code=404, detail="compatibility group is disabled")
async def _protected(
request: Request,
group: str,
authorization: str | None,
*,
write: bool = False,
) -> None:
_enabled(request, group)
if write and group not in request.app.state.settings.v2_write_groups:
raise HTTPException(status_code=503, detail="compatibility group is read-only")
if not authorization or not authorization.startswith("Bearer "):
raise _unauthorized("Missing bearer token")
token = authorization.removeprefix("Bearer ").strip()
configured = request.app.state.settings.api_token
if configured and hmac.compare_digest(token, configured):
return
try:
valid = await _store(request).validate_token(token)
except Exception as exc:
raise HTTPException(status_code=503, detail="authorization dependency unavailable") from exc
if not valid:
raise _unauthorized("Invalid bearer token")
def _store(request: Request) -> SQLCompatibilityStore:
store = request.app.state.compat_store
if store is None:
raise HTTPException(status_code=503, detail="compatibility store unavailable")
return store
def _unauthorized(message: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "unauthorized", "message": message},
)
def _page(values: list[dict[str, Any]]) -> dict[str, Any]:
return {"data": values, "count": len(values)}
def _hub(row: Any) -> dict[str, Any]:
value = dict(row["body"] or {})
value.update(
id=row["id"], slug=row["slug"], name=row["name"], domain=row["domain"],
hubKind=row["hub_kind"], hubFamily=row["hub_family"],
vsmFunction=row["vsm_function"], vsmSystem=row["vsm_system"],
status=row["status"], description=row["description"],
)
return {key: item for key, item in value.items() if item is not None}
def _manifest(row: Any) -> dict[str, Any]:
value = dict(row["body"] or {})
value.update(
id=row["id"], hubId=row["hub_id"], hubSlug=row["hub_slug"],
manifestVersion=row["manifest_version"], status=row["status"],
)
return {key: item for key, item in value.items() if item is not None}
def _consumer(row: Any) -> dict[str, Any]:
value = dict(row["body"] or {})
value.update(
id=row["id"], slug=row["slug"], name=row["name"],
description=row["description"],
hubCapabilityManifestId=row["hub_capability_manifest_id"],
rateLimitPerMinute=row["rate_limit_per_minute"], quotaPerDay=row["quota_per_day"],
keyPrefix=row["key_prefix"], status=row["status"],
)
return {key: item for key, item in value.items() if item is not None}
def _widget(row: Any) -> dict[str, Any]:
value = dict(row["body"] or {})
value.update(
id=row["id"], hubId=row["hub_id"], name=row["name"],
widgetType=row["widget_type"], capabilityRef=row["capability_ref"],
viewContext=row["view_context"], policyScope=row["policy_scope"],
status=row["status"],
)
return {key: item for key, item in value.items() if item is not None}
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
def _now() -> datetime:
return datetime.now(timezone.utc)