Implement ops-hub bootstrap compatibility

This commit is contained in:
2026-06-27 13:04:54 +02:00
parent 1e87adbcbb
commit c532ad8731
21 changed files with 1330 additions and 402 deletions

View File

@@ -1,32 +1,368 @@
from collections.abc import Callable
from typing import Annotated
import json
import re
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Request
from fastapi import APIRouter, Depends, Query, Request, status
from fastapi.responses import PlainTextResponse, RedirectResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from core_hub.schemas import CatalogItem, HubCapabilityManifest, HubResource
from core_hub.crypto import new_api_key
from core_hub.db import session_scope
from core_hub.models import (
ApiConsumer,
ApiKey,
Hub,
HubCapabilityManifest,
InteractionEvent,
Widget,
)
from core_hub.schemas import CatalogItem
from core_hub.security import require_api_token
from core_hub.seeds import (
ANNOTATION_CATEGORIES,
EVENT_TYPES,
POLICY_SCOPES,
PUBLIC_HUBS,
PUBLIC_MANIFESTS,
WIDGET_TYPES,
)
router = APIRouter(prefix="/api/v2", tags=["api-v2"])
TokenDependency = Annotated[str, Depends(require_api_token)]
SessionDependency = Annotated[AsyncSession, Depends(session_scope)]
@router.get("/hubs", response_model=list[HubResource])
def list_hubs() -> list[HubResource]:
return PUBLIC_HUBS
def slugify(value: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
return slug or "resource"
@router.get("/hub-capability-manifests", response_model=list[HubCapabilityManifest])
def list_hub_capability_manifests() -> list[HubCapabilityManifest]:
return PUBLIC_MANIFESTS
def encode_body(payload: dict[str, Any]) -> str:
return json.dumps(payload, sort_keys=True)
def decode_body(raw: str | None) -> dict[str, Any]:
if not raw:
return {}
value = json.loads(raw)
return value if isinstance(value, dict) else {}
def page(data: list[dict[str, Any]]) -> dict[str, Any]:
return {"data": data, "count": len(data)}
def serialize_hub(row: Hub) -> dict[str, Any]:
payload = decode_body(row.body_json)
payload.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,
}
)
if row.description is not None:
payload["description"] = row.description
return {key: value for key, value in payload.items() if value is not None}
def serialize_manifest(row: HubCapabilityManifest) -> dict[str, Any]:
payload = decode_body(row.body_json)
payload.update(
{
"id": row.id,
"hubId": row.hub_id,
"hubSlug": row.hub_slug,
"manifestVersion": row.manifest_version,
"status": row.status,
}
)
return {key: value for key, value in payload.items() if value is not None}
def serialize_consumer(row: ApiConsumer) -> dict[str, Any]:
payload = decode_body(row.body_json)
payload.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: value for key, value in payload.items() if value is not None}
def serialize_widget(row: Widget) -> dict[str, Any]:
payload = decode_body(row.body_json)
payload.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: value for key, value in payload.items() if value is not None}
def serialize_event(row: InteractionEvent) -> dict[str, Any]:
payload = decode_body(row.body_json)
payload.update(
{
"id": row.id,
"widgetId": row.widget_id,
"eventType": row.event_type,
"viewContext": row.view_context,
"metadata": decode_body(row.metadata_json),
}
)
return {key: value for key, value in payload.items() if value is not None}
def compatibility_openapi(request: Request) -> dict[str, Any]:
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("/hubs")
async def list_hubs(_: TokenDependency, session: SessionDependency) -> dict[str, Any]:
result = await session.execute(select(Hub).order_by(Hub.slug))
return page([serialize_hub(row) for row in result.scalars()])
@router.post("/hubs", status_code=status.HTTP_201_CREATED)
async def create_hub(
body: dict[str, Any], _: TokenDependency, session: SessionDependency
) -> dict[str, Any]:
row = Hub(
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_json=encode_body(body),
)
session.add(row)
await session.commit()
await session.refresh(row)
return serialize_hub(row)
@router.get("/hub-capability-manifests")
async def list_hub_capability_manifests(
_: TokenDependency,
session: SessionDependency,
hub_id: Annotated[str | None, Query(alias="hubId")] = None,
) -> dict[str, Any]:
statement = select(HubCapabilityManifest).order_by(HubCapabilityManifest.created_at)
if hub_id:
statement = statement.where(HubCapabilityManifest.hub_id == hub_id)
result = await session.execute(statement)
return page([serialize_manifest(row) for row in result.scalars()])
@router.post("/hub-capability-manifests", status_code=status.HTTP_201_CREATED)
async def create_hub_capability_manifest(
body: dict[str, Any], _: TokenDependency, session: SessionDependency
) -> dict[str, Any]:
hub_id = body.get("hubId")
hub_slug = None
if hub_id:
hub = await session.get(Hub, hub_id)
hub_slug = hub.slug if hub else None
row = HubCapabilityManifest(
hub_id=hub_id,
hub_slug=hub_slug,
manifest_version=body.get("manifestVersion", "0.1.0"),
status=body.get("status", "draft"),
body_json=encode_body(body),
)
session.add(row)
await session.commit()
await session.refresh(row)
return serialize_manifest(row)
@router.patch("/hub-capability-manifests/{manifest_id}")
async def update_hub_capability_manifest(
manifest_id: str, body: dict[str, Any], _: TokenDependency, session: SessionDependency
) -> dict[str, Any]:
row = await session.get(HubCapabilityManifest, manifest_id)
if row is None:
return {"id": manifest_id, "status": "missing"}
payload = decode_body(row.body_json)
payload.update(body)
row.manifest_version = payload.get("manifestVersion", row.manifest_version)
row.body_json = encode_body(payload)
await session.commit()
await session.refresh(row)
return serialize_manifest(row)
@router.post("/hub-capability-manifests/{manifest_id}/activate")
async def activate_hub_capability_manifest(
manifest_id: str, _: TokenDependency, session: SessionDependency
) -> dict[str, Any]:
row = await session.get(HubCapabilityManifest, manifest_id)
if row is None:
return {"id": manifest_id, "status": "missing"}
row.status = "active"
await session.commit()
await session.refresh(row)
return serialize_manifest(row)
@router.get("/api-consumers")
async def list_api_consumers(_: TokenDependency, session: SessionDependency) -> dict[str, Any]:
result = await session.execute(select(ApiConsumer).order_by(ApiConsumer.name))
return page([serialize_consumer(row) for row in result.scalars()])
@router.post("/api-consumers", status_code=status.HTTP_201_CREATED)
async def create_api_consumer(
body: dict[str, Any], _: TokenDependency, session: SessionDependency
) -> dict[str, Any]:
row = ApiConsumer(
slug=body.get("slug") or slugify(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"),
status=body.get("status", "active"),
body_json=encode_body(body),
)
session.add(row)
await session.commit()
await session.refresh(row)
return serialize_consumer(row)
@router.post("/api-consumers/{consumer_id}/api-keys", status_code=status.HTTP_201_CREATED)
async def create_api_key(
consumer_id: str, body: dict[str, Any], _: TokenDependency, session: SessionDependency
) -> dict[str, Any]:
full_key, key_prefix, key_hash = new_api_key()
row = ApiKey(
api_consumer_id=consumer_id,
key_prefix=key_prefix,
key_hash=key_hash,
scopes=body.get("scopes"),
)
session.add(row)
consumer = await session.get(ApiConsumer, consumer_id)
if consumer:
consumer.key_prefix = key_prefix
await session.commit()
await session.refresh(row)
return {
"fullKey": full_key,
"apiKey": {
"id": row.id,
"apiConsumerId": consumer_id,
"keyPrefix": key_prefix,
"scopes": row.scopes,
"status": row.status,
},
}
@router.get("/widgets")
async def list_widgets(_: TokenDependency, session: SessionDependency) -> dict[str, Any]:
result = await session.execute(select(Widget).order_by(Widget.name))
return page([serialize_widget(row) for row in result.scalars()])
@router.post("/widgets", status_code=status.HTTP_201_CREATED)
async def create_widget(
body: dict[str, Any], _: TokenDependency, session: SessionDependency
) -> dict[str, Any]:
row = Widget(
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_json=encode_body(body),
)
session.add(row)
await session.commit()
await session.refresh(row)
return serialize_widget(row)
@router.get("/interaction-events")
async def list_interaction_events(_: TokenDependency, session: SessionDependency) -> dict[str, Any]:
result = await session.execute(select(InteractionEvent).order_by(InteractionEvent.created_at))
return page([serialize_event(row) for row in result.scalars()])
@router.post("/interaction-events", status_code=status.HTTP_201_CREATED)
async def create_interaction_event(
body: dict[str, Any], _: TokenDependency, session: SessionDependency
) -> dict[str, Any]:
row = InteractionEvent(
widget_id=body["widgetId"],
event_type=body["eventType"],
view_context=body.get("viewContext"),
metadata_json=encode_body(body.get("metadata", {})),
body_json=encode_body(body),
)
session.add(row)
await session.commit()
await session.refresh(row)
return serialize_event(row)
@router.get("/hub-registry")
async def hub_registry(_: TokenDependency, session: SessionDependency) -> dict[str, Any]:
hubs = await list_hubs(_, session)
manifests = await list_hub_capability_manifests(_, session)
return {"data": {"hubs": hubs["data"], "hubCapabilityManifests": manifests["data"]}}
@router.get("/annotations")
@router.post("/annotations", status_code=status.HTTP_201_CREATED)
@router.get("/requirement-candidates")
@router.post("/requirement-candidates", status_code=status.HTTP_201_CREATED)
@router.get("/decision-records")
@router.post("/decision-records", status_code=status.HTTP_201_CREATED)
@router.get("/deployment-records")
@router.post("/deployment-records", status_code=status.HTTP_201_CREATED)
@router.get("/outcome-signals")
@router.post("/outcome-signals", status_code=status.HTTP_201_CREATED)
async def deferred_collection(_: TokenDependency) -> dict[str, list]:
return {"data": []}
@router.post("/token", tags=["api-v2-protected"], operation_id="post_token")
async def issue_token(_: TokenDependency) -> dict[str, str]:
return {"access_token": "already-authenticated", "token_type": "bearer"}
@router.get("/widget-types", response_model=list[CatalogItem])
@@ -50,13 +386,13 @@ def list_policy_scopes() -> list[CatalogItem]:
@router.get("/openapi.json", include_in_schema=False)
def openapi_json(request: Request) -> dict:
return request.app.openapi()
def openapi_json(request: Request) -> dict[str, Any]:
return compatibility_openapi(request)
@router.get("/openapi.yaml", include_in_schema=False)
def openapi_yaml(request: Request) -> PlainTextResponse:
info = request.app.openapi().get("info", {})
info = compatibility_openapi(request).get("info", {})
body = (
"openapi: 3.1.0\n"
"info:\n"
@@ -69,41 +405,3 @@ def openapi_yaml(request: Request) -> PlainTextResponse:
@router.get("/docs", include_in_schema=False)
def docs_redirect() -> RedirectResponse:
return RedirectResponse(url="/docs")
def protected_empty_collection(
name: str, method: str
) -> Callable[[TokenDependency], dict[str, list]]:
def endpoint(_: TokenDependency) -> dict[str, list]:
return {name: []}
endpoint.__name__ = f"{method.lower()}_{name}_endpoint"
return endpoint
def register_protected_collection(path: str, name: str, methods: tuple[str, ...]) -> None:
for method in methods:
method_lower = method.lower()
router.add_api_route(
path,
protected_empty_collection(name, method),
methods=[method],
tags=["api-v2-protected"],
operation_id=f"{method_lower}_{name}",
)
register_protected_collection("/api-consumers", "api_consumers", ("GET", "POST"))
register_protected_collection("/widgets", "widgets", ("GET", "POST"))
register_protected_collection("/interaction-events", "interaction_events", ("GET", "POST"))
register_protected_collection("/annotations", "annotations", ("GET", "POST"))
register_protected_collection("/requirement-candidates", "requirement_candidates", ("GET", "POST"))
register_protected_collection("/decision-records", "decision_records", ("GET", "POST"))
register_protected_collection("/deployment-records", "deployment_records", ("GET", "POST"))
register_protected_collection("/outcome-signals", "outcome_signals", ("GET", "POST"))
register_protected_collection("/hub-registry", "hub_registry", ("GET",))
@router.post("/token", tags=["api-v2-protected"], operation_id="post_token")
def issue_token(_: TokenDependency) -> dict[str, str]:
return {"access_token": "already-authenticated", "token_type": "bearer"}

View File

@@ -1,17 +1,33 @@
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI
import core_hub.models # noqa: F401
from core_hub import __version__
from core_hub.api.v2 import router as api_v2_router
from core_hub.config import get_settings
from core_hub.db import Base, get_engine
from core_hub.schemas import HealthResponse, ReadinessResponse
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
settings = get_settings()
if settings.auto_create_tables:
engine = get_engine()
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
yield
def create_app() -> FastAPI:
settings = get_settings()
app = FastAPI(
title="Core Hub API",
version=__version__,
description="Third-generation production interaction framework API.",
lifespan=lifespan,
)
@app.get("/healthz", response_model=HealthResponse, tags=["system"])

View File

@@ -14,6 +14,9 @@ class Settings(BaseModel):
)
)
api_token: str | None = Field(default_factory=lambda: os.getenv("CORE_HUB_API_TOKEN"))
auto_create_tables: bool = Field(
default_factory=lambda: os.getenv("CORE_HUB_AUTO_CREATE_TABLES", "0") == "1"
)
@property
def auth_configured(self) -> bool:

13
src/core_hub/crypto.py Normal file
View File

@@ -0,0 +1,13 @@
import hashlib
import secrets
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def new_api_key(prefix: str = "ch") -> tuple[str, str, str]:
secret = secrets.token_urlsafe(32)
full_key = f"{prefix}_{secret}"
key_prefix = full_key[:12]
return full_key, key_prefix, hash_token(full_key)

View File

@@ -1,20 +1,30 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, String, Text, func
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from .db import Base
def uuid_str() -> str:
return str(uuid.uuid4())
class Hub(Base):
__tablename__ = "hubs"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
slug: Mapped[str] = mapped_column(String(120), unique=True, index=True)
name: Mapped[str] = mapped_column(String(240))
domain: Mapped[str | None] = mapped_column(String(240))
hub_kind: Mapped[str | None] = mapped_column(String(80))
hub_family: Mapped[str | None] = mapped_column(String(80))
vsm_function: Mapped[str | None] = mapped_column(String(80))
vsm_system: Mapped[str | None] = mapped_column(String(80))
status: Mapped[str] = mapped_column(String(40), default="active")
description: Mapped[str | None] = mapped_column(Text())
body_json: Mapped[str] = mapped_column(Text(), default="{}")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
@@ -24,20 +34,71 @@ class Hub(Base):
class HubCapabilityManifest(Base):
__tablename__ = "hub_capability_manifests"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
hub_slug: Mapped[str] = mapped_column(String(120), index=True)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
hub_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("hubs.id"), index=True)
hub_slug: Mapped[str | None] = mapped_column(String(120), index=True)
manifest_version: Mapped[str] = mapped_column(String(40), default="0.1.0")
status: Mapped[str] = mapped_column(String(40), default="draft")
body_json: Mapped[str] = mapped_column(Text())
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class ApiConsumer(Base):
__tablename__ = "api_consumers"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
slug: Mapped[str] = mapped_column(String(120), unique=True, index=True)
name: Mapped[str] = mapped_column(String(240))
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
slug: Mapped[str | None] = mapped_column(String(120), unique=True, index=True)
name: Mapped[str] = mapped_column(String(240), index=True)
description: Mapped[str | None] = mapped_column(Text())
hub_capability_manifest_id: Mapped[str | None] = mapped_column(String(36), index=True)
rate_limit_per_minute: Mapped[int | None] = mapped_column(Integer())
quota_per_day: Mapped[int | None] = mapped_column(Integer())
key_prefix: Mapped[str | None] = mapped_column(String(32))
key_hash: Mapped[str | None] = mapped_column(String(255))
status: Mapped[str] = mapped_column(String(40), default="active")
body_json: Mapped[str] = mapped_column(Text(), default="{}")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class ApiKey(Base):
__tablename__ = "api_keys"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
api_consumer_id: Mapped[str] = mapped_column(
String(36), ForeignKey("api_consumers.id"), index=True
)
key_prefix: Mapped[str] = mapped_column(String(32), index=True)
key_hash: Mapped[str] = mapped_column(String(255), unique=True)
scopes: Mapped[str | None] = mapped_column(Text())
status: Mapped[str] = mapped_column(String(40), default="active")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class Widget(Base):
__tablename__ = "widgets"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
hub_id: Mapped[str] = mapped_column(String(36), ForeignKey("hubs.id"), index=True)
name: Mapped[str] = mapped_column(String(240))
widget_type: Mapped[str | None] = mapped_column(String(120), index=True)
capability_ref: Mapped[str | None] = mapped_column(String(240), index=True)
view_context: Mapped[str | None] = mapped_column(String(500))
policy_scope: Mapped[str | None] = mapped_column(String(120))
status: Mapped[str] = mapped_column(String(40), default="active")
body_json: Mapped[str] = mapped_column(Text(), default="{}")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class InteractionEvent(Base):
__tablename__ = "interaction_events"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
widget_id: Mapped[str] = mapped_column(String(36), ForeignKey("widgets.id"), index=True)
event_type: Mapped[str] = mapped_column(String(160), index=True)
view_context: Mapped[str | None] = mapped_column(String(500))
metadata_json: Mapped[str] = mapped_column(Text(), default="{}")
body_json: Mapped[str] = mapped_column(Text(), default="{}")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View File

@@ -1,15 +1,22 @@
from typing import Annotated
from fastapi import Depends, Header, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from .config import Settings, get_settings
from .crypto import hash_token
from .db import session_scope
from .models import ApiKey
SettingsDependency = Annotated[Settings, Depends(get_settings)]
SessionDependency = Annotated[AsyncSession, Depends(session_scope)]
AuthorizationHeader = Annotated[str | None, Header()]
def require_api_token(
async def require_api_token(
settings: SettingsDependency,
session: SessionDependency,
authorization: AuthorizationHeader = None,
) -> str:
if not authorization or not authorization.startswith("Bearer "):
@@ -19,10 +26,17 @@ def require_api_token(
)
token = authorization.removeprefix("Bearer ").strip()
if not settings.api_token or token != settings.api_token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "unauthorized", "message": "Invalid bearer token"},
)
if settings.api_token and token == settings.api_token:
return token
return token
token_hash = hash_token(token)
result = await session.execute(
select(ApiKey).where(ApiKey.key_hash == token_hash, ApiKey.status == "active")
)
if result.scalar_one_or_none():
return token
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "unauthorized", "message": "Invalid bearer token"},
)