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

2
.gitignore vendored
View File

@@ -174,3 +174,5 @@ cython_debug/
# PyPI configuration file
.pypirc
# Local smoke/runtime state
.local/

View File

@@ -0,0 +1,4 @@
{
"data": [],
"count": 0
}

View File

@@ -0,0 +1,4 @@
{
"data": [],
"count": 0
}

View File

@@ -0,0 +1,9 @@
{
"ok": true,
"hub": {"slug": "ops-hub", "created": true},
"manifest": {"status": "active", "created": true, "activated": true},
"apiConsumer": {"name": "ops-hub", "created": true},
"runtimeKey": {"created": true, "keyPrefix": "ch_example", "field": "OPS_HUB_KEY"},
"widgets": {"created": 14, "reused": 0},
"event": {"eventType": "ops-endpoint-verified"}
}

View File

@@ -1,16 +0,0 @@
[
{
"hub_slug": "core-hub",
"manifest_version": "0.1.0",
"capabilities": [
"capability.infotech.core-hub",
"api.v2.compatibility",
"contract.ir"
],
"endpoints": [
{"rel": "hubs", "href": "/api/v2/hubs"},
{"rel": "events", "href": "/api/v2/interaction-events"},
{"rel": "widgets", "href": "/api/v2/widgets"}
]
}
]

View File

@@ -1,17 +0,0 @@
[
{
"slug": "core-hub",
"name": "Core Hub",
"status": "active",
"description": "Third-generation production interaction framework.",
"capabilities": [
"hub-registry",
"interaction-events",
"workplan-coordination",
"operator-console"
],
"links": [
{"rel": "self", "href": "/api/v2/hubs/core-hub"}
]
}
]

File diff suppressed because it is too large Load Diff

View File

@@ -4,12 +4,14 @@
Core Hub must preserve the Inter-Hub API surfaces that existing consumers rely on until each consumer has moved to a new explicit Core Hub contract.
## Initial Compatibility Surface
## Current Compatibility Semantics
Public/read surfaces:
The active ops-hub gate expects unauthenticated `GET /api/v2/hubs` to return `401`, not `404` or public data. Core Hub therefore treats hub registry and bootstrap resources as protected, while keeping static catalogs and API documentation public.
The OpenAPI document served at `/api/v2/openapi.json` includes both FastAPI-native `/api/v2/...` paths and Inter-Hub-compatible unprefixed aliases such as `/hubs`, `/hub-capability-manifests`, `/api-consumers`, and `/policy-scopes`.
## Public Catalog/Documentation Surfaces
- `GET /api/v2/hubs`
- `GET /api/v2/hub-capability-manifests`
- `GET /api/v2/widget-types`
- `GET /api/v2/event-types`
- `GET /api/v2/annotation-categories`
@@ -18,31 +20,38 @@ Public/read surfaces:
- `GET /api/v2/openapi.yaml`
- `GET /api/v2/docs`
Protected/write or authenticated surfaces:
## Protected Bootstrap Surfaces
- `GET /api/v2/hubs`
- `POST /api/v2/hubs`
- `GET /api/v2/hub-capability-manifests`
- `POST /api/v2/hub-capability-manifests`
- `PATCH /api/v2/hub-capability-manifests/{manifest_id}`
- `POST /api/v2/hub-capability-manifests/{manifest_id}/activate`
- `GET /api/v2/api-consumers`
- `POST /api/v2/api-consumers`
- `POST /api/v2/api-consumers/{consumer_id}/api-keys`
- `GET /api/v2/widgets`
- `POST /api/v2/widgets`
- `GET /api/v2/interaction-events`
- `POST /api/v2/interaction-events`
- `GET /api/v2/hub-registry`
- `POST /api/v2/token`
- `/api/v2/api-consumers`
- `/api/v2/widgets`
- `/api/v2/interaction-events`
Deferred protected surfaces currently return empty compatibility collections:
- `/api/v2/annotations`
- `/api/v2/requirement-candidates`
- `/api/v2/decision-records`
- `/api/v2/deployment-records`
- `/api/v2/outcome-signals`
- `/api/v2/hub-registry`
Potentially preserved SDK endpoints:
- `/api/v2/sdk`
- `/api/v2/sdk/ihf-client.ts`
- `/api/v2/sdk/ihf-client.py`
## Known Consumers
- `ops-hub` bootstrap and Inter-Hub gate probes
- `activity-core` optional Inter-Hub sink
- Custodian workplans and operator smokes
- Downstream docs or probes that check the public hub registry
- Downstream docs or probes that check the hub registry
## Acceptance Standard
@@ -54,6 +63,14 @@ A route is compatible when:
- error shape is documented;
- at least one consumer smoke passes against Core Hub.
## Current Evidence
2026-06-27 local evidence:
- `ops-hub/scripts/interhub-gate-probe.py --base http://127.0.0.1:8017` passed.
- `ops-hub/scripts/ops-hub-bootstrap-api.py` passed against local Core Hub with a throwaway operator token.
- The bootstrap created an ops-hub record, activated manifest, API consumer, runtime API key, 14 widgets, and one `ops-endpoint-verified` event.
## Transition Rule
Do not remove an Inter-Hub-compatible route until every known consumer either passes against the new Core Hub-native route or has a recorded cancellation decision.

View File

@@ -1,15 +1,13 @@
# Inter-Hub Legacy Inventory
Status: initial inventory for Core Hub compatibility work.
Status: initial inventory plus local ops-hub compatibility evidence.
## Production Role
`inter-hub` is the Haskell/IHP implementation that currently represents the second-generation interaction framework idea. Core Hub should preserve consumer-visible behavior before retiring it.
## Public API v2 Routes To Preserve
## Catalog And Documentation Routes
- `GET /api/v2/hubs`
- `GET /api/v2/hub-capability-manifests`
- `GET /api/v2/widget-types`
- `GET /api/v2/event-types`
- `GET /api/v2/annotation-categories`
@@ -20,22 +18,35 @@ Status: initial inventory for Core Hub compatibility work.
## Protected API v2 Routes To Preserve
- `GET /api/v2/hubs`
- `POST /api/v2/hubs`
- `GET /api/v2/hub-capability-manifests`
- `POST /api/v2/hub-capability-manifests`
- `PATCH /api/v2/hub-capability-manifests/{manifest_id}`
- `POST /api/v2/hub-capability-manifests/{manifest_id}/activate`
- `GET /api/v2/api-consumers`
- `POST /api/v2/api-consumers`
- `POST /api/v2/api-consumers/{consumer_id}/api-keys`
- `GET /api/v2/widgets`
- `POST /api/v2/widgets`
- `GET /api/v2/interaction-events`
- `POST /api/v2/interaction-events`
- `GET /api/v2/hub-registry`
- `POST /api/v2/token`
- `/api/v2/api-consumers`
- `/api/v2/widgets`
- `/api/v2/interaction-events`
Deferred protected surfaces:
- `/api/v2/annotations`
- `/api/v2/requirement-candidates`
- `/api/v2/decision-records`
- `/api/v2/deployment-records`
- `/api/v2/outcome-signals`
- `/api/v2/hub-registry`
## Current Consumers
- `ops-hub` bootstrap and gate probes use hub, manifest, API consumer, widget, interaction-event, token, and hub-registry surfaces.
- `ops-hub` bootstrap and gate probes use hub, manifest, API consumer, API key, widget, interaction-event, token, and hub-registry surfaces.
- `activity-core` can emit through an Inter-Hub sink but has a State Hub fallback during transition.
- Custodian workplans and operator smokes depend on public route evidence and protected smoke evidence.
- Custodian workplans and operator smokes depend on route evidence and protected smoke evidence.
## Data Sources For Migration
@@ -43,6 +54,14 @@ Status: initial inventory for Core Hub compatibility work.
- API consumer and key-prefix rows, with raw keys treated as non-recoverable.
- Hub manifests, widgets, registries, event rows, annotations, decisions, deployments, outcomes, and request logs where historically useful.
## Compatibility Risk
## Local Compatibility Evidence
The first Core Hub implementation preserves route presence and auth-before-business behavior with seed data. Full compatibility still requires fixture capture from legacy Inter-Hub and consumer smokes against ops-hub and activity-core.
2026-06-27:
- Core Hub local API started with `CORE_HUB_AUTO_CREATE_TABLES=1` and SQLite smoke DB.
- `ops-hub/scripts/interhub-gate-probe.py` passed: unauthenticated `/api/v2/hubs` returned `401`, `/api/v2/openapi.json` returned `200`, and required unprefixed OpenAPI paths were present.
- `ops-hub/scripts/ops-hub-bootstrap-api.py` passed: created ops-hub, activated manifest, API consumer, runtime key, 14 widgets, and the initial Gitea readiness interaction event.
## Remaining Compatibility Risk
Local Core Hub compatibility is proven for the ops-hub bootstrap script. Production closure still requires deployment, approved credential custody, and a smoke against the deployed Core Hub endpoint.

View File

@@ -16,15 +16,27 @@ branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def timestamps() -> list[sa.Column]:
return [
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
]
def upgrade() -> None:
op.create_table(
"hubs",
sa.Column("id", sa.String(length=36), primary_key=True),
sa.Column("slug", sa.String(length=120), nullable=False),
sa.Column("name", sa.String(length=240), nullable=False),
sa.Column("domain", sa.String(length=240), nullable=True),
sa.Column("hub_kind", sa.String(length=80), nullable=True),
sa.Column("hub_family", sa.String(length=80), nullable=True),
sa.Column("vsm_function", sa.String(length=80), nullable=True),
sa.Column("vsm_system", sa.String(length=80), nullable=True),
sa.Column("status", sa.String(length=40), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("body_json", sa.Text(), nullable=False),
*timestamps(),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_hubs_slug", "hubs", ["slug"], unique=True)
@@ -32,34 +44,112 @@ def upgrade() -> None:
op.create_table(
"hub_capability_manifests",
sa.Column("id", sa.String(length=36), primary_key=True),
sa.Column("hub_slug", sa.String(length=120), nullable=False),
sa.Column("hub_id", sa.String(length=36), sa.ForeignKey("hubs.id"), nullable=True),
sa.Column("hub_slug", sa.String(length=120), nullable=True),
sa.Column("manifest_version", sa.String(length=40), nullable=False),
sa.Column("status", sa.String(length=40), nullable=False),
sa.Column("body_json", sa.Text(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
*timestamps(),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index(
"ix_hub_capability_manifests_hub_slug",
"hub_capability_manifests",
["hub_slug"],
"ix_hub_capability_manifests_hub_id", "hub_capability_manifests", ["hub_id"]
)
op.create_index(
"ix_hub_capability_manifests_hub_slug", "hub_capability_manifests", ["hub_slug"]
)
op.create_table(
"api_consumers",
sa.Column("id", sa.String(length=36), primary_key=True),
sa.Column("slug", sa.String(length=120), nullable=False),
sa.Column("slug", sa.String(length=120), nullable=True),
sa.Column("name", sa.String(length=240), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("hub_capability_manifest_id", sa.String(length=36), nullable=True),
sa.Column("rate_limit_per_minute", sa.Integer(), nullable=True),
sa.Column("quota_per_day", sa.Integer(), nullable=True),
sa.Column("key_prefix", sa.String(length=32), nullable=True),
sa.Column("key_hash", sa.String(length=255), nullable=True),
sa.Column("status", sa.String(length=40), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("body_json", sa.Text(), nullable=False),
*timestamps(),
)
op.create_index("ix_api_consumers_slug", "api_consumers", ["slug"], unique=True)
op.create_index("ix_api_consumers_name", "api_consumers", ["name"])
op.create_index(
"ix_api_consumers_hub_capability_manifest_id",
"api_consumers",
["hub_capability_manifest_id"],
)
op.create_table(
"api_keys",
sa.Column("id", sa.String(length=36), primary_key=True),
sa.Column(
"api_consumer_id",
sa.String(length=36),
sa.ForeignKey("api_consumers.id"),
nullable=False,
),
sa.Column("key_prefix", sa.String(length=32), nullable=False),
sa.Column("key_hash", sa.String(length=255), nullable=False),
sa.Column("scopes", sa.Text(), nullable=True),
sa.Column("status", sa.String(length=40), nullable=False),
*timestamps(),
)
op.create_index("ix_api_keys_api_consumer_id", "api_keys", ["api_consumer_id"])
op.create_index("ix_api_keys_key_prefix", "api_keys", ["key_prefix"])
op.create_index("ix_api_keys_key_hash", "api_keys", ["key_hash"], unique=True)
op.create_table(
"widgets",
sa.Column("id", sa.String(length=36), primary_key=True),
sa.Column("hub_id", sa.String(length=36), sa.ForeignKey("hubs.id"), nullable=False),
sa.Column("name", sa.String(length=240), nullable=False),
sa.Column("widget_type", sa.String(length=120), nullable=True),
sa.Column("capability_ref", sa.String(length=240), nullable=True),
sa.Column("view_context", sa.String(length=500), nullable=True),
sa.Column("policy_scope", sa.String(length=120), nullable=True),
sa.Column("status", sa.String(length=40), nullable=False),
sa.Column("body_json", sa.Text(), nullable=False),
*timestamps(),
)
op.create_index("ix_widgets_hub_id", "widgets", ["hub_id"])
op.create_index("ix_widgets_widget_type", "widgets", ["widget_type"])
op.create_index("ix_widgets_capability_ref", "widgets", ["capability_ref"])
op.create_table(
"interaction_events",
sa.Column("id", sa.String(length=36), primary_key=True),
sa.Column("widget_id", sa.String(length=36), sa.ForeignKey("widgets.id"), nullable=False),
sa.Column("event_type", sa.String(length=160), nullable=False),
sa.Column("view_context", sa.String(length=500), nullable=True),
sa.Column("metadata_json", sa.Text(), nullable=False),
sa.Column("body_json", sa.Text(), nullable=False),
*timestamps(),
)
op.create_index("ix_interaction_events_widget_id", "interaction_events", ["widget_id"])
op.create_index("ix_interaction_events_event_type", "interaction_events", ["event_type"])
def downgrade() -> None:
op.drop_index("ix_interaction_events_event_type", table_name="interaction_events")
op.drop_index("ix_interaction_events_widget_id", table_name="interaction_events")
op.drop_table("interaction_events")
op.drop_index("ix_widgets_capability_ref", table_name="widgets")
op.drop_index("ix_widgets_widget_type", table_name="widgets")
op.drop_index("ix_widgets_hub_id", table_name="widgets")
op.drop_table("widgets")
op.drop_index("ix_api_keys_key_hash", table_name="api_keys")
op.drop_index("ix_api_keys_key_prefix", table_name="api_keys")
op.drop_index("ix_api_keys_api_consumer_id", table_name="api_keys")
op.drop_table("api_keys")
op.drop_index("ix_api_consumers_hub_capability_manifest_id", table_name="api_consumers")
op.drop_index("ix_api_consumers_name", table_name="api_consumers")
op.drop_index("ix_api_consumers_slug", table_name="api_consumers")
op.drop_table("api_consumers")
op.drop_index("ix_hub_capability_manifests_hub_slug", table_name="hub_capability_manifests")
op.drop_index("ix_hub_capability_manifests_hub_id", table_name="hub_capability_manifests")
op.drop_table("hub_capability_manifests")
op.drop_index("ix_hubs_slug", table_name="hubs")
op.drop_table("hubs")

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"},
)

View File

@@ -1,14 +1,33 @@
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
import core_hub.models # noqa: F401
from core_hub.app import create_app
from core_hub.db import Base
from core_hub.config import get_settings
from core_hub.db import Base, get_engine, get_sessionmaker
@pytest.fixture
def app():
return create_app()
def app(monkeypatch, tmp_path):
monkeypatch.setenv("CORE_HUB_DATABASE_URL", f"sqlite+aiosqlite:///{tmp_path / 'core-hub.db'}")
monkeypatch.setenv("CORE_HUB_API_TOKEN", "operator-token")
monkeypatch.setenv("CORE_HUB_AUTO_CREATE_TABLES", "1")
get_settings.cache_clear()
get_engine.cache_clear()
get_sessionmaker.cache_clear()
try:
yield create_app()
finally:
get_settings.cache_clear()
get_engine.cache_clear()
get_sessionmaker.cache_clear()
@pytest.fixture
def client(app):
with TestClient(app) as test_client:
yield test_client
@pytest.fixture

View File

@@ -1,8 +1,4 @@
from fastapi.testclient import TestClient
PUBLIC_ENDPOINTS = [
"/api/v2/hubs",
"/api/v2/hub-capability-manifests",
CATALOG_ENDPOINTS = [
"/api/v2/widget-types",
"/api/v2/event-types",
"/api/v2/annotation-categories",
@@ -12,6 +8,8 @@ PUBLIC_ENDPOINTS = [
]
PROTECTED_ENDPOINTS = [
"/api/v2/hubs",
"/api/v2/hub-capability-manifests",
"/api/v2/api-consumers",
"/api/v2/widgets",
"/api/v2/interaction-events",
@@ -23,33 +21,127 @@ PROTECTED_ENDPOINTS = [
"/api/v2/hub-registry",
]
OPERATOR_HEADERS = {"Authorization": "Bearer operator-token"}
def test_public_api_v2_endpoints_respond(app):
client = TestClient(app)
for endpoint in PUBLIC_ENDPOINTS:
def test_catalog_endpoints_respond_without_auth(client):
for endpoint in CATALOG_ENDPOINTS:
response = client.get(endpoint)
assert response.status_code == 200, endpoint
def test_hubs_seed_core_hub(app):
response = TestClient(app).get("/api/v2/hubs")
assert response.status_code == 200
assert response.json()[0]["slug"] == "core-hub"
def test_protected_api_v2_endpoints_fail_before_business_logic(app):
client = TestClient(app)
def test_protected_api_v2_endpoints_fail_before_business_logic(client):
for endpoint in PROTECTED_ENDPOINTS:
response = client.get(endpoint)
assert response.status_code == 401, endpoint
assert response.json()["detail"]["code"] == "unauthorized"
def test_openapi_contains_compatibility_routes(app):
payload = TestClient(app).get("/api/v2/openapi.json").json()
def test_openapi_contains_ops_hub_gate_paths(client):
payload = client.get("/api/v2/openapi.json").json()
assert "/api/v2/hubs" in payload["paths"]
assert "/api/v2/widgets" in payload["paths"]
assert "/hubs" in payload["paths"]
assert "/hub-capability-manifests" in payload["paths"]
assert "/api-consumers" in payload["paths"]
assert "/policy-scopes" in payload["paths"]
def test_ops_hub_bootstrap_sequence(client):
hub = client.post(
"/api/v2/hubs",
headers=OPERATOR_HEADERS,
json={
"slug": "ops-hub",
"name": "Ops Hub",
"domain": "ops.coulomb.social",
"hubKind": "domain",
"hubFamily": "vsm",
"vsmFunction": "OPS",
"vsmSystem": "1",
},
)
assert hub.status_code == 201
hub_record = hub.json()
assert hub_record["slug"] == "ops-hub"
listed_hubs = client.get("/api/v2/hubs", headers=OPERATOR_HEADERS).json()
assert listed_hubs["data"][0]["id"] == hub_record["id"]
manifest = client.post(
"/api/v2/hub-capability-manifests",
headers=OPERATOR_HEADERS,
json={
"hubId": hub_record["id"],
"manifestVersion": "1.0",
"declaredWidgetTypes": ["ops-readiness-gate"],
"declaredEventTypes": ["ops-endpoint-verified"],
"declaredAnnotationCategories": ["ops-readiness-blocker"],
"declaredPolicyScopes": ["ops-registry"],
},
)
assert manifest.status_code == 201
manifest_record = manifest.json()
activated = client.post(
f"/api/v2/hub-capability-manifests/{manifest_record['id']}/activate",
headers=OPERATOR_HEADERS,
)
assert activated.status_code == 200
assert activated.json()["status"] == "active"
consumer = client.post(
"/api/v2/api-consumers",
headers=OPERATOR_HEADERS,
json={
"name": "ops-hub",
"description": "API consumer for the VSM Operations hub",
"hubCapabilityManifestId": manifest_record["id"],
"rateLimitPerMinute": 120,
"quotaPerDay": 50000,
},
)
assert consumer.status_code == 201
consumer_record = consumer.json()
key_response = client.post(
f"/api/v2/api-consumers/{consumer_record['id']}/api-keys",
headers=OPERATOR_HEADERS,
json={"scopes": "framework:read hub:ops-hub:read hub:ops-hub:write"},
)
assert key_response.status_code == 201
runtime_key = key_response.json()["fullKey"]
runtime_headers = {"Authorization": f"Bearer {runtime_key}"}
widget = client.post(
"/api/v2/widgets",
headers=runtime_headers,
json={
"hubId": hub_record["id"],
"status": "active",
"name": "Gitea Registry Readiness",
"widgetType": "ops-readiness-gate",
"capabilityRef": "ops:readiness:gitea-registry",
"viewContext": "ops-hub/readiness/gitea-registry",
"policyScope": "ops-registry",
},
)
assert widget.status_code == 201
widget_record = widget.json()
event = client.post(
"/api/v2/interaction-events",
headers=runtime_headers,
json={
"widgetId": widget_record["id"],
"eventType": "ops-endpoint-verified",
"viewContext": "railiance-apps/workplans/RAIL-AP-WP-0001",
"metadata": {"expectedStatus": 401},
},
)
assert event.status_code == 201
assert event.json()["eventType"] == "ops-endpoint-verified"
registry = client.get("/api/v2/hub-registry", headers=runtime_headers)
assert registry.status_code == 200
assert registry.json()["data"]["hubs"][0]["slug"] == "ops-hub"

View File

@@ -3,9 +3,18 @@ from sqlalchemy import inspect
import core_hub.models # noqa: F401
from core_hub.db import Base
FOUNDATION_TABLES = {
"hubs",
"hub_capability_manifests",
"api_consumers",
"api_keys",
"widgets",
"interaction_events",
}
def test_metadata_contains_foundation_tables():
assert {"hubs", "hub_capability_manifests", "api_consumers"}.issubset(Base.metadata.tables)
assert FOUNDATION_TABLES.issubset(Base.metadata.tables)
async def test_sqlite_fixture_creates_foundation_tables(sqlite_engine):
@@ -14,4 +23,4 @@ async def test_sqlite_fixture_creates_foundation_tables(sqlite_engine):
lambda sync_conn: inspect(sync_conn).get_table_names()
)
assert {"hubs", "hub_capability_manifests", "api_consumers"}.issubset(table_names)
assert FOUNDATION_TABLES.issubset(table_names)

View File

@@ -1,16 +1,13 @@
from fastapi.testclient import TestClient
def test_healthz(app):
response = TestClient(app).get("/healthz")
def test_healthz(client):
response = client.get("/healthz")
assert response.status_code == 200
assert response.json()["status"] == "ok"
assert response.json()["service"] == "core-hub"
def test_readyz(app):
response = TestClient(app).get("/readyz")
def test_readyz(client):
response = client.get("/readyz")
assert response.status_code == 200
assert response.json()["checks"]["database_url"] == "configured"

View File

@@ -20,34 +20,34 @@ Preserve the Inter-Hub `/api/v2` behavior required by current consumers while Co
```task
id: CORE-WP-0004-T01
status: progress
status: done
priority: high
state_hub_task_id: "4e24ab14-d5cf-4466-8287-21eaf075cb44"
```
2026-06-27: Implemented seed-backed public compatibility endpoints for hubs, manifests, catalogs, OpenAPI JSON/YAML, and docs redirect. Tests prove route presence and response status. This remains `progress` until legacy Inter-Hub fixture comparison is captured.
Result 2026-06-27: Aligned catalog/docs/OpenAPI behavior with the active ops-hub gate. Static catalogs and docs are public; hub registry/bootstrap data is protected. `/api/v2/openapi.json` now includes the unprefixed path aliases expected by ops-hub gate checks. Local gate probe passed against Core Hub.
## Protected Bootstrap Compatibility
```task
id: CORE-WP-0004-T02
status: progress
status: done
priority: high
state_hub_task_id: "87073ee7-0213-4eed-881a-b18ab764ae65"
```
2026-06-27: Implemented protected placeholder routes for API consumers, widgets, interaction events, annotations, requirement candidates, decisions, deployments, outcome signals, hub registry, and token route. Tests prove unauthenticated requests fail before business logic. This remains `progress` until authenticated create/read behavior is backed by persistence and fixtures.
Result 2026-06-27: Replaced placeholders with persistence-backed protected routes for hubs, manifests, API consumers, API keys, widgets, interaction events, and hub registry. Runtime API keys are generated display-once and stored hashed. The real `ops-hub/scripts/ops-hub-bootstrap-api.py` passed against local Core Hub, creating ops-hub, an active manifest, API consumer, runtime key, 14 widgets, and one readiness event.
## ops-hub Smoke
```task
id: CORE-WP-0004-T03
status: wait
status: progress
priority: high
state_hub_task_id: "2d18f575-135b-4ba8-9537-6920d0c61e1b"
```
Waiting on a deployed Core Hub runtime plus approved credential routing. No secrets in logs, workplans, or chat.
2026-06-27: Local smoke passed with a throwaway operator token and SQLite smoke DB. Remaining closure requires deployed Core Hub runtime plus approved credential routing. No secrets in logs, workplans, or chat.
## activity-core Smoke