generated from coulomb/repo-seed
Implement ops-hub bootstrap compatibility
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -174,3 +174,5 @@ cython_debug/
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
# Local smoke/runtime state
|
||||
.local/
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"data": [],
|
||||
"count": 0
|
||||
}
|
||||
4
contracts/fixtures/api-v2/protected/hubs-empty.json
Normal file
4
contracts/fixtures/api-v2/protected/hubs-empty.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"data": [],
|
||||
"count": 0
|
||||
}
|
||||
@@ -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"}
|
||||
}
|
||||
@@ -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"}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
13
src/core_hub/crypto.py
Normal 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)
|
||||
@@ -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())
|
||||
|
||||
@@ -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"},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user