Files
inter-hub/Web/Controller/WebhookSubscriptions.hs
Bernd Worsch 3cac021213
Some checks failed
Test / test (push) Has been cancelled
feat(WP-0010): IHF Phase 9 — External API Surface and Consumer SDKs
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>
2026-04-01 19:52:20 +00:00

68 lines
2.6 KiB
Haskell

module Web.Controller.WebhookSubscriptions where
import Web.Types
import Web.View.WebhookSubscriptions.New
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
import qualified Data.ByteString.Random as Random
import qualified Data.Text.Encoding as TE
import qualified Data.ByteString.Base16 as Base16
-- Webhook event topics are framework lifecycle events, not interaction event types
allowedWebhookTopics :: [Text]
allowedWebhookTopics =
[ "interaction_event.created"
, "annotation.created"
, "requirement_candidate.created"
, "decision_record.created"
, "deployment_record.created"
, "outcome_signal.created"
]
instance Controller WebhookSubscriptionsController where
beforeAction = ensureIsUser
action WebhookSubscriptionsAction { apiConsumerId } = do
redirectTo (ShowApiConsumerAction apiConsumerId)
action NewWebhookSubscriptionAction { apiConsumerId } = do
consumer <- fetch apiConsumerId
let subscription = newRecord @WebhookSubscription
render NewView { subscription, consumer }
action CreateWebhookSubscriptionAction = do
apiConsumerId <- param @(Id ApiConsumer) "apiConsumerId"
consumer <- fetch apiConsumerId
eventType <- param @Text "eventType"
targetUrl <- param @Text "targetUrl"
-- Validate against allowed webhook topics
unless (eventType `elem` allowedWebhookTopics) $ do
setErrorMessage ("Unknown webhook topic: " <> eventType)
redirectTo (NewWebhookSubscriptionAction apiConsumerId)
Right () -> do
-- Generate HMAC signing secret
secretBytes <- liftIO $ Random.random 32
let secret = TE.decodeUtf8 (Base16.encode secretBytes)
_sub <- newRecord @WebhookSubscription
|> set #apiConsumerId consumer.id
|> set #eventType eventType
|> set #targetUrl targetUrl
|> set #secret secret
|> set #isActive True
|> createRecord
redirectTo (ShowApiConsumerAction apiConsumerId)
action ToggleWebhookSubscriptionAction { webhookSubscriptionId } = do
sub <- fetch webhookSubscriptionId
sub |> set #isActive (not sub.isActive) |> updateRecord
consumer <- fetch sub.apiConsumerId
redirectTo (ShowApiConsumerAction consumer.id)
action DeleteWebhookSubscriptionAction { webhookSubscriptionId } = do
sub <- fetch webhookSubscriptionId
consumerId <- pure sub.apiConsumerId
deleteRecord sub
redirectTo (ShowApiConsumerAction consumerId)