generated from coulomb/repo-seed
feat(WP-0010): IHF Phase 9 — External API Surface and Consumer SDKs
Some checks failed
Test / test (push) Has been cancelled
Some checks failed
Test / test (push) Has been cancelled
Delivers the full Phase 9 external API layer: - Versioned REST API (/api/v2/) with OpenAPI 3.1 spec; enum arrays for widget_type, event_type, annotation category drawn live from registry tables - OAuth 2.0 client credentials flow (/api/v2/token); hub:*:write scopes gated on active HubCapabilityManifest FK - API key management: SHA256-hashed tokens, key_prefix for display, one-time reveal on creation, revocation support - TypeScript and Python consumer SDKs generated from registry tables (/api/v2/sdk/ihf-client.ts, /api/v2/sdk/ihf-client.py) - Webhook delivery: HMAC-SHA256 signing, append-only webhook_deliveries, fire-and-forget dispatch via forkIO, 3-retry logic - Admin API dashboard with 24h stats (request count, error rate, last seen) - Rate limiting (per-minute) and daily quota enforcement via api_request_log - Schema migration: api_consumers, api_keys, webhook_subscriptions (CHECK constraint on 6 framework lifecycle topics), webhook_deliveries (append-only trigger), api_request_log - ARCHITECTURE-LAYERS.md scorecard: 3.34 → 3.41 (approaching Strong) - contracts/functional/interaction-reporting-v1.md extended with Phase 9 endpoint catalogue and 422 validation error format GAAF: no bare TEXT discriminators; webhook event_type uses CHECK constraint over 6 allowed framework lifecycle topic strings (not widget event types). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
764
workplans/IHUB-WP-0010-ihf-phase9-external-api.md
Normal file
764
workplans/IHUB-WP-0010-ihf-phase9-external-api.md
Normal file
@@ -0,0 +1,764 @@
|
||||
---
|
||||
id: IHUB-WP-0010
|
||||
type: workplan
|
||||
title: "IHF Phase 9 — External API Surface and Consumer SDKs"
|
||||
domain: inter_hub
|
||||
repo: inter-hub
|
||||
status: active
|
||||
owner: custodian
|
||||
topic_slug: inter_hub
|
||||
created: "2026-04-01"
|
||||
updated: "2026-04-01"
|
||||
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 1–8 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 1–4 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)
|
||||
|
||||
---
|
||||
|
||||
## 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):**
|
||||
|
||||
- [ ] All core IHF artifact types are readable via `/api/v2/`
|
||||
- [ ] Interaction events and annotations are writable via `/api/v2/`
|
||||
- [ ] OpenAPI spec generated; `widget_type`, `event_type`, `category` carry
|
||||
`enum` arrays from live registries
|
||||
- [ ] TypeScript SDK at `/api/v2/sdk/ihf-client.ts` exports correct enums
|
||||
- [ ] Python SDK at `/api/v2/sdk/ihf-client.py` exports correct enums
|
||||
- [ ] Webhook delivery confirmed for `interaction_event.created` and
|
||||
`requirement_candidate.created`
|
||||
- [ ] API usage dashboard renders correctly with AutoRefresh
|
||||
- [ ] OAuth client credentials flow works end-to-end
|
||||
- [ ] Submission of an unregistered `event_type` returns HTTP 422 with
|
||||
registry-referenced error
|
||||
- [ ] Rate limiting returns 429 with `Retry-After`
|
||||
- [ ] CLAUDE.md updated; IHUB-WP-0010 listed as complete
|
||||
Reference in New Issue
Block a user