generated from coulomb/repo-seed
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
772 lines
28 KiB
Markdown
772 lines
28 KiB
Markdown
---
|
||
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 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)
|
||
|
||
## 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
|