feat(WP-0010): IHF Phase 9 — External API Surface and Consumer SDKs
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:
2026-04-01 19:52:20 +00:00
parent 286d33923a
commit 3cac021213
38 changed files with 3581 additions and 17 deletions

View File

@@ -0,0 +1,65 @@
module Application.Helper.ApiRateLimit where
-- Rate limiting and request logging for /api/v2/ endpoints.
-- Called before action dispatch in all ApiV2* controllers.
import Generated.Types
import IHP.Prelude
import IHP.ModelSupport
import IHP.ControllerPrelude
import Data.Aeson (object, (.=))
import Database.PostgreSQL.Simple (Only(..))
import Web.Controller.Api.V2.Auth (respondWithStatus)
-- | Log a request to api_request_log and enforce rate limit / quota.
-- Returns () on success; calls respondWithStatus and exits on limit exceeded.
checkRateLimitAndLog ::
( ?context :: ControllerContext
, ?modelContext :: ModelContext
, ?respond :: Respond
, ?request :: Request
) =>
ApiConsumer ->
Text -> -- endpoint path
Text -> -- HTTP method
Int -> -- response status code (0 if not yet known; log after)
IO ()
checkRateLimitAndLog consumer endpoint method _statusCode = do
-- Check rate limit: requests in last 60 seconds
rows1 <- sqlQuery
"SELECT COUNT(*) FROM api_request_log \
\WHERE api_consumer_id = ? AND requested_at >= NOW() - INTERVAL '60 seconds'"
(Only consumer.id)
let reqCount = case rows1 of
[Only (n :: Int)] -> n
_ -> 0
when (reqCount >= consumer.rateLimitPerMinute) do
respondWithStatus 429 $ object
[ "error" .= ("Rate limit exceeded" :: Text)
, "code" .= ("rate_limited" :: Text)
, "retry_after" .= (60 :: Int)
]
-- Check daily quota
rows2 <- sqlQuery
"SELECT COUNT(*) FROM api_request_log \
\WHERE api_consumer_id = ? AND requested_at >= ? - INTERVAL '1 day'"
(consumer.id, consumer.quotaResetsAt)
let quotaUsed = case rows2 of
[Only (n :: Int)] -> n
_ -> 0
when (quotaUsed >= consumer.quotaPerDay) do
respondWithStatus 429 $ object
[ "error" .= ("Daily quota exceeded" :: Text)
, "code" .= ("quota_exceeded" :: Text)
, "quota_resets_at" .= consumer.quotaResetsAt
]
-- Log the request (status_code will be 0 here; update after response)
sqlExec
"INSERT INTO api_request_log (id, api_consumer_id, endpoint, method, status_code, requested_at) \
\VALUES (uuid_generate_v4(), ?, ?, ?, 200, NOW())"
(consumer.id, endpoint, method)
pure ()

View File

@@ -0,0 +1,116 @@
-- IHF Phase 9 — External API Surface and Consumer SDKs
-- IHUB-WP-0010-T01: api_consumers, api_keys, webhook_subscriptions,
-- webhook_deliveries, api_request_log
-- api_consumers: external systems that authenticate against /api/v2/
-- hub_capability_manifest_id is 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
);
CREATE INDEX api_consumers_manifest_idx ON api_consumers (hub_capability_manifest_id);
-- api_keys: bearer tokens for consumer authentication
-- key_hash stores SHA-256 hex of the full key; key_prefix (first 8 hex chars)
-- is shown in UI for identification. The full key is never stored.
-- token_type: 'static' for admin-created keys, 'oauth' for tokens from /api/v2/token
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,
key_hash TEXT NOT NULL,
scopes TEXT NOT NULL DEFAULT '',
token_type TEXT NOT NULL DEFAULT 'static'
CHECK (token_type IN ('static', 'oauth')),
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);
CREATE INDEX api_keys_hash_idx ON api_keys (key_hash);
-- webhook_subscriptions: consumer subscriptions to framework lifecycle events.
-- event_topic uses framework-level event names (distinct from widget interaction
-- event_type_registry which stores user interaction types like 'clicked', 'viewed').
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 CHECK (event_type IN (
'interaction_event.created',
'annotation.created',
'requirement_candidate.created',
'decision_record.created',
'deployment_record.created',
'outcome_signal.created'
)),
target_url TEXT NOT NULL,
secret TEXT NOT NULL,
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
);
CREATE INDEX webhook_deliveries_sub_idx
ON webhook_deliveries (webhook_subscription_id, attempted_at DESC);
-- 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();
CREATE OR REPLACE FUNCTION webhook_deliveries_no_delete()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION 'webhook_deliveries is append-only';
END; $$;
CREATE TRIGGER webhook_deliveries_no_delete
BEFORE DELETE ON webhook_deliveries
FOR EACH ROW EXECUTE FUNCTION webhook_deliveries_no_delete();
-- 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_time_idx
ON api_request_log (api_consumer_id, requested_at DESC);

View File

@@ -701,3 +701,87 @@ CREATE INDEX hub_capability_manifests_status_idx ON hub_capability_manifests (st
-- GAAF: type registries enforced from here (IHUB-WP-0009)
-- All new type discriminator columns (widget_type, event_type, category,
-- policy_scope) must reference a registry table or carry a CHECK constraint.
-- IHF Phase 9 — External API Surface and Consumer SDKs (IHUB-WP-0010)
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
);
CREATE INDEX api_consumers_manifest_idx ON api_consumers (hub_capability_manifest_id);
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,
key_hash TEXT NOT NULL,
scopes TEXT NOT NULL DEFAULT '',
token_type TEXT NOT NULL DEFAULT 'static'
CHECK (token_type IN ('static', 'oauth')),
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);
CREATE INDEX api_keys_hash_idx ON api_keys (key_hash);
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 CHECK (event_type IN (
'interaction_event.created',
'annotation.created',
'requirement_candidate.created',
'decision_record.created',
'deployment_record.created',
'outcome_signal.created'
)),
target_url TEXT NOT NULL,
secret TEXT NOT NULL,
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);
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
);
CREATE INDEX webhook_deliveries_sub_idx
ON webhook_deliveries (webhook_subscription_id, attempted_at DESC);
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_time_idx
ON api_request_log (api_consumer_id, requested_at DESC);