diff --git a/contracts/openapi/core-hub.openapi.json b/contracts/openapi/core-hub.openapi.json
index 6b77bbd..0a20ed6 100644
--- a/contracts/openapi/core-hub.openapi.json
+++ b/contracts/openapi/core-hub.openapi.json
@@ -1656,6 +1656,55 @@
]
}
},
+ "/console": {
+ "get": {
+ "operationId": "operator_console_console_get",
+ "parameters": [
+ {
+ "in": "header",
+ "name": "authorization",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Authorization"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "text/html": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ },
+ "description": "Successful Response"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ },
+ "description": "Validation Error"
+ }
+ },
+ "summary": "Operator Console",
+ "tags": [
+ "operator-console"
+ ]
+ }
+ },
"/healthz": {
"get": {
"operationId": "healthz_healthz_get",
diff --git a/docs/specs/ui-operator-console.md b/docs/specs/ui-operator-console.md
index d97ae56..1d00ace 100644
--- a/docs/specs/ui-operator-console.md
+++ b/docs/specs/ui-operator-console.md
@@ -76,6 +76,19 @@ The first useful prototype should implement read-heavy surfaces before mutation-
Use whynot-design quiet, document-like operational language: mostly neutral surfaces, strong typography hierarchy through labels and tables, no marketing hero, no decorative imagery, no gradients, no animation beyond basic hover/focus feedback. The console should feel like a control room notebook rather than a product landing page.
+
+## First Prototype
+
+The first prototype is a protected server-rendered route at `/console`. It uses the existing FastAPI runtime, inline whynot-aligned tokens/classes, and the current SQLAlchemy read model to show:
+
+- readiness gates;
+- hub registry rows;
+- migration run ledger rows;
+- non-secret API consumer metadata;
+- recent interaction events.
+
+The route requires the same bearer-token authentication as protected `/api/v2` resources. It intentionally renders only API key prefixes and non-secret lifecycle metadata.
+
## Open Implementation Choice
The preferred first implementation is a thin FastAPI-served HTML/HTMX or static custom-element shell that consumes `/api/v2` and later Core Hub-native read endpoints. A heavier SPA should wait until the API and workflows prove the extra runtime is useful.
diff --git a/src/core_hub/app.py b/src/core_hub/app.py
index 0077acf..934cdee 100644
--- a/src/core_hub/app.py
+++ b/src/core_hub/app.py
@@ -7,6 +7,7 @@ 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.console import router as console_router
from core_hub.db import Base, get_engine
from core_hub.schemas import HealthResponse, ReadinessResponse
@@ -45,6 +46,7 @@ def create_app() -> FastAPI:
return ReadinessResponse(service="core-hub", status=status, checks=checks)
app.include_router(api_v2_router)
+ app.include_router(console_router)
return app
diff --git a/src/core_hub/console.py b/src/core_hub/console.py
new file mode 100644
index 0000000..c6c04db
--- /dev/null
+++ b/src/core_hub/console.py
@@ -0,0 +1,290 @@
+# ruff: noqa: E501
+from html import escape
+from typing import Annotated, Any
+
+from fastapi import APIRouter, Depends
+from fastapi.responses import HTMLResponse
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from core_hub.db import session_scope
+from core_hub.models import (
+ ApiConsumer,
+ Hub,
+ HubCapabilityManifest,
+ InteractionEvent,
+ MigrationRun,
+ Widget,
+)
+from core_hub.security import require_api_token
+
+router = APIRouter(tags=["operator-console"])
+TokenDependency = Annotated[str, Depends(require_api_token)]
+SessionDependency = Annotated[AsyncSession, Depends(session_scope)]
+
+
+CONSOLE_CSS = """
+:root {
+ --ink: #0a0a0a;
+ --ink-2: #1f1f1f;
+ --ink-3: #5c5c5c;
+ --ink-4: #8a8a8a;
+ --ink-5: #b5b5b3;
+ --line: #e5e5e2;
+ --line-strong: #c9c9c5;
+ --line-soft: #f0f0ec;
+ --paper: #ffffff;
+ --paper-2: #fafaf7;
+ --paper-3: #f4f4ef;
+ --hi: #ffe14a;
+ --ff-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ --ff-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
+ --sp-2: 8px;
+ --sp-3: 12px;
+ --sp-4: 16px;
+ --sp-5: 24px;
+ --sp-6: 32px;
+ --r-2: 4px;
+}
+* { box-sizing: border-box; }
+html { font-family: var(--ff-sans); color: var(--ink); background: var(--paper); }
+body { margin: 0; }
+a { color: inherit; text-decoration: underline; text-decoration-color: var(--line-strong); text-underline-offset: 3px; }
+code { font-family: var(--ff-mono); font-size: 0.92em; background: var(--paper-3); padding: 1px 5px; border-radius: 2px; }
+.wn-topnav { height: 56px; border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; padding: 0 var(--sp-5); background: var(--paper); position: sticky; top: 0; z-index: 2; }
+.wn-topnav__brand { display: flex; align-items: baseline; gap: var(--sp-2); font-weight: 600; }
+.wn-topnav__brand-slug { font: 500 12px/1 var(--ff-mono); color: var(--ink-3); letter-spacing: 0.04em; }
+.wn-topnav__right { font: 500 11px/1 var(--ff-mono); color: var(--ink-3); text-transform: uppercase; letter-spacing: 0.08em; }
+.console-shell { min-height: calc(100vh - 56px); display: grid; grid-template-columns: 248px minmax(0, 1fr); }
+.wn-sidebar { border-right: 1px solid var(--line); background: var(--paper-2); padding: var(--sp-5); position: sticky; top: 56px; height: calc(100vh - 56px); }
+.wn-sidebar__group { display: grid; gap: var(--sp-2); margin-bottom: var(--sp-5); }
+.wn-sidebar__group-label, .wn-eyebrow { font: 500 11px/1.2 var(--ff-mono); letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-3); }
+.wn-sidebar__item { display: flex; justify-content: space-between; gap: var(--sp-3); padding: 8px 0; font: 500 13px/1.35 var(--ff-sans); text-decoration: none; border-bottom: 1px solid transparent; }
+.wn-sidebar__item:hover { border-bottom-color: var(--line-strong); }
+.wn-sidebar__count { font: 500 11px/1 var(--ff-mono); color: var(--ink-4); }
+.console-main { padding: var(--sp-6); min-width: 0; }
+.wn-page-header { display: grid; gap: var(--sp-3); margin-bottom: var(--sp-6); }
+.wn-page-header__row { display: flex; justify-content: space-between; gap: var(--sp-5); align-items: end; }
+.wn-page-header__title { font: 600 36px/1.05 var(--ff-sans); margin: 0; letter-spacing: 0; }
+.wn-page-header__lede { margin: 0; max-width: 760px; color: var(--ink-3); line-height: 1.55; }
+.wn-btn { display: inline-flex; align-items: center; border: 1px solid var(--ink); border-radius: var(--r-2); padding: 9px 14px; font: 500 13px/1.2 var(--ff-sans); text-decoration: none; background: var(--ink); color: var(--paper); }
+.kpi-grid { display: grid; grid-template-columns: repeat(6, minmax(120px, 1fr)); gap: var(--sp-3); margin-bottom: var(--sp-6); }
+.wn-card { background: var(--paper); border: 1px solid var(--line); border-radius: var(--r-2); padding: var(--sp-4); }
+.stat-card { min-height: 96px; display: grid; align-content: space-between; gap: var(--sp-3); }
+.stat-card strong { font: 600 30px/1 var(--ff-sans); }
+.stat-card span { font: 500 11px/1.2 var(--ff-mono); letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-3); }
+.console-section { margin-bottom: var(--sp-6); }
+.section-head { display: flex; justify-content: space-between; gap: var(--sp-5); align-items: baseline; margin-bottom: var(--sp-3); }
+.section-head h2 { font: 600 20px/1.2 var(--ff-sans); margin: 0; letter-spacing: 0; }
+.section-head p { margin: 0; color: var(--ink-3); font-size: 13px; }
+.table-wrap { border: 1px solid var(--line); border-radius: var(--r-2); overflow-x: auto; background: var(--paper); }
+table { width: 100%; border-collapse: collapse; font-size: 13px; min-width: 680px; }
+th, td { text-align: left; padding: 11px 14px; border-bottom: 1px solid var(--line); vertical-align: top; }
+th { font: 500 11px/1.2 var(--ff-mono); letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-3); background: var(--paper-2); }
+tr:last-child td { border-bottom: 0; }
+.wn-tag { display: inline-block; border: 1px solid var(--line); border-radius: 999px; padding: 5px 9px; font: 500 10px/1 var(--ff-mono); letter-spacing: 0.08em; text-transform: uppercase; white-space: nowrap; color: var(--ink-3); }
+.wn-tag--active, .wn-tag--done, .wn-tag--imported { background: var(--ink); border-color: var(--ink); color: var(--paper); }
+.wn-tag--wait, .wn-tag--blocked, .wn-tag--degraded { background: var(--paper-3); color: var(--ink-2); }
+.wn-tag--warn, .wn-tag--draft, .wn-tag--dry-run { background: var(--hi); border-color: transparent; color: #1a1500; }
+.wn-empty-state { border: 1px dashed var(--line-strong); border-radius: var(--r-2); padding: var(--sp-5); color: var(--ink-3); font-size: 14px; }
+.gate-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--sp-3); }
+.gate { border: 1px solid var(--line); border-radius: var(--r-2); padding: var(--sp-4); display: grid; gap: var(--sp-2); }
+.gate b { font-size: 14px; }
+.gate span { color: var(--ink-3); font-size: 13px; line-height: 1.45; }
+@media (max-width: 980px) {
+ .console-shell { grid-template-columns: 1fr; }
+ .wn-sidebar { position: static; height: auto; border-right: 0; border-bottom: 1px solid var(--line); }
+ .kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .gate-list { grid-template-columns: 1fr; }
+}
+@media (max-width: 640px) {
+ .wn-topnav { padding: 0 var(--sp-4); }
+ .console-main { padding: var(--sp-4); }
+ .wn-page-header__row { display: grid; }
+ .wn-page-header__title { font-size: 28px; }
+ .kpi-grid { grid-template-columns: 1fr; }
+}
+"""
+
+
+def text(value: Any) -> str:
+ if value is None or value == "":
+ return "-"
+ return escape(str(value))
+
+
+def short_id(value: str | None) -> str:
+ if not value:
+ return "-"
+ return escape(value[:8])
+
+
+def tag(value: str | None) -> str:
+ status = str(value or "unknown")
+ css_status = escape(status.lower().replace("_", "-").replace(" ", "-"))
+ return f'{escape(status)}'
+
+
+def table_or_empty(headers: list[str], rows: list[list[str]], empty: str) -> str:
+ if not rows:
+ return f'
{escape(empty)}
'
+ head = "".join(f"{escape(header)} | " for header in headers)
+ body = "".join("" + "".join(f"| {cell} | " for cell in row) + "
" for row in rows)
+ return f''
+
+
+async def fetch_rows(
+ session: AsyncSession, model: Any, order_by: Any | None = None, limit: int = 12
+) -> list[Any]:
+ statement = select(model)
+ if order_by is not None:
+ statement = statement.order_by(order_by)
+ statement = statement.limit(limit)
+ result = await session.execute(statement)
+ return list(result.scalars())
+
+
+@router.get("/console", response_class=HTMLResponse)
+async def operator_console(_: TokenDependency, session: SessionDependency) -> HTMLResponse:
+ hubs = await fetch_rows(session, Hub, Hub.slug)
+ manifests = await fetch_rows(
+ session, HubCapabilityManifest, HubCapabilityManifest.created_at.desc()
+ )
+ widgets = await fetch_rows(session, Widget, Widget.name)
+ events = await fetch_rows(
+ session, InteractionEvent, InteractionEvent.created_at.desc(), limit=8
+ )
+ consumers = await fetch_rows(session, ApiConsumer, ApiConsumer.name)
+ migrations = await fetch_rows(session, MigrationRun, MigrationRun.created_at.desc(), limit=8)
+
+ active_manifests = sum(1 for item in manifests if item.status == "active")
+ stats = [
+ ("Hubs", len(hubs)),
+ ("Active manifests", active_manifests),
+ ("Widgets", len(widgets)),
+ ("Events", len(events)),
+ ("Consumers", len(consumers)),
+ ("Migration runs", len(migrations)),
+ ]
+
+ registry_rows = [
+ [
+ f"{text(hub.slug)}",
+ text(hub.name),
+ text(hub.domain),
+ text(hub.hub_kind),
+ tag(hub.status),
+ ]
+ for hub in hubs
+ ]
+ migration_rows = [
+ [
+ f"{short_id(run.id)}",
+ text(run.source),
+ text(run.schema_version),
+ f"{short_id(run.bundle_sha256)}",
+ tag("dry-run" if run.dry_run else run.status),
+ ]
+ for run in migrations
+ ]
+ consumer_rows = [
+ [
+ f"{text(consumer.slug)}",
+ text(consumer.name),
+ text(consumer.key_prefix),
+ text(consumer.rate_limit_per_minute),
+ tag(consumer.status),
+ ]
+ for consumer in consumers
+ ]
+ event_rows = [
+ [
+ f"{short_id(event.id)}",
+ text(event.event_type),
+ f"{short_id(event.widget_id)}",
+ text(event.view_context),
+ ]
+ for event in events
+ ]
+
+ stat_cards = "".join(
+ f'{escape(label)}{value}'
+ for label, value in stats
+ )
+
+ html = f"""
+
+
+
+
+ Core Hub operator console
+
+
+
+
+ Core Hub/ operator
+ protected console
+
+
+
+
+
+
+
+
+
+ Readiness gates
Current blocker map
+
+
{tag("blocked")} ops-hub smokeLocal smoke passed. Closure waits on deployed runtime and approved credential routing.
+
{tag("blocked")} staging importImporter is ready. Closure waits on approved Inter-Hub export bundle and staging DB.
+
{tag("blocked")} activity-core smokeWaits on deployed Core Hub runtime or explicit transition fallback decision.
+
{tag("active")} console prototypeProtected read-only console surface is active.
+
+
+
+
+ Hub registry
Hubs visible to the protected runtime
+ {table_or_empty(["Slug", "Name", "Domain", "Kind", "Status"], registry_rows, "No hubs registered yet.")}
+
+
+
+ Migration runs
Import evidence and bundle identity
+ {table_or_empty(["Run", "Source", "Schema", "Bundle", "Status"], migration_rows, "No migration runs recorded yet.")}
+
+
+
+ Access
Non-secret API consumer metadata
+ {table_or_empty(["Slug", "Name", "Key prefix", "Rate/min", "Status"], consumer_rows, "No API consumers registered yet.")}
+
+
+
+ Recent events
Latest interaction evidence
+ {table_or_empty(["Event", "Type", "Widget", "Context"], event_rows, "No interaction events recorded yet.")}
+
+
+
+
+"""
+ return HTMLResponse(html)
diff --git a/tests/test_console.py b/tests/test_console.py
new file mode 100644
index 0000000..c061de1
--- /dev/null
+++ b/tests/test_console.py
@@ -0,0 +1,86 @@
+OPERATOR_HEADERS = {"Authorization": "Bearer operator-token"}
+
+
+def seed_console_data(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",
+ },
+ ).json()
+ manifest = client.post(
+ "/api/v2/hub-capability-manifests",
+ headers=OPERATOR_HEADERS,
+ json={"hubId": hub["id"], "manifestVersion": "1.0"},
+ ).json()
+ client.post(
+ f"/api/v2/hub-capability-manifests/{manifest['id']}/activate",
+ headers=OPERATOR_HEADERS,
+ )
+ consumer = client.post(
+ "/api/v2/api-consumers",
+ headers=OPERATOR_HEADERS,
+ json={
+ "name": "ops-hub",
+ "description": "API consumer for the VSM Operations hub",
+ "hubCapabilityManifestId": manifest["id"],
+ "rateLimitPerMinute": 120,
+ },
+ ).json()
+ key_payload = client.post(
+ f"/api/v2/api-consumers/{consumer['id']}/api-keys",
+ headers=OPERATOR_HEADERS,
+ json={"scopes": "framework:read hub:ops-hub:write"},
+ ).json()
+ runtime_headers = {"Authorization": f"Bearer {key_payload['fullKey']}"}
+ widget = client.post(
+ "/api/v2/widgets",
+ headers=runtime_headers,
+ json={
+ "hubId": hub["id"],
+ "name": "Readiness",
+ "widgetType": "ops-readiness-gate",
+ "capabilityRef": "ops:readiness",
+ "viewContext": "ops-hub/readiness",
+ "policyScope": "ops-registry",
+ },
+ ).json()
+ client.post(
+ "/api/v2/interaction-events",
+ headers=runtime_headers,
+ json={
+ "widgetId": widget["id"],
+ "eventType": "ops-endpoint-verified",
+ "metadata": {"expectedStatus": 401},
+ },
+ )
+ return key_payload
+
+
+def test_console_requires_operator_auth(client):
+ response = client.get("/console")
+
+ assert response.status_code == 401
+
+
+def test_console_renders_operational_state_without_full_key(client):
+ key_payload = seed_console_data(client)
+
+ response = client.get("/console", headers=OPERATOR_HEADERS)
+
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("text/html")
+ body = response.text
+ assert "Operator console" in body
+ assert "ops-hub" in body
+ assert "ops-endpoint-verified" in body
+ assert key_payload["apiKey"]["keyPrefix"] in body
+ assert key_payload["fullKey"] not in body
+ assert "fullKey" not in body
diff --git a/workplans/CORE-WP-0006-operator-console-and-whynot-ui.md b/workplans/CORE-WP-0006-operator-console-and-whynot-ui.md
index 26e9cdd..f03cde7 100644
--- a/workplans/CORE-WP-0006-operator-console-and-whynot-ui.md
+++ b/workplans/CORE-WP-0006-operator-console-and-whynot-ui.md
@@ -42,12 +42,12 @@ Result 2026-06-27: Added `docs/specs/whynot-ui-adapter.md`, mapping whynot-desig
```task
id: CORE-WP-0006-T03
-status: wait
+status: done
priority: medium
state_hub_task_id: "6da92878-f0a4-4ff0-8b46-4629c646a7d5"
```
-Build first operator console prototype after the FastAPI foundation exposes stable read endpoints.
+Result 2026-06-27: Added protected `/console` prototype in FastAPI with whynot-aligned tokens/classes, readiness gates, hub registry, migration runs, non-secret API consumer metadata, recent events, and tests covering auth plus full-key non-disclosure.
## Add Visual Checks
@@ -58,4 +58,4 @@ priority: low
state_hub_task_id: "12628328-6699-4abf-aa95-c0a07542f3e9"
```
-Add Playwright visual checks once real UI surfaces exist.
+Add Playwright visual checks for `/console` across desktop and mobile, including non-overlap checks and protected-state screenshot handling.