Files
inter-hub/workplans/IHUB-WP-0010-ihf-phase9-external-api.md
tegwick 26708ba799
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
chore: close IHUB-WP-0010 status
2026-06-07 17:42:26 +02:00

772 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
id: IHUB-WP-0010
type: workplan
title: "IHF Phase 9 — External API Surface and Consumer SDKs"
domain: inter_hub
repo: inter-hub
status: done
owner: custodian
topic_slug: inter_hub
created: "2026-04-01"
updated: "2026-06-07"
completed: "2026-06-07"
state_hub_sync: done
state_hub_workstream_id: "c6c6e87f-e145-4bc4-9881-61f92b14d4de"
---
# IHF Phase 9 — External API Surface and Consumer SDKs
## Goal
Make the IHF consumable by systems outside the reference IHP implementation.
Phase 8 established federated governance within a single deployment. Phase 9
exposes that governance state as a stable, versioned, authenticated REST API
and ships consumer SDKs that make integration a day's work rather than a
project.
## Background
Phases 18 and IHUB-WP-0009 (GAAF Compliance Foundation) are complete. All
Phase 9 entry gates are satisfied:
- Four type registries seeded and validated in controllers ✓
- `HubCapabilityManifest` table and activation workflow operational ✓
- `/contracts/` directory with Core and Functional contract artifacts ✓
- `ARCHITECTURE-LAYERS.md` scorecard at ≥3.3 ✓ (actual: 3.34)
- Architectural fitness functions in CI ✓
- `docs/domain-hub-extension-guide.md` published ✓
The type registries established in WP-0009 are the critical dependency: the
OpenAPI 3.1 spec must enumerate `widget_type`, `event_type`, and `category`
as finite enum arrays derived from those registries — not unconstrained
strings. Building the API without this would produce an incorrect contract.
Reference: `specs/InteractionHubFrameworkSpecification_v0.2.md` §Phase 9.
## GAAF Architectural Constraints
All new code in this workplan must comply with:
1. **No bare TEXT type discriminators**`api_consumers.consumer_type` (if
added) must reference a registry or carry a CHECK constraint.
2. **ApiConsumer must declare capability scope** — if a consumer is a domain
hub, its `hub_capability_manifest_id` FK must be set. Non-hub consumers
leave it NULL; that is not an exception.
3. **Append-only invariant is permanent** — no migration in this workplan may
add UPDATE or DELETE capability to `interaction_events` or `outcome_signals`.
4. **Core tables are frozen**`widgets`, `interaction_events`, `annotations`,
`hubs`, and Phase 14 dependents must not gain columns without a
corresponding `/contracts/core/` update.
## Data Artifacts Introduced
`ApiConsumer`, `ApiKey`, `WebhookSubscription`, `WebhookDelivery`
Schema additions:
- `api_consumers` table with `hub_capability_manifest_id` FK (nullable)
- `api_keys` table linked to `api_consumers`
- `webhook_subscriptions` table
- `webhook_deliveries` table
- `api_request_log` table (for usage dashboard and rate limiting)
## Close-out Correction - 2026-06-07
State Hub showed IHUB-WP-0010 as active even though all eleven task rows were
already `done`. The workplan frontmatter and final exit checklist were corrected
to reflect the completed Phase 9 state.
---
## Tasks
### T01 — Schema: ApiConsumer, ApiKey, WebhookSubscription, WebhookDelivery
```task
id: IHUB-WP-0010-T01
status: done
priority: high
state_hub_task_id: "4db51957-5aaf-4834-88f8-14c972b32136"
```
Add all new tables required by Phase 9. This is the only task that touches
`Application/Schema.sql` and migrations.
1. Schema additions in `Application/Schema.sql`:
```sql
-- api_consumers: external systems that authenticate against /api/v2/
-- hub_capability_manifest_id: set when the consumer is a domain hub;
-- NULL for third-party tools that authenticate without a manifest.
CREATE TABLE api_consumers (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
description TEXT,
hub_capability_manifest_id UUID REFERENCES hub_capability_manifests(id),
rate_limit_per_minute INTEGER NOT NULL DEFAULT 60,
quota_per_day INTEGER NOT NULL DEFAULT 10000,
quota_resets_at TIMESTAMP WITH TIME ZONE NOT NULL
DEFAULT (date_trunc('day', NOW() AT TIME ZONE 'UTC') + interval '1 day'),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
);
-- api_keys: bearer tokens for consumer authentication
CREATE TABLE api_keys (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
api_consumer_id UUID NOT NULL REFERENCES api_consumers(id) ON DELETE CASCADE,
key_prefix TEXT NOT NULL, -- first 8 chars of the key, for display
key_hash TEXT NOT NULL, -- bcrypt/sha256 hash; never store plain
scopes TEXT NOT NULL DEFAULT '', -- space-separated OAuth-style scopes
expires_at TIMESTAMP WITH TIME ZONE,
revoked_at TIMESTAMP WITH TIME ZONE,
last_used_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
);
CREATE UNIQUE INDEX api_keys_prefix_idx ON api_keys (key_prefix);
CREATE INDEX api_keys_consumer_idx ON api_keys (api_consumer_id);
-- webhook_subscriptions: consumer event subscriptions
CREATE TABLE webhook_subscriptions (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
api_consumer_id UUID NOT NULL REFERENCES api_consumers(id) ON DELETE CASCADE,
event_type TEXT NOT NULL
REFERENCES event_type_registry(name),
target_url TEXT NOT NULL,
secret TEXT NOT NULL, -- HMAC signing secret; never expose in responses
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
);
CREATE INDEX webhook_subs_consumer_idx ON webhook_subscriptions (api_consumer_id);
CREATE INDEX webhook_subs_event_type_idx ON webhook_subscriptions (event_type);
-- webhook_deliveries: delivery attempt log (append-only)
CREATE TABLE webhook_deliveries (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
webhook_subscription_id UUID NOT NULL
REFERENCES webhook_subscriptions(id),
payload JSONB NOT NULL,
attempted_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
status TEXT NOT NULL CHECK (status IN ('pending','delivered','failed')),
response_code INTEGER,
latency_ms INTEGER,
error_message TEXT
);
-- Append-only trigger for webhook_deliveries
CREATE OR REPLACE FUNCTION webhook_deliveries_no_update()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION 'webhook_deliveries is append-only';
END; $$;
CREATE TRIGGER webhook_deliveries_no_update
BEFORE UPDATE ON webhook_deliveries
FOR EACH ROW EXECUTE FUNCTION webhook_deliveries_no_update();
-- api_request_log: usage tracking for dashboard and rate limiting
CREATE TABLE api_request_log (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
api_consumer_id UUID REFERENCES api_consumers(id),
endpoint TEXT NOT NULL,
method TEXT NOT NULL,
status_code INTEGER NOT NULL,
latency_ms INTEGER,
requested_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
);
CREATE INDEX api_request_log_consumer_idx
ON api_request_log (api_consumer_id, requested_at DESC);
```
2. Write migration: `Application/Migration/1743000001-api-phase9-schema.sql`
containing the DDL above. Run via `migrate`.
3. Register new types in `Web/Types.hs`:
- `data ApiConsumer`, `data ApiKey`, `data WebhookSubscription`,
`data WebhookDelivery`, `data ApiRequestLog`
- Include `HasField` instances following IHP conventions.
4. Add routes in `Web/Routes.hs` for:
- `ApiConsumersController` (admin CRUD)
- `ApiKeysController` (admin CRUD)
- `WebhookSubscriptionsController` (admin CRUD)
- `Api.V2.*` controllers (added in T02/T03)
**Exit criteria:** `migrate` runs cleanly; Types.hs compiles; no existing
tests broken.
---
### T02 — REST read API `/api/v2/` for all core IHF artifact types
```task
id: IHUB-WP-0010-T02
status: done
priority: high
state_hub_task_id: "5a4fc7d1-f930-40f9-bbcf-323aaa717191"
```
Expose all core IHF artifact types as read-only JSON endpoints under
`/api/v2/`. All endpoints require a valid `ApiKey` Bearer token.
1. Create `Web/Controller/Api/V2/` directory with a shared auth helper:
```haskell
-- Web/Controller/Api/V2/Auth.hs
module Web.Controller.Api.V2.Auth where
import IHP.Prelude
import Web.Types
import qualified Crypto.Hash.SHA256 as SHA256
import qualified Data.ByteString.Base16 as Base16
-- | Extract and validate Bearer token from Authorization header.
-- Returns the ApiConsumer on success or halts with 401.
requireApiConsumer :: (?context :: ControllerContext) => IO ApiConsumer
```
2. Implement read controllers for each artifact type. Pattern:
```haskell
-- Web/Controller/Api/V2/WidgetsController.hs
instance Controller Api.V2.WidgetsController where
action Api.V2.IndexWidgetsAction = do
consumer <- requireApiConsumer
widgets <- query @Widget
|> orderByDesc #createdAt
|> paginate
|> fetch
renderJson (map widgetToJson widgets)
action Api.V2.ShowWidgetAction { widgetId } = do
consumer <- requireApiConsumer
widget <- fetch widgetId
renderJson (widgetToJson widget)
```
3. Implement controllers for:
- `Api.V2.WidgetsController` (index, show)
- `Api.V2.InteractionEventsController` (index, show; filter by widget_id, hub_id, event_type)
- `Api.V2.AnnotationsController` (index, show; filter by widget_id, category)
- `Api.V2.RequirementCandidatesController` (index, show)
- `Api.V2.DecisionRecordsController` (index, show)
- `Api.V2.DeploymentRecordsController` (index, show)
- `Api.V2.OutcomeSignalsController` (index, show)
4. JSON serialisation: each `*ToJson` function must produce camelCase field
names. Type discriminator fields (`widget_type`, `event_type`, `category`)
emit the registry name string as-is.
5. Pagination: `?page=N&per_page=M` query params; default 50 per page; max 200.
Response envelope: `{ "data": [...], "meta": { "page": N, "per_page": M, "total": T } }`.
6. Authentication failure: 401 JSON `{ "error": "Unauthorized", "code": "invalid_api_key" }`.
**Exit criteria:** All seven read endpoints return 200 JSON with a valid Bearer
token; return 401 without one; pagination meta renders in all responses.
---
### T03 — REST write API: submit interaction events and annotations
```task
id: IHUB-WP-0010-T03
status: done
priority: high
state_hub_task_id: "ade7f62b-b27d-4edf-b91c-b785057d269d"
```
Allow external consumers to submit interaction events and annotations via
`POST /api/v2/interaction-events` and `POST /api/v2/annotations`.
1. Extend `Api.V2.InteractionEventsController` with `CreateInteractionEventAction`:
```
POST /api/v2/interaction-events
Content-Type: application/json
Authorization: Bearer <token>
{
"widgetId": "<uuid>",
"eventType": "clicked",
"viewContext": "dashboard",
"metadata": {}
}
```
2. Validation pipeline for `event_type`:
- Check `event_type` exists in `event_type_registry`. If not → HTTP 422:
```json
{
"error": "Unregistered event type",
"code": "unregistered_event_type",
"value": "<submitted_value>",
"registry": "/api/v2/event-types"
}
```
- If the consumer has a `hub_capability_manifest_id`, additionally check
the type is in `declared_event_types` on the active manifest. If not →
HTTP 422 with `"code": "event_type_not_in_manifest"`.
- Framework-level types (owned by no hub) are always allowed.
3. Extend `Api.V2.AnnotationsController` with `CreateAnnotationAction`:
```
POST /api/v2/annotations
Body: { "widgetId": "<uuid>", "category": "ux-friction", "body": "..." }
```
Validate `category` against `annotation_category_registry` with the same
422 pattern.
4. On success, return HTTP 201 with the created record JSON.
5. Add `GET /api/v2/event-types` and `GET /api/v2/widget-types` and
`GET /api/v2/annotation-categories` — read the respective registry tables
and return the list of registered names with description and owner_hub_id.
These endpoints are unauthenticated (public enumeration).
**Exit criteria:** Valid POST creates a record and returns 201; unregistered
`event_type` returns 422 with registry URL in response; unauthenticated POST
returns 401; registry listing endpoints return 200 without auth.
---
### T04 — OpenAPI 3.1 spec generation with type registry enumerations
```task
id: IHUB-WP-0010-T04
status: done
priority: high
state_hub_task_id: "f5ba93af-6772-4b87-b1b5-0c79b46486c8"
```
Generate a valid OpenAPI 3.1 JSON document at `GET /api/v2/openapi.json` that
accurately describes all Phase 9 endpoints. The critical constraint: type
discriminator fields must carry `enum` arrays, not unconstrained `string`.
1. Create `Web/Controller/Api/V2/OpenApiController.hs`. This controller builds
and renders the OpenAPI document in-process (no code-generation tool needed
for v1; a Haskell value → JSON approach is fine).
2. The spec structure:
```
openapi: "3.1.0"
info:
title: Interaction Hub Framework API
version: "2.0"
description: |
IHF external API. See /contracts/functional/interaction-reporting-v1.md
for the human-readable contract companion.
servers:
- url: /api/v2
paths:
/widgets: ...
/interaction-events: ...
...
components:
schemas:
WidgetType:
type: string
enum: [<values from widget_type_registry>]
EventType:
type: string
enum: [<values from event_type_registry>]
AnnotationCategory:
type: string
enum: [<values from annotation_category_registry>]
```
3. The enum arrays must be **queried live** from the registry tables at request
time. The spec is generated data, not a static file. This ensures the spec
stays in sync with the registries automatically.
4. Add `x-ihf-contract: /contracts/functional/interaction-reporting-v1.md` as
a top-level extension field.
5. Serve at `GET /api/v2/openapi.json` (unauthenticated — it is a public
contract). Also serve `GET /api/v2/openapi.yaml` as a convenience.
6. Add a Swagger UI at `GET /api/v2/docs` that loads the generated spec.
Use the CDN-served Swagger UI (static HTML that loads swagger-ui from
unpkg; no npm build step required).
**Exit criteria:** `/api/v2/openapi.json` is valid OpenAPI 3.1; `widget_type`,
`event_type`, and `category` fields reference `$ref` schemas with `enum`
arrays populated from live registries; a new type added to any registry
appears in the spec on the next request.
---
### T05 — OAuth 2.0 client credentials flow
```task
id: IHUB-WP-0010-T05
status: done
priority: high
state_hub_task_id: "b8c5c9d5-7d9c-42ca-b679-0f7514b61910"
```
Implement the OAuth 2.0 client credentials grant so that external consumers
can obtain short-lived access tokens by presenting their client ID and secret.
1. `POST /api/v2/token` endpoint:
```
Content-Type: application/x-www-form-urlencoded
Body: grant_type=client_credentials&client_id=<id>&client_secret=<secret>&scope=hub:dev-hub:read
```
Response:
```json
{ "access_token": "<opaque>", "token_type": "Bearer", "expires_in": 3600 }
```
2. Implementation approach: opaque tokens stored in `api_keys` (reuse the
existing table; add `token_type TEXT DEFAULT 'bearer'` and `expires_at`).
JWTs are acceptable but opaque tokens are simpler for v1 and avoid key
management complexity.
3. Scope validation:
- `hub:{slug}:read` — allowed for all active consumers
- `hub:{slug}:write` — requires the consumer's `hub_capability_manifest_id`
to reference an active manifest for that hub slug
- `framework:read` — allowed for all
- Unknown scopes → 400 `invalid_scope`
4. Token lifetime: 3600 seconds (1 hour). Refresh tokens are out of scope for
Phase 9.
5. Standard OAuth error responses: 400 with `error` field (`invalid_client`,
`invalid_grant`, `invalid_scope`, `unsupported_grant_type`).
6. The existing ApiKey Bearer auth in T02/T03 should accept both pre-created
static keys AND tokens issued by this endpoint. Both are rows in `api_keys`.
**Exit criteria:** Full client credentials flow works end-to-end; hub-scoped
write is rejected without a manifest; token expiry is enforced.
---
### T06 — API consumer and API key management UI
```task
id: IHUB-WP-0010-T06
status: done
priority: medium
state_hub_task_id: "64f48e86-522a-4566-a91a-ce840d3f2633"
```
Admin-only web UI for managing API consumers and their keys. Extends the
existing admin section pattern established in Phase 8.
1. `Web/Controller/ApiConsumersController.hs`:
- CRUD: index, show, new, create, edit, update, delete
- Delete: soft-delete by setting `is_active = FALSE`; do not destroy records
2. `Web/Controller/ApiKeysController.hs`:
- Index (scoped to a consumer), new, create, revoke (sets `revoked_at`)
- Key generation: create a random 32-byte key, store `key_prefix` (first 8
hex chars) and `key_hash` (SHA-256). Display the full key **once** on
creation — never again. Show a "copy this now" alert banner.
3. Views (`Web/View/ApiConsumers/`, `Web/View/ApiKeys/`):
- Consumer index: name, linked manifest (if set), active/inactive badge,
rate limit, quota, key count
- Consumer show: details + key list + webhook subscriptions
- Consumer form: name, description, manifest selector (optional), rate
limit, quota fields
- Key create success: one-time display of full key value
4. Link "API Consumers" from the existing admin navigation.
5. Domain hub consumer display: if `hub_capability_manifest_id` is set, show
the manifest name, status badge, and a link to its show page.
**Exit criteria:** Full CRUD works; key is shown once on creation; revoked
keys are rejected by auth middleware; consumer linked to manifest shows
manifest details.
---
### T07 — TypeScript SDK generation from type registries
```task
id: IHUB-WP-0010-T07
status: done
priority: medium
state_hub_task_id: "8b7ecc7b-ffbe-449e-a33a-8ac2f029fc9f"
```
Generate a TypeScript client SDK that exports typed enums derived from the
live type registries, plus typed fetch wrappers for `/api/v2/` endpoints.
1. Create `Web/Controller/Api/V2/SdkController.hs` with a
`GenerateTsSdkAction` that:
- Queries `widget_type_registry`, `event_type_registry`,
`annotation_category_registry`
- Renders `Web/View/Api/V2/TsSdk.hs` which produces a `.ts` file
2. The generated TypeScript file (`GET /api/v2/sdk/ihf-client.ts`):
```typescript
// Auto-generated by IHF. Do not edit manually.
// Regenerate by fetching /api/v2/sdk/ihf-client.ts
export enum WidgetType {
Button = "button",
DataTable = "data-table",
// ... all entries from widget_type_registry
}
export enum EventType {
Clicked = "clicked",
Viewed = "viewed",
// ... all entries from event_type_registry
}
export enum AnnotationCategory {
UxFriction = "ux-friction",
// ... all entries from annotation_category_registry
}
export interface IhfApiOptions {
baseUrl: string;
bearerToken: string;
}
export class IhfClient {
constructor(private opts: IhfApiOptions) {}
async getWidgets(params?: { page?: number; perPage?: number }) { ... }
async getInteractionEvents(params?: { widgetId?: string; eventType?: EventType }) { ... }
async submitInteractionEvent(body: { widgetId: string; eventType: EventType; viewContext: string; metadata?: object }) { ... }
async submitAnnotation(body: { widgetId: string; category: AnnotationCategory; body: string }) { ... }
}
```
3. Serve the raw `.ts` file at `GET /api/v2/sdk/ihf-client.ts` (MIME type
`application/typescript`). This is always regenerated from live registries.
4. Also serve a static SDK download page at `GET /api/v2/sdk` listing
available SDKs with download links and usage instructions.
**Exit criteria:** `/api/v2/sdk/ihf-client.ts` is valid TypeScript; enum
values match the live registries; a new registry entry appears in the SDK on
next request.
---
### T08 — Python SDK generation from type registries
```task
id: IHUB-WP-0010-T08
status: done
priority: medium
state_hub_task_id: "a943d34b-9898-4aa1-a505-9184a6a476b0"
```
Generate a Python client SDK (`GET /api/v2/sdk/ihf-client.py`) following the
same live-generation approach as the TypeScript SDK.
1. Extend `Web/Controller/Api/V2/SdkController.hs` with a
`GeneratePySdkAction`.
2. The generated Python file:
```python
# Auto-generated by IHF. Do not edit manually.
# Regenerate: curl <base>/api/v2/sdk/ihf-client.py > ihf_client.py
from enum import Enum
from typing import Optional
import urllib.request, json
class WidgetType(str, Enum):
BUTTON = "button"
DATA_TABLE = "data-table"
# ... all entries from widget_type_registry
class EventType(str, Enum):
CLICKED = "clicked"
VIEWED = "viewed"
# ... all entries from event_type_registry
class AnnotationCategory(str, Enum):
UX_FRICTION = "ux-friction"
# ... all entries from annotation_category_registry
class IhfClient:
def __init__(self, base_url: str, bearer_token: str): ...
def get_widgets(self, page: int = 1, per_page: int = 50) -> dict: ...
def get_interaction_events(self, widget_id: Optional[str] = None, event_type: Optional[EventType] = None) -> dict: ...
def submit_interaction_event(self, widget_id: str, event_type: EventType, view_context: str, metadata: Optional[dict] = None) -> dict: ...
def submit_annotation(self, widget_id: str, category: AnnotationCategory, body: str) -> dict: ...
```
3. The enum identifier names follow Python conventions (UPPER_SNAKE_CASE).
The values are the registry strings. Use `str, Enum` so instances compare
equal to their string value.
4. Serve at `GET /api/v2/sdk/ihf-client.py` (MIME `text/x-python`).
**Exit criteria:** `/api/v2/sdk/ihf-client.py` is syntactically valid Python;
enum values match live registries; Python SDK page linked from `/api/v2/sdk`.
---
### T09 — Webhook delivery for interaction events, candidate creation, and decision records
```task
id: IHUB-WP-0010-T09
status: done
priority: medium
state_hub_task_id: "249e4de7-a0fb-4325-809b-a896bb807c17"
```
Allow consumers to subscribe to push notifications when governance events occur.
At minimum: `interaction_event.created` and `requirement_candidate.created`.
1. `Web/Controller/WebhookSubscriptionsController.hs`:
- CRUD scoped to an `ApiConsumer`
- `event_type` validated against `event_type_registry`
2. Delivery trigger: after inserting an `InteractionEvent` row, dispatch
delivery for all active subscriptions matching `event_type`. Use IHP's
background job mechanism (`IHP.Job`):
```haskell
-- Web/Job/WebhookDeliveryJob.hs
instance Job WebhookDeliveryJob where
perform WebhookDeliveryJob { .. } = do
-- Build payload, sign with subscription.secret (HMAC-SHA256)
-- POST to target_url
-- Insert WebhookDelivery record with result
```
3. HMAC signing: add `X-IHF-Signature: sha256=<hex>` header to each delivery
request. Consumers verify this header to authenticate the webhook.
4. Retry policy: on HTTP 4xx/5xx or network error, retry up to 3 times with
exponential backoff (5s, 25s, 125s). Record each attempt separately in
`webhook_deliveries`.
5. Add the same delivery trigger for `RequirementCandidate` creation and
`DecisionRecord` creation.
6. Webhook subscriptions management linked from the consumer show page.
**Exit criteria:** A subscription to `interaction_event.created` receives a
POST with HMAC signature when an event is inserted via the API; delivery is
recorded in `webhook_deliveries`; failed deliveries are retried up to 3 times.
---
### T10 — API usage dashboard
```task
id: IHUB-WP-0010-T10
status: done
priority: medium
state_hub_task_id: "fedae9a8-4cdc-41e2-b692-3cc848b9dd6f"
```
Admin view that surfaces per-consumer API usage metrics in near real-time.
1. Request logging middleware: for all `/api/v2/` requests, insert a row into
`api_request_log` after the response is sent (non-blocking). Capture:
`api_consumer_id`, `endpoint`, `method`, `status_code`, `latency_ms`,
`requested_at`.
2. `Web/Controller/ApiDashboardController.hs` with `ShowApiDashboardAction`:
- Aggregate query: per-consumer request count (last 24h), error rate
(4xx+5xx / total), last-seen timestamp, top 5 endpoints
- Uses IHP `AutoRefresh` for push updates on new log rows
3. View (`Web/View/ApiDashboard/Show.hs`):
- Summary table: consumer name, requests (24h), error rate %, last seen,
manifest linked (✓/)
- Time-series sparkline (optional; use a simple count-per-hour query if
rendering inline)
- Error breakdown panel: group by status code
4. Link "API Dashboard" from the admin nav.
**Exit criteria:** Dashboard renders for all consumers; request counts and
error rates update in near-real-time via AutoRefresh; last-seen shows correct
timestamp.
---
### T11 — Rate limiting, quota management, and close-out
```task
id: IHUB-WP-0010-T11
status: done
priority: medium
state_hub_task_id: "32befd50-af80-4bfd-affd-cbcf5e6bfac5"
```
Enforce per-consumer limits and close out the workplan.
**Rate limiting and quota:**
1. Middleware (applied to all `/api/v2/` routes) before action dispatch:
- Count requests in the last 60 seconds from `api_request_log` for the
consumer. If `> rate_limit_per_minute` → HTTP 429 with
`Retry-After: <seconds>` header.
- Count requests today (since `quota_resets_at - 1 day`) for the consumer.
If `> quota_per_day` → HTTP 429 with
`X-Quota-Resets-At: <iso8601>` header.
- Both return JSON `{ "error": "Rate limit exceeded", "code": "rate_limited" }`
or `"code": "quota_exceeded"`.
2. Quota reset: add a daily job `Web/Job/QuotaResetJob.hs` that runs at
00:00 UTC and updates `quota_resets_at` to the next midnight for all
consumers.
**Close-out:**
3. Update `CLAUDE.md`:
- Change `Active Workplan` section to point to the next workplan (Phase 10:
IHUB-WP-0011 when created) or mark Phase 9 as complete.
- Add IHUB-WP-0010 to the "Completed workplans" list.
4. Update `SCOPE.md` to reflect Phase 9 completion.
5. Update `ARCHITECTURE-LAYERS.md`:
- Add `ApiConsumer`, `ApiKey`, `WebhookSubscription`, `WebhookDelivery`,
`ApiRequestLog` to the Extensions layer.
- Update scorecard (Phase 9 adds external API surface — likely improves
Cross-layer and Configuration scores).
- Set next review date.
6. Update `/contracts/functional/interaction-reporting-v1.md` to reference
the live OpenAPI spec URL (`/api/v2/openapi.json`) and confirm the Phase 9
endpoint list.
7. Mark all exit criteria verified in this workplan file.
8. Sync task completions to state hub.
**Exit criteria (Phase 9 complete when all of these are true):**
- [x] All core IHF artifact types are readable via `/api/v2/`
- [x] Interaction events and annotations are writable via `/api/v2/`
- [x] OpenAPI spec generated; `widget_type`, `event_type`, `category` carry
`enum` arrays from live registries
- [x] TypeScript SDK at `/api/v2/sdk/ihf-client.ts` exports correct enums
- [x] Python SDK at `/api/v2/sdk/ihf-client.py` exports correct enums
- [x] Webhook delivery confirmed for `interaction_event.created` and
`requirement_candidate.created`
- [x] API usage dashboard renders correctly with AutoRefresh
- [x] OAuth client credentials flow works end-to-end
- [x] Submission of an unregistered `event_type` returns HTTP 422 with
registry-referenced error
- [x] Rate limiting returns 429 with `Retry-After`
- [x] CLAUDE.md updated; IHUB-WP-0010 listed as complete