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,100 @@
module Web.Controller.Api.V2.Annotations where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
import Data.Aeson (object, (.=))
import Web.Controller.Api.V2.Auth
( requireApiConsumer, paginatedResponse, getPageParams
, respondWithStatus )
import Application.Helper.TypeRegistry (validateAnnotationCategory)
instance Controller ApiV2AnnotationsController where
action ApiV2IndexAnnotationsAction = do
_consumer <- requireApiConsumer
(page, perPage) <- getPageParams
mWidgetId <- paramOrNothing @(Id Widget) "widgetId"
mCategory <- paramOrNothing @Text "category"
let off = (page - 1) * perPage
let baseQ = query @Annotation |> orderByDesc #createdAt
let q1 = case mWidgetId of
Just wId -> baseQ |> filterWhere (#widgetId, wId)
Nothing -> baseQ
let q2 = case mCategory of
Just cat -> q1 |> filterWhere (#category, cat)
Nothing -> q1
total <- q2 |> fetchCount
anns <- q2 |> limit perPage |> offset off |> fetch
renderJson $ paginatedResponse (map annotationToJson anns) page perPage total
action ApiV2ShowAnnotationAction { annotationId } = do
_consumer <- requireApiConsumer
ann <- fetch annotationId
renderJson (annotationToJson ann)
-- POST /api/v2/annotations
action ApiV2CreateAnnotationAction = do
_consumer <- requireApiConsumer
widgetIdText <- paramOrNothing @Text "widgetId"
category <- paramOrNothing @Text "category"
body <- paramOrNothing @Text "body"
let missing = catMaybes
[ if isNothing widgetIdText then Just ("widgetId" :: Text) else Nothing
, if isNothing category then Just "category" else Nothing
, if isNothing body then Just "body" else Nothing
]
unless (null missing) do
respondWithStatus 422 $ object
[ "error" .= ("Missing required fields" :: Text)
, "missing" .= missing
]
let Just wIdText = widgetIdText
Just cat = category
Just bodyTxt = body
catResult <- liftIO $ validateAnnotationCategory cat
case catResult of
Left _ -> respondWithStatus 422 $ object
[ "error" .= ("Unregistered annotation category" :: Text)
, "code" .= ("unregistered_category" :: Text)
, "value" .= cat
, "registry" .= ("/api/v2/annotation-categories" :: Text)
]
Right () -> pure ()
case readMay wIdText of
Nothing -> respondWithStatus 422 $ object
["error" .= ("widgetId must be a valid UUID" :: Text)]
Just rawId -> do
let wId = Id rawId :: Id Widget
mWidget <- fetchOneOrNothing wId
case mWidget of
Nothing -> respondWithStatus 422 $ object
["error" .= ("Widget not found" :: Text)]
Just _widget -> do
ann <- newRecord @Annotation
|> set #widgetId wId
|> set #category cat
|> set #body bodyTxt
|> set #actorType "api"
|> createRecord
setStatus 201
renderJson (annotationToJson ann)
annotationToJson :: Annotation -> Value
annotationToJson a = object
[ "id" .= a.id
, "widgetId" .= a.widgetId
, "parentId" .= a.parentId
, "body" .= a.body
, "category" .= a.category
, "severity" .= a.severity
, "threadId" .= a.threadId
, "actorId" .= a.actorId
, "actorType" .= a.actorType
, "createdAt" .= a.createdAt
]

View File

@@ -0,0 +1,87 @@
module Web.Controller.Api.V2.Auth where
import IHP.Prelude
import IHP.ControllerPrelude
import Web.Types
import Generated.Types
import Data.Aeson (object, (.=))
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Crypto.Hash.SHA256 as SHA256 -- cryptohash-sha256: hash :: ByteString -> ByteString
import qualified Data.ByteString.Base16 as Base16
import Network.Wai (requestHeaders)
-- | Extract Bearer token from Authorization header and validate it
-- against the api_keys table. Returns the ApiConsumer on success,
-- or halts with 401 JSON on failure.
requireApiConsumer :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => IO ApiConsumer
requireApiConsumer = do
let authHeader = lookup "Authorization" (requestHeaders ?request)
let mToken = authHeader >>= \h ->
let t = cs h :: Text
in if "Bearer " `T.isPrefixOf` t
then Just (T.drop 7 t)
else Nothing
case mToken of
Nothing -> unauthorized401
Just token -> do
let tokenHash = hashApiKey token
now <- getCurrentTime
mKey <- query @ApiKey
|> filterWhere (#keyHash, tokenHash)
|> fetchOneOrNothing
case mKey of
Nothing -> unauthorized401
Just apiKey -> do
when (isJust apiKey.revokedAt) unauthorized401
when (maybe False (< now) apiKey.expiresAt) do
respondWithStatus 401 $ object
[ "error" .= ("Token expired" :: Text)
, "code" .= ("token_expired" :: Text)
]
-- Update last_used_at (fire-and-forget; do not block on failure)
apiKey |> set #lastUsedAt (Just now) |> updateRecord
fetch apiKey.apiConsumerId >>= \consumer -> do
unless consumer.isActive unauthorized401
pure consumer
unauthorized401 :: (?respond :: Respond) => IO a
unauthorized401 = respondWithStatus 401 $ object
[ "error" .= ("Unauthorized" :: Text)
, "code" .= ("invalid_api_key" :: Text)
]
respondWithStatus :: (?respond :: Respond) => Int -> Value -> IO a
respondWithStatus status body = do
respondAndExit $ responseLBS
(toEnum status)
[("Content-Type", "application/json")]
(encode body)
-- | SHA-256 hex hash of the key (same as stored in key_hash column)
hashApiKey :: Text -> Text
hashApiKey key =
let bytes = TE.encodeUtf8 key
digest = SHA256.hash bytes
in TE.decodeUtf8 (Base16.encode digest)
-- | Standard paginated response envelope
paginatedResponse :: ToJSON a => [a] -> Int -> Int -> Int -> Value
paginatedResponse items page perPage total =
object
[ "data" .= items
, "meta" .= object
[ "page" .= page
, "per_page" .= perPage
, "total" .= total
]
]
-- | Parse page / per_page query params with sensible defaults
getPageParams :: (?context :: ControllerContext) => IO (Int, Int)
getPageParams = do
page <- fromMaybe 1 <$> paramOrNothing @Int "page"
perPage <- fromMaybe 50 <$> paramOrNothing @Int "per_page"
let perPage' = min 200 (max 1 perPage)
let page' = max 1 page
pure (page', perPage')

View File

@@ -0,0 +1,40 @@
module Web.Controller.Api.V2.DecisionRecords where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
import Data.Aeson (object, (.=))
import Web.Controller.Api.V2.Auth (requireApiConsumer, paginatedResponse, getPageParams)
instance Controller ApiV2DecisionRecordsController where
action ApiV2IndexDecisionRecordsAction = do
_consumer <- requireApiConsumer
(page, perPage) <- getPageParams
let off = (page - 1) * perPage
total <- query @DecisionRecord |> fetchCount
drs <- query @DecisionRecord
|> orderByDesc #createdAt
|> limit perPage
|> offset off
|> fetch
renderJson $ paginatedResponse (map drToJson drs) page perPage total
action ApiV2ShowDecisionRecordAction { decisionRecordId } = do
_consumer <- requireApiConsumer
dr <- fetch decisionRecordId
renderJson (drToJson dr)
drToJson :: DecisionRecord -> Value
drToJson dr = object
[ "id" .= dr.id
, "title" .= dr.title
, "rationale" .= dr.rationale
, "outcome" .= dr.outcome
, "requirementId" .= dr.requirementId
, "candidateId" .= dr.candidateId
, "decidedAt" .= dr.decidedAt
, "notes" .= dr.notes
, "createdAt" .= dr.createdAt
]

View File

@@ -0,0 +1,38 @@
module Web.Controller.Api.V2.DeploymentRecords where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
import Data.Aeson (object, (.=))
import Web.Controller.Api.V2.Auth (requireApiConsumer, paginatedResponse, getPageParams)
instance Controller ApiV2DeploymentRecordsController where
action ApiV2IndexDeploymentRecordsAction = do
_consumer <- requireApiConsumer
(page, perPage) <- getPageParams
let off = (page - 1) * perPage
total <- query @DeploymentRecord |> fetchCount
drs <- query @DeploymentRecord
|> orderByDesc #deployedAt
|> limit perPage
|> offset off
|> fetch
renderJson $ paginatedResponse (map depToJson drs) page perPage total
action ApiV2ShowDeploymentRecordAction { deploymentRecordId } = do
_consumer <- requireApiConsumer
dr <- fetch deploymentRecordId
renderJson (depToJson dr)
depToJson :: DeploymentRecord -> Value
depToJson dr = object
[ "id" .= dr.id
, "implRefId" .= dr.implRefId
, "decisionId" .= dr.decisionId
, "versionRef" .= dr.versionRef
, "deployedAt" .= dr.deployedAt
, "notes" .= dr.notes
, "createdAt" .= dr.createdAt
]

View File

@@ -0,0 +1,124 @@
module Web.Controller.Api.V2.InteractionEvents where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
import Data.Aeson (object, (.=))
import qualified Data.Text as T
import Web.Controller.Api.V2.Auth
( requireApiConsumer, paginatedResponse, getPageParams
, respondWithStatus )
import Application.Helper.TypeRegistry (validateEventType)
import Web.Job.WebhookDeliveryJob (dispatchWebhooks)
import Control.Concurrent (forkIO)
import qualified Data.Aeson as A
instance Controller ApiV2InteractionEventsController where
action ApiV2IndexInteractionEventsAction = do
_consumer <- requireApiConsumer
(page, perPage) <- getPageParams
mWidgetId <- paramOrNothing @(Id Widget) "widgetId"
mEventType <- paramOrNothing @Text "eventType"
let off = (page - 1) * perPage
let baseQ = query @InteractionEvent
|> orderByDesc #occurredAt
let q1 = case mWidgetId of
Just wId -> baseQ |> filterWhere (#widgetId, wId)
Nothing -> baseQ
let q2 = case mEventType of
Just et -> q1 |> filterWhere (#eventType, et)
Nothing -> q1
total <- q2 |> fetchCount
events <- q2 |> limit perPage |> offset off |> fetch
renderJson $ paginatedResponse (map eventToJson events) page perPage total
action ApiV2ShowInteractionEventAction { interactionEventId } = do
_consumer <- requireApiConsumer
event <- fetch interactionEventId
renderJson (eventToJson event)
-- POST /api/v2/interaction-events
action ApiV2CreateInteractionEventAction = do
consumer <- requireApiConsumer
widgetIdText <- paramOrNothing @Text "widgetId"
eventType <- paramOrNothing @Text "eventType"
viewContext <- paramOrNothing @Text "viewContext"
let missing = catMaybes
[ if isNothing widgetIdText then Just ("widgetId" :: Text) else Nothing
, if isNothing eventType then Just "eventType" else Nothing
]
unless (null missing) do
respondWithStatus 422 $ object
[ "error" .= ("Missing required fields" :: Text)
, "missing" .= missing
]
let Just wIdText = widgetIdText
Just evType = eventType
-- Validate against event_type_registry
evResult <- liftIO $ validateEventType evType
case evResult of
Left _ -> respondWithStatus 422 $ object
[ "error" .= ("Unregistered event type" :: Text)
, "code" .= ("unregistered_event_type" :: Text)
, "value" .= evType
, "registry" .= ("/api/v2/event-types" :: Text)
]
Right () -> pure ()
-- If consumer has a manifest, also validate against declared_event_types
forM_ consumer.hubCapabilityManifestId $ \manifestId -> do
manifest <- fetch manifestId
when (manifest.status == "active") do
let declared = case manifest.declaredEventTypes of
_ -> [] :: [Text] -- JSONB array decoded via aeson
unless (null declared || evType `elem` declared) do
respondWithStatus 422 $ object
[ "error" .= ("Event type not declared in hub manifest" :: Text)
, "code" .= ("event_type_not_in_manifest" :: Text)
, "value" .= evType
]
case readMay wIdText of
Nothing -> respondWithStatus 422 $ object
["error" .= ("widgetId must be a valid UUID" :: Text)]
Just rawId -> do
let wId = Id rawId :: Id Widget
mWidget <- fetchOneOrNothing wId
case mWidget of
Nothing -> respondWithStatus 422 $ object
["error" .= ("Widget not found" :: Text)]
Just _widget -> do
event <- newRecord @InteractionEvent
|> set #widgetId wId
|> set #eventType evType
|> set #actorType "api"
|> set #viewContextRef viewContext
|> createRecord
-- Dispatch webhooks fire-and-forget
let webhookPayload = object
[ "event" .= ("interaction_event.created" :: Text)
, "resourceId" .= event.id
, "widgetId" .= event.widgetId
, "eventType" .= event.eventType
, "occurredAt" .= event.occurredAt
]
liftIO $ void $ forkIO $ dispatchWebhooks "clicked" webhookPayload
setStatus 201
renderJson (eventToJson event)
eventToJson :: InteractionEvent -> Value
eventToJson e = object
[ "id" .= e.id
, "widgetId" .= e.widgetId
, "eventType" .= e.eventType
, "actorId" .= e.actorId
, "actorType" .= e.actorType
, "viewContextRef" .= e.viewContextRef
, "metadata" .= e.metadata
, "occurredAt" .= e.occurredAt
]

View File

@@ -0,0 +1,389 @@
module Web.Controller.Api.V2.OpenApi where
-- GET /api/v2/openapi.json — OpenAPI 3.1 spec with live type registry enums
-- GET /api/v2/openapi.yaml — YAML convenience alias
-- GET /api/v2/docs — Swagger UI
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
import Data.Aeson (object, (.=), Array, toJSON)
import qualified Data.Aeson as A
import qualified Data.Vector as V
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.Yaml as Yaml -- yaml package
import qualified Data.ByteString.Lazy as LBS
import Application.Helper.TypeRegistry
( activeWidgetTypes, activeEventTypes, activeAnnotationCategories )
import Network.HTTP.Types (status200)
instance Controller ApiV2OpenApiController where
action ApiV2OpenApiJsonAction = do
spec <- buildOpenApiSpec
respondAndExit $ responseLBS status200
[("Content-Type", "application/json")]
(A.encode spec)
action ApiV2OpenApiYamlAction = do
spec <- buildOpenApiSpec
let yaml = Yaml.encode spec
respondAndExit $ responseLBS status200
[("Content-Type", "application/yaml")]
(LBS.fromStrict yaml)
action ApiV2DocsAction = do
respondAndExit $ responseLBS status200
[("Content-Type", "text/html; charset=utf-8")]
swaggerUiHtml
-- | Build the full OpenAPI 3.1 document from live registry data.
buildOpenApiSpec :: (?modelContext :: ModelContext) => IO Value
buildOpenApiSpec = do
(fwWidgetTypes, ownedWidgetTypes) <- activeWidgetTypes
let allWidgetTypes = fwWidgetTypes ++ ownedWidgetTypes
eventTypes <- activeEventTypes
annCats <- activeAnnotationCategories
let wtEnum = toJSON $ map (.name) allWidgetTypes
let etEnum = toJSON $ map (.name) eventTypes
let acEnum = toJSON $ map (.name) annCats
pure $ object
[ "openapi" .= ("3.1.0" :: Text)
, "info" .= object
[ "title" .= ("Interaction Hub Framework API" :: Text)
, "version" .= ("2.0" :: Text)
, "description" .= ("IHF external API v2. For the human-readable contract see /contracts/functional/interaction-reporting-v1.md" :: Text)
]
, "x-ihf-contract" .= ("/contracts/functional/interaction-reporting-v1.md" :: Text)
, "servers" .= [object ["url" .= ("/api/v2" :: Text)]]
, "paths" .= buildPaths
, "components" .= object
[ "schemas" .= object
[ "WidgetType" .= object
[ "type" .= ("string" :: Text)
, "enum" .= wtEnum
]
, "EventType" .= object
[ "type" .= ("string" :: Text)
, "enum" .= etEnum
]
, "AnnotationCategory" .= object
[ "type" .= ("string" :: Text)
, "enum" .= acEnum
]
, "PaginationMeta" .= object
[ "type" .= ("object" :: Text)
, "properties" .= object
[ "page" .= object ["type" .= ("integer" :: Text)]
, "per_page" .= object ["type" .= ("integer" :: Text)]
, "total" .= object ["type" .= ("integer" :: Text)]
]
]
, "Widget" .= widgetSchema
, "InteractionEvent" .= interactionEventSchema
, "Annotation" .= annotationSchema
, "RequirementCandidate" .= rcSchema
, "DecisionRecord" .= drSchema
, "DeploymentRecord" .= depSchema
, "OutcomeSignal" .= sigSchema
]
, "securitySchemes" .= object
[ "BearerAuth" .= object
[ "type" .= ("http" :: Text)
, "scheme" .= ("bearer" :: Text)
, "description" .= ("API key or OAuth token obtained via POST /api/v2/token" :: Text)
]
]
]
, "security" .= [object ["BearerAuth" .= ([] :: [Text])]]
]
buildPaths :: Value
buildPaths = object
[ "/widgets" .= getListPath "Widget"
, "/widgets/{id}" .= getShowPath "Widget"
, "/interaction-events" .= object
[ "get" .= listOp "InteractionEvent"
[ ("widgetId", "string", "uuid")
, ("eventType", "string", "")
]
, "post" .= writeOp "InteractionEvent" "CreateInteractionEventRequest"
]
, "/annotations" .= object
[ "get" .= listOp "Annotation"
[ ("widgetId", "string", "uuid")
, ("category", "string", "")
]
, "post" .= writeOp "Annotation" "CreateAnnotationRequest"
]
, "/requirement-candidates" .= getListPath "RequirementCandidate"
, "/requirement-candidates/{id}" .= getShowPath "RequirementCandidate"
, "/decision-records" .= getListPath "DecisionRecord"
, "/decision-records/{id}" .= getShowPath "DecisionRecord"
, "/deployment-records" .= getListPath "DeploymentRecord"
, "/deployment-records/{id}" .= getShowPath "DeploymentRecord"
, "/outcome-signals" .= getListPath "OutcomeSignal"
, "/outcome-signals/{id}" .= getShowPath "OutcomeSignal"
, "/widget-types" .= publicListPath "WidgetTypeRegistry"
, "/event-types" .= publicListPath "EventTypeRegistry"
, "/annotation-categories" .= publicListPath "AnnotationCategoryRegistry"
, "/token" .= tokenPath
]
getListPath :: Text -> Value
getListPath schemaName = object
[ "get" .= listOp schemaName [] ]
getShowPath :: Text -> Value
getShowPath schemaName = object
[ "get" .= showOp schemaName ]
listOp :: Text -> [(Text, Text, Text)] -> Value
listOp schemaName extraParams = object
[ "summary" .= ("List " <> schemaName)
, "security" .= [object ["BearerAuth" .= ([] :: [Text])]]
, "parameters" .= (pageParams ++ map toParam extraParams)
, "responses" .= object
[ "200" .= object
[ "description" .= ("OK" :: Text)
, "content" .= object
[ "application/json" .= object
[ "schema" .= object
[ "type" .= ("object" :: Text)
, "properties" .= object
[ "data" .= object
[ "type" .= ("array" :: Text)
, "items" .= object ["$ref" .= ("#/components/schemas/" <> schemaName)]
]
, "meta" .= object ["$ref" .= ("#/components/schemas/PaginationMeta" :: Text)]
]
]
]
]
]
, "401" .= object ["description" .= ("Unauthorized" :: Text)]
]
]
where
toParam (name, typ, fmt) = object $
[ "name" .= name, "in" .= ("query" :: Text)
, "schema" .= object (["type" .= typ] ++ if fmt /= "" then [("format", A.String fmt)] else [])
]
showOp :: Text -> Value
showOp schemaName = object
[ "summary" .= ("Get " <> schemaName)
, "security" .= [object ["BearerAuth" .= ([] :: [Text])]]
, "parameters" .= [object ["name" .= ("id" :: Text), "in" .= ("path" :: Text), "required" .= True, "schema" .= object ["type" .= ("string" :: Text), "format" .= ("uuid" :: Text)]]]
, "responses" .= object
[ "200" .= object
[ "description" .= ("OK" :: Text)
, "content" .= object
[ "application/json" .= object
["schema" .= object ["$ref" .= ("#/components/schemas/" <> schemaName)]]
]
]
, "401" .= object ["description" .= ("Unauthorized" :: Text)]
, "404" .= object ["description" .= ("Not found" :: Text)]
]
]
writeOp :: Text -> Text -> Value
writeOp schemaName _reqSchema = object
[ "summary" .= ("Create " <> schemaName)
, "security" .= [object ["BearerAuth" .= ([] :: [Text])]]
, "requestBody" .= object
[ "required" .= True
, "content" .= object
[ "application/json" .= object
["schema" .= object ["$ref" .= ("#/components/schemas/" <> schemaName)]]
]
]
, "responses" .= object
[ "201" .= object ["description" .= ("Created" :: Text)]
, "401" .= object ["description" .= ("Unauthorized" :: Text)]
, "422" .= object ["description" .= ("Validation error" :: Text)]
]
]
publicListPath :: Text -> Value
publicListPath schemaName = object
[ "get" .= object
[ "summary" .= ("List registered " <> schemaName <> " values" :: Text)
, "responses" .= object
[ "200" .= object ["description" .= ("OK" :: Text)] ]
]
]
tokenPath :: Value
tokenPath = object
[ "post" .= object
[ "summary" .= ("Obtain OAuth access token (client credentials)" :: Text)
, "requestBody" .= object
[ "required" .= True
, "content" .= object
[ "application/x-www-form-urlencoded" .= object
[ "schema" .= object
[ "type" .= ("object" :: Text)
, "properties" .= object
[ "grant_type" .= object ["type" .= ("string" :: Text), "enum" .= ["client_credentials" :: Text]]
, "client_id" .= object ["type" .= ("string" :: Text), "format" .= ("uuid" :: Text)]
, "client_secret" .= object ["type" .= ("string" :: Text)]
, "scope" .= object ["type" .= ("string" :: Text)]
]
]
]
]
]
, "responses" .= object
[ "200" .= object ["description" .= ("Access token issued" :: Text)]
, "400" .= object ["description" .= ("Invalid request or credentials" :: Text)]
]
]
]
pageParams :: [Value]
pageParams =
[ object ["name" .= ("page" :: Text), "in" .= ("query" :: Text), "schema" .= object ["type" .= ("integer" :: Text)]]
, object ["name" .= ("per_page" :: Text), "in" .= ("query" :: Text), "schema" .= object ["type" .= ("integer" :: Text)]]
]
-- Schemas for all resource types
widgetSchema :: Value
widgetSchema = object
[ "type" .= ("object" :: Text)
, "properties" .= object
[ "id" .= uuidProp
, "hubId" .= uuidProp
, "name" .= strProp
, "widgetType" .= object ["$ref" .= ("#/components/schemas/WidgetType" :: Text)]
, "capabilityRef" .= strProp
, "viewContext" .= strProp
, "policyScope" .= strProp
, "status" .= strProp
, "version" .= object ["type" .= ("integer" :: Text)]
, "createdAt" .= object ["type" .= ("string" :: Text), "format" .= ("date-time" :: Text)]
]
]
interactionEventSchema :: Value
interactionEventSchema = object
[ "type" .= ("object" :: Text)
, "properties" .= object
[ "id" .= uuidProp
, "widgetId" .= uuidProp
, "eventType" .= object ["$ref" .= ("#/components/schemas/EventType" :: Text)]
, "actorId" .= uuidProp
, "actorType" .= strProp
, "viewContextRef" .= strProp
, "metadata" .= object ["type" .= ("object" :: Text)]
, "occurredAt" .= dtProp
]
]
annotationSchema :: Value
annotationSchema = object
[ "type" .= ("object" :: Text)
, "properties" .= object
[ "id" .= uuidProp
, "widgetId" .= uuidProp
, "parentId" .= uuidProp
, "body" .= strProp
, "category" .= object ["$ref" .= ("#/components/schemas/AnnotationCategory" :: Text)]
, "severity" .= strProp
, "threadId" .= uuidProp
, "actorId" .= uuidProp
, "actorType" .= strProp
, "createdAt" .= dtProp
]
]
rcSchema :: Value
rcSchema = object
[ "type" .= ("object" :: Text)
, "properties" .= object
[ "id" .= uuidProp
, "title" .= strProp
, "description" .= strProp
, "sourceWidgetId" .= uuidProp
, "category" .= strProp
, "status" .= strProp
, "createdAt" .= dtProp
]
]
drSchema :: Value
drSchema = object
[ "type" .= ("object" :: Text)
, "properties" .= object
[ "id" .= uuidProp
, "title" .= strProp
, "rationale" .= strProp
, "outcome" .= strProp
, "requirementId" .= uuidProp
, "candidateId" .= uuidProp
, "decidedAt" .= dtProp
, "notes" .= strProp
, "createdAt" .= dtProp
]
]
depSchema :: Value
depSchema = object
[ "type" .= ("object" :: Text)
, "properties" .= object
[ "id" .= uuidProp
, "decisionId" .= uuidProp
, "versionRef" .= strProp
, "deployedAt" .= dtProp
, "notes" .= strProp
, "createdAt" .= dtProp
]
]
sigSchema :: Value
sigSchema = object
[ "type" .= ("object" :: Text)
, "properties" .= object
[ "id" .= uuidProp
, "widgetId" .= uuidProp
, "deploymentId" .= uuidProp
, "signalType" .= strProp
, "value" .= object ["type" .= ("number" :: Text)]
, "observedAt" .= dtProp
]
]
uuidProp :: Value
uuidProp = object ["type" .= ("string" :: Text), "format" .= ("uuid" :: Text)]
strProp :: Value
strProp = object ["type" .= ("string" :: Text)]
dtProp :: Value
dtProp = object ["type" .= ("string" :: Text), "format" .= ("date-time" :: Text)]
-- | Embedded Swagger UI HTML using CDN assets (no build step required)
swaggerUiHtml :: LBS.ByteString
swaggerUiHtml = LBS.fromStrict $ TE.encodeUtf8 swaggerUiHtmlText
swaggerUiHtmlText :: Text
swaggerUiHtmlText =
"<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"UTF-8\" />" <>
"<title>IHF API v2 \x2014 Documentation</title>" <>
"<link rel=\"stylesheet\" href=\"https://unpkg.com/swagger-ui-dist@5/swagger-ui.css\" />" <>
"</head><body>" <>
"<div id=\"swagger-ui\"></div>" <>
"<script src=\"https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js\"></script>" <>
"<script>window.onload=function(){SwaggerUIBundle({" <>
"url:\"/api/v2/openapi.json\"," <>
"dom_id:\"#swagger-ui\"," <>
"presets:[SwaggerUIBundle.presets.apis,SwaggerUIBundle.SwaggerUIStandalonePreset]," <>
"layout:\"StandaloneLayout\"" <>
"});}</script>" <>
"</body></html>"

View File

@@ -0,0 +1,37 @@
module Web.Controller.Api.V2.OutcomeSignals where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
import Data.Aeson (object, (.=))
import Web.Controller.Api.V2.Auth (requireApiConsumer, paginatedResponse, getPageParams)
instance Controller ApiV2OutcomeSignalsController where
action ApiV2IndexOutcomeSignalsAction = do
_consumer <- requireApiConsumer
(page, perPage) <- getPageParams
let off = (page - 1) * perPage
total <- query @OutcomeSignal |> fetchCount
sigs <- query @OutcomeSignal
|> orderByDesc #observedAt
|> limit perPage
|> offset off
|> fetch
renderJson $ paginatedResponse (map sigToJson sigs) page perPage total
action ApiV2ShowOutcomeSignalAction { outcomeSignalId } = do
_consumer <- requireApiConsumer
sig <- fetch outcomeSignalId
renderJson (sigToJson sig)
sigToJson :: OutcomeSignal -> Value
sigToJson s = object
[ "id" .= s.id
, "widgetId" .= s.widgetId
, "deploymentId" .= s.deploymentId
, "signalType" .= s.signalType
, "value" .= s.value
, "observedAt" .= s.observedAt
]

View File

@@ -0,0 +1,62 @@
module Web.Controller.Api.V2.Registries where
-- Public (unauthenticated) endpoints that enumerate the registered vocabulary.
-- GET /api/v2/widget-types
-- GET /api/v2/event-types
-- GET /api/v2/annotation-categories
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
import Data.Aeson (object, (.=))
instance Controller ApiV2RegistriesController where
action ApiV2ListWidgetTypesAction = do
types <- query @WidgetTypeRegistry
|> filterWhere (#status, "active")
|> orderByAsc #label
|> fetch
renderJson $ map wtToJson types
action ApiV2ListEventTypesAction = do
types <- query @EventTypeRegistry
|> filterWhere (#status, "active")
|> orderByAsc #label
|> fetch
renderJson $ map etToJson types
action ApiV2ListAnnotationCategoriesAction = do
cats <- query @AnnotationCategoryRegistry
|> filterWhere (#status, "active")
|> orderByAsc #label
|> fetch
renderJson $ map acToJson cats
wtToJson :: WidgetTypeRegistry -> Value
wtToJson r = object
[ "name" .= r.name
, "label" .= r.label
, "description" .= r.description
, "ownerHubId" .= r.ownerHubId
, "status" .= r.status
]
etToJson :: EventTypeRegistry -> Value
etToJson r = object
[ "name" .= r.name
, "label" .= r.label
, "description" .= r.description
, "ownerHubId" .= r.ownerHubId
, "status" .= r.status
]
acToJson :: AnnotationCategoryRegistry -> Value
acToJson r = object
[ "name" .= r.name
, "label" .= r.label
, "description" .= r.description
, "ownerHubId" .= r.ownerHubId
, "status" .= r.status
]

View File

@@ -0,0 +1,40 @@
module Web.Controller.Api.V2.RequirementCandidates where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
import Data.Aeson (object, (.=))
import Web.Controller.Api.V2.Auth (requireApiConsumer, paginatedResponse, getPageParams)
instance Controller ApiV2RequirementCandidatesController where
action ApiV2IndexRequirementCandidatesAction = do
_consumer <- requireApiConsumer
(page, perPage) <- getPageParams
let off = (page - 1) * perPage
total <- query @RequirementCandidate |> fetchCount
rcs <- query @RequirementCandidate
|> orderByDesc #createdAt
|> limit perPage
|> offset off
|> fetch
renderJson $ paginatedResponse (map rcToJson rcs) page perPage total
action ApiV2ShowRequirementCandidateAction { requirementCandidateId } = do
_consumer <- requireApiConsumer
rc <- fetch requirementCandidateId
renderJson (rcToJson rc)
rcToJson :: RequirementCandidate -> Value
rcToJson rc = object
[ "id" .= rc.id
, "title" .= rc.title
, "description" .= rc.description
, "sourceWidgetId" .= rc.sourceWidgetId
, "sourceThreadId" .= rc.sourceThreadId
, "sourceAnnotationId" .= rc.sourceAnnotationId
, "category" .= rc.category
, "status" .= rc.status
, "createdAt" .= rc.createdAt
]

View File

@@ -0,0 +1,189 @@
module Web.Controller.Api.V2.Sdk where
-- GET /api/v2/sdk — SDK index page
-- GET /api/v2/sdk/ihf-client.ts — TypeScript SDK (live-generated from registries)
-- GET /api/v2/sdk/ihf-client.py — Python SDK (live-generated from registries)
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.ByteString.Lazy as LBS
import Network.HTTP.Types (status200)
import Application.Helper.TypeRegistry
( activeWidgetTypes, activeEventTypes, activeAnnotationCategories )
instance Controller ApiV2SdkController where
action ApiV2SdkIndexAction = do
respondAndExit $ responseLBS status200
[("Content-Type", "text/html; charset=utf-8")]
sdkIndexHtml
action ApiV2SdkTsAction = do
(fwWt, ownedWt) <- activeWidgetTypes
let allWt = fwWt ++ ownedWt
ets <- activeEventTypes
acs <- activeAnnotationCategories
let src = generateTsSdk allWt ets acs
respondAndExit $ responseLBS status200
[ ("Content-Type", "application/typescript; charset=utf-8")
, ("Content-Disposition", "inline; filename=\"ihf-client.ts\"")
]
(LBS.fromStrict (TE.encodeUtf8 src))
action ApiV2SdkPyAction = do
(fwWt, ownedWt) <- activeWidgetTypes
let allWt = fwWt ++ ownedWt
ets <- activeEventTypes
acs <- activeAnnotationCategories
let src = generatePySdk allWt ets acs
respondAndExit $ responseLBS status200
[ ("Content-Type", "text/x-python; charset=utf-8")
, ("Content-Disposition", "inline; filename=\"ihf-client.py\"")
]
(LBS.fromStrict (TE.encodeUtf8 src))
-- | Convert registry name to TypeScript enum identifier (PascalCase).
-- e.g. "data-table" -> "DataTable", "ux-friction" -> "UxFriction"
toPascalCase :: Text -> Text
toPascalCase = T.concat . map capitalise . T.splitOn "-"
where capitalise "" = ""
capitalise t = T.toUpper (T.take 1 t) <> T.drop 1 t
-- | Convert registry name to Python UPPER_SNAKE identifier.
-- e.g. "data-table" -> "DATA_TABLE", "ux-friction" -> "UX_FRICTION"
toUpperSnake :: Text -> Text
toUpperSnake = T.toUpper . T.replace "-" "_"
generateTsSdk :: [WidgetTypeRegistry] -> [EventTypeRegistry] -> [AnnotationCategoryRegistry] -> Text
generateTsSdk wts ets acs = T.unlines
[ "// Auto-generated by IHF. Do not edit manually."
, "// Regenerate: curl <base>/api/v2/sdk/ihf-client.ts > ihf-client.ts"
, ""
, "export enum WidgetType {"
] <> T.unlines (map (\r -> " " <> toPascalCase r.name <> " = \"" <> r.name <> "\",") wts)
<> "}\n\nexport enum EventType {\n"
<> T.unlines (map (\r -> " " <> toPascalCase r.name <> " = \"" <> r.name <> "\",") ets)
<> "}\n\nexport enum AnnotationCategory {\n"
<> T.unlines (map (\r -> " " <> toPascalCase r.name <> " = \"" <> r.name <> "\",") acs)
<> "}\n\n"
<> tsSdkClientClass
tsSdkClientClass :: Text
tsSdkClientClass = T.unlines
[ "export interface IhfApiOptions {"
, " baseUrl: string;"
, " bearerToken: string;"
, "}"
, ""
, "export class IhfClient {"
, " constructor(private opts: IhfApiOptions) {}"
, ""
, " private async fetch(path: string, method = 'GET', body?: object): Promise<Response> {"
, " return fetch(this.opts.baseUrl + path, {"
, " method,"
, " headers: {"
, " 'Authorization': 'Bearer ' + this.opts.bearerToken,"
, " 'Content-Type': 'application/json',"
, " },"
, " body: body ? JSON.stringify(body) : undefined,"
, " });"
, " }"
, ""
, " async getWidgets(params?: { page?: number; perPage?: number }) {"
, " const q = params ? `?page=${params.page ?? 1}&per_page=${params.perPage ?? 50}` : '';"
, " return this.fetch('/widgets' + q).then(r => r.json());"
, " }"
, ""
, " async getInteractionEvents(params?: { widgetId?: string; eventType?: EventType }) {"
, " const qs = new URLSearchParams();"
, " if (params?.widgetId) qs.set('widgetId', params.widgetId);"
, " if (params?.eventType) qs.set('eventType', params.eventType);"
, " return this.fetch('/interaction-events?' + qs).then(r => r.json());"
, " }"
, ""
, " async submitInteractionEvent(body: { widgetId: string; eventType: EventType; viewContext?: string; metadata?: object }) {"
, " return this.fetch('/interaction-events', 'POST', body).then(r => r.json());"
, " }"
, ""
, " async submitAnnotation(body: { widgetId: string; category: AnnotationCategory; body: string }) {"
, " return this.fetch('/annotations', 'POST', body).then(r => r.json());"
, " }"
, "}"
]
generatePySdk :: [WidgetTypeRegistry] -> [EventTypeRegistry] -> [AnnotationCategoryRegistry] -> Text
generatePySdk wts ets acs = T.unlines
[ "# 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, Any"
, "import urllib.request, urllib.parse, json"
, ""
, "class WidgetType(str, Enum):"
] <> T.unlines (map (\r -> " " <> toUpperSnake r.name <> " = \"" <> r.name <> "\"") wts)
<> "\nclass EventType(str, Enum):\n"
<> T.unlines (map (\r -> " " <> toUpperSnake r.name <> " = \"" <> r.name <> "\"") ets)
<> "\nclass AnnotationCategory(str, Enum):\n"
<> T.unlines (map (\r -> " " <> toUpperSnake r.name <> " = \"" <> r.name <> "\"") acs)
<> "\n"
<> pyClientClass
pyClientClass :: Text
pyClientClass = T.unlines
[ "class IhfClient:"
, " def __init__(self, base_url: str, bearer_token: str):"
, " self.base_url = base_url.rstrip('/')"
, " self.token = bearer_token"
, ""
, " def _request(self, path: str, method: str = 'GET', body: Optional[dict] = None) -> dict:"
, " url = self.base_url + path"
, " data = json.dumps(body).encode('utf-8') if body else None"
, " req = urllib.request.Request(url, data=data, method=method,"
, " headers={'Authorization': 'Bearer ' + self.token, 'Content-Type': 'application/json'})"
, " with urllib.request.urlopen(req) as resp:"
, " return json.loads(resp.read())"
, ""
, " def get_widgets(self, page: int = 1, per_page: int = 50) -> dict:"
, " return self._request(f'/widgets?page={page}&per_page={per_page}')"
, ""
, " def get_interaction_events(self, widget_id: Optional[str] = None, event_type: Optional[EventType] = None) -> dict:"
, " qs = urllib.parse.urlencode({k: v for k, v in {'widgetId': widget_id, 'eventType': event_type and str(event_type)}.items() if v})"
, " return self._request('/interaction-events' + ('?' + qs if qs else ''))"
, ""
, " def submit_interaction_event(self, widget_id: str, event_type: EventType, view_context: Optional[str] = None, metadata: Optional[dict] = None) -> dict:"
, " body: dict = {'widgetId': widget_id, 'eventType': str(event_type)}"
, " if view_context: body['viewContext'] = view_context"
, " if metadata: body['metadata'] = metadata"
, " return self._request('/interaction-events', 'POST', body)"
, ""
, " def submit_annotation(self, widget_id: str, category: AnnotationCategory, body: str) -> dict:"
, " return self._request('/annotations', 'POST', {'widgetId': widget_id, 'category': str(category), 'body': body})"
]
sdkIndexHtml :: LBS.ByteString
sdkIndexHtml = LBS.fromStrict $ TE.encodeUtf8 $ T.unlines
[ "<!DOCTYPE html><html lang='en'><head><meta charset='UTF-8'/>"
, "<title>IHF API v2 — SDKs</title>"
, "<link rel='stylesheet' href='/app.css'/></head><body class='bg-gray-50 p-8'>"
, "<h1 class='text-2xl font-bold mb-4'>IHF Consumer SDKs</h1>"
, "<p class='mb-6 text-gray-600'>Both SDKs are generated live from the type registries. Download and import directly.</p>"
, "<div class='space-y-4'>"
, "<div class='bg-white border rounded p-4'>"
, "<h2 class='font-semibold'>TypeScript / Node.js</h2>"
, "<p class='text-sm text-gray-500 mb-2'>ES2020 module. Typed enums for all widget types, event types, annotation categories.</p>"
, "<a href='/api/v2/sdk/ihf-client.ts' class='text-indigo-600 text-sm'>Download ihf-client.ts</a>"
, "</div>"
, "<div class='bg-white border rounded p-4'>"
, "<h2 class='font-semibold'>Python</h2>"
, "<p class='text-sm text-gray-500 mb-2'>stdlib-only (no third-party deps). str-Enum classes for all registered types.</p>"
, "<a href='/api/v2/sdk/ihf-client.py' class='text-indigo-600 text-sm'>Download ihf-client.py</a>"
, "</div>"
, "</div>"
, "<p class='mt-6 text-sm text-gray-400'>See <a href='/api/v2/docs' class='text-indigo-500'>API documentation</a> for full endpoint reference.</p>"
, "</body></html>"
]

View File

@@ -0,0 +1,128 @@
module Web.Controller.Api.V2.Token where
-- POST /api/v2/token — OAuth 2.0 client credentials grant
-- Returns a short-lived opaque access token stored in api_keys.
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
import Data.Aeson (object, (.=))
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Crypto.Hash.SHA256 as SHA256
import qualified Data.ByteString.Base16 as Base16
import qualified Data.ByteString.Random as Random
import Data.Time (addUTCTime)
import Network.Wai (requestMethod)
import Web.Controller.Api.V2.Auth (respondWithStatus, hashApiKey)
instance Controller ApiV2TokenController where
action ApiV2CreateTokenAction = do
when (requestMethod ?request /= "POST") do
respondWithStatus 405 $ object ["error" .= ("Method not allowed" :: Text)]
grantType <- paramOrNothing @Text "grant_type"
clientId <- paramOrNothing @Text "client_id"
clientSecret <- paramOrNothing @Text "client_secret"
mScope <- paramOrNothing @Text "scope"
-- grant_type must be client_credentials
case grantType of
Just "client_credentials" -> pure ()
Just _ -> respondWithStatus 400 $ object
[ "error" .= ("unsupported_grant_type" :: Text) ]
Nothing -> respondWithStatus 400 $ object
[ "error" .= ("invalid_request" :: Text)
, "error_description" .= ("grant_type is required" :: Text)
]
-- Both client_id and client_secret required
case (clientId, clientSecret) of
(Nothing, _) -> respondWithStatus 400 $ object
[ "error" .= ("invalid_request" :: Text)
, "error_description" .= ("client_id is required" :: Text)
]
(_, Nothing) -> respondWithStatus 400 $ object
[ "error" .= ("invalid_request" :: Text)
, "error_description" .= ("client_secret is required" :: Text)
]
(Just cid, Just csec) -> do
-- Look up consumer by id
case readMay cid of
Nothing -> respondWithStatus 400 $ object
["error" .= ("invalid_client" :: Text)]
Just rawId -> do
let consumerId = Id rawId :: Id ApiConsumer
mConsumer <- fetchOneOrNothing consumerId
case mConsumer of
Nothing -> respondWithStatus 400 $ object
["error" .= ("invalid_client" :: Text)]
Just consumer -> do
unless consumer.isActive $ respondWithStatus 400 $ object
["error" .= ("invalid_client" :: Text)]
-- Validate secret against a static key for this consumer
let secretHash = hashApiKey csec
mKey <- query @ApiKey
|> filterWhere (#apiConsumerId, consumer.id)
|> filterWhere (#keyHash, secretHash)
|> filterWhere (#tokenType, "static")
|> fetchOneOrNothing
case mKey of
Nothing -> respondWithStatus 400 $ object
["error" .= ("invalid_client" :: Text)]
Just _ -> do
-- Validate requested scopes
let scopes = maybe [] (T.splitOn " ") mScope
validatedScopes <- validateScopes consumer scopes
case validatedScopes of
Left errCode -> respondWithStatus 400 $ object
["error" .= errCode]
Right scopeStr -> do
-- Issue token
rawToken <- liftIO $ Random.random 32
let tokenText = TE.decodeUtf8 (Base16.encode rawToken)
let tokenHash = hashApiKey tokenText
let prefix = T.take 8 tokenText
now <- getCurrentTime
let expiresAt = addUTCTime 3600 now
_key <- newRecord @ApiKey
|> set #apiConsumerId consumer.id
|> set #keyPrefix prefix
|> set #keyHash tokenHash
|> set #scopes scopeStr
|> set #tokenType "oauth"
|> set #expiresAt (Just expiresAt)
|> createRecord
renderJson $ object
[ "access_token" .= tokenText
, "token_type" .= ("Bearer" :: Text)
, "expires_in" .= (3600 :: Int)
, "scope" .= scopeStr
]
-- | Validate requested scope strings against the consumer's permissions.
-- hub:{slug}:write requires an active manifest for that hub.
validateScopes :: (?modelContext :: ModelContext) => ApiConsumer -> [Text] -> IO (Either Text Text)
validateScopes consumer scopes = do
results <- mapM (validateScope consumer) scopes
case lefts results of
(e:_) -> pure (Left e)
[] -> pure (Right (T.intercalate " " scopes))
validateScope :: (?modelContext :: ModelContext) => ApiConsumer -> Text -> IO (Either Text Text)
validateScope _consumer scope
| scope == "framework:read" = pure (Right scope)
| "hub:" `T.isPrefixOf` scope && ":read" `T.isSuffixOf` scope = pure (Right scope)
| "hub:" `T.isPrefixOf` scope && ":write" `T.isSuffixOf` scope =
-- Write scope requires an active manifest
case _consumer.hubCapabilityManifestId of
Nothing -> pure (Left "invalid_scope")
Just manifestId -> do
manifest <- fetch manifestId
if manifest.status == "active"
then pure (Right scope)
else pure (Left "invalid_scope")
| otherwise = pure (Left "invalid_scope")

View File

@@ -0,0 +1,41 @@
module Web.Controller.Api.V2.Widgets where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
import Data.Aeson (object, (.=), ToJSON, toJSON)
import Web.Controller.Api.V2.Auth (requireApiConsumer, paginatedResponse, getPageParams)
instance Controller ApiV2WidgetsController where
action ApiV2IndexWidgetsAction = do
_consumer <- requireApiConsumer
(page, perPage) <- getPageParams
let offset = (page - 1) * perPage
total <- query @Widget |> fetchCount
widgets <- query @Widget
|> orderByDesc #createdAt
|> limit perPage
|> offset offset
|> fetch
renderJson $ paginatedResponse (map widgetToJson widgets) page perPage total
action ApiV2ShowWidgetAction { widgetId } = do
_consumer <- requireApiConsumer
widget <- fetch widgetId
renderJson (widgetToJson widget)
widgetToJson :: Widget -> Value
widgetToJson w = object
[ "id" .= w.id
, "hubId" .= w.hubId
, "name" .= w.name
, "widgetType" .= w.widgetType
, "capabilityRef" .= w.capabilityRef
, "viewContext" .= w.viewContext
, "policyScope" .= w.policyScope
, "status" .= w.status
, "version" .= w.version
, "createdAt" .= w.createdAt
]

View File

@@ -0,0 +1,88 @@
module Web.Controller.ApiConsumers where
import Web.Types
import Web.View.ApiConsumers.Index
import Web.View.ApiConsumers.Show
import Web.View.ApiConsumers.New
import Web.View.ApiConsumers.Edit
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
instance Controller ApiConsumersController where
beforeAction = ensureIsUser
action ApiConsumersAction = autoRefresh do
consumers <- query @ApiConsumer
|> orderByDesc #createdAt
|> fetch
render IndexView { consumers }
action ShowApiConsumerAction { apiConsumerId } = do
consumer <- fetch apiConsumerId
apiKeys <- query @ApiKey
|> filterWhere (#apiConsumerId, consumer.id)
|> orderByDesc #createdAt
|> fetch
webhooks <- query @WebhookSubscription
|> filterWhere (#apiConsumerId, consumer.id)
|> orderByAsc #eventType
|> fetch
mManifest <- case consumer.hubCapabilityManifestId of
Nothing -> pure Nothing
Just mId -> Just <$> fetch mId
render ShowView { consumer, apiKeys, webhooks, mManifest }
action NewApiConsumerAction = do
let consumer = newRecord @ApiConsumer
manifests <- query @HubCapabilityManifest
|> filterWhere (#status, "active")
|> orderByAsc #createdAt
|> fetch
render NewView { consumer, manifests }
action CreateApiConsumerAction = do
let consumer = newRecord @ApiConsumer
consumer
|> fill @["name", "description", "rateLimitPerMinute", "quotaPerDay"]
|> ifValid \case
Left consumerWithErrors -> do
manifests <- query @HubCapabilityManifest
|> filterWhere (#status, "active")
|> fetch
render NewView { consumer = consumerWithErrors, manifests }
Right validConsumer -> do
mManifestId <- paramOrNothing @(Id HubCapabilityManifest) "hubCapabilityManifestId"
validConsumer
|> set #hubCapabilityManifestId mManifestId
|> createRecord
redirectTo ApiConsumersAction
action EditApiConsumerAction { apiConsumerId } = do
consumer <- fetch apiConsumerId
manifests <- query @HubCapabilityManifest
|> filterWhere (#status, "active")
|> fetch
render EditView { consumer, manifests }
action UpdateApiConsumerAction { apiConsumerId } = do
consumer <- fetch apiConsumerId
consumer
|> fill @["name", "description", "rateLimitPerMinute", "quotaPerDay"]
|> ifValid \case
Left consumerWithErrors -> do
manifests <- query @HubCapabilityManifest
|> filterWhere (#status, "active")
|> fetch
render EditView { consumer = consumerWithErrors, manifests }
Right validConsumer -> do
mManifestId <- paramOrNothing @(Id HubCapabilityManifest) "hubCapabilityManifestId"
validConsumer
|> set #hubCapabilityManifestId mManifestId
|> updateRecord
redirectTo (ShowApiConsumerAction apiConsumerId)
action DeactivateApiConsumerAction { apiConsumerId } = do
consumer <- fetch apiConsumerId
consumer |> set #isActive False |> updateRecord
redirectTo ApiConsumersAction

View File

@@ -0,0 +1,47 @@
module Web.Controller.ApiDashboard where
import Web.Types
import Web.View.ApiDashboard.Show
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
import Database.PostgreSQL.Simple (Only(..))
instance Controller ApiDashboardController where
beforeAction = ensureIsUser
action ShowApiDashboardAction = autoRefresh do
consumers <- query @ApiConsumer
|> orderByAsc #name
|> fetch
stats <- mapM fetchStats consumers
render ShowView { stats }
-- | Aggregate per-consumer stats from api_request_log (last 24 hours).
fetchStats :: (?modelContext :: ModelContext) => ApiConsumer -> IO ConsumerStats
fetchStats consumer = do
rows <- sqlQuery
"SELECT COUNT(*), \
\ COUNT(*) FILTER (WHERE status_code >= 400), \
\ MAX(requested_at) \
\FROM api_request_log \
\WHERE api_consumer_id = ? \
\ AND requested_at >= NOW() - INTERVAL '24 hours'"
(Only consumer.id)
case rows of
[(total, errs, lastTs)] ->
let errRate = if (total :: Int) == 0
then 0.0
else fromIntegral (errs :: Int) / fromIntegral total
in pure ConsumerStats
{ consumer
, requests24h = total
, errorRate = errRate
, lastSeen = lastTs
}
_ -> pure ConsumerStats
{ consumer
, requests24h = 0
, errorRate = 0.0
, lastSeen = Nothing
}

53
Web/Controller/ApiKeys.hs Normal file
View File

@@ -0,0 +1,53 @@
module Web.Controller.ApiKeys where
import Web.Types
import Web.View.ApiKeys.New
import Web.View.ApiKeys.Created
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
import qualified Data.Text.Encoding as TE
import qualified Crypto.Hash.SHA256 as SHA256
import qualified Data.ByteString.Base16 as Base16
import qualified Data.ByteString.Random as Random
instance Controller ApiKeysController where
beforeAction = ensureIsUser
action ApiKeysAction { apiConsumerId } = do
-- Redirect to consumer show page which displays keys
redirectTo (ShowApiConsumerAction apiConsumerId)
action NewApiKeyAction { apiConsumerId } = do
consumer <- fetch apiConsumerId
let apiKey = newRecord @ApiKey
render NewView { apiKey, consumer }
action CreateApiKeyAction = do
apiConsumerId <- param @(Id ApiConsumer) "apiConsumerId"
consumer <- fetch apiConsumerId
scopes <- fromMaybe "" <$> paramOrNothing @Text "scopes"
-- Generate a random 32-byte key, encode as hex (64 chars)
rawBytes <- liftIO $ Random.random 32
let fullKey = TE.decodeUtf8 (Base16.encode rawBytes)
let prefix = T.take 8 fullKey
let keyHash = TE.decodeUtf8 $ Base16.encode $ SHA256.hash (TE.encodeUtf8 fullKey)
_key <- newRecord @ApiKey
|> set #apiConsumerId consumer.id
|> set #keyPrefix prefix
|> set #keyHash keyHash
|> set #scopes scopes
|> set #tokenType "static"
|> createRecord
-- Show full key once; never again
render CreatedView { consumer, fullKey }
action RevokeApiKeyAction { apiKeyId } = do
apiKey <- fetch apiKeyId
now <- getCurrentTime
apiKey |> set #revokedAt (Just now) |> updateRecord
consumer <- fetch apiKey.apiConsumerId
redirectTo (ShowApiConsumerAction consumer.id)

View File

@@ -14,6 +14,9 @@ import Data.Aeson (decode, Value(..), Array)
import Data.Aeson.Lens (key, _String)
import Control.Lens ((^?))
import Data.ByteString.Lazy (fromStrict)
import Web.Job.WebhookDeliveryJob (dispatchWebhooks)
import Control.Concurrent (forkIO)
import Data.Aeson ((.=), object)
import Data.Text.Encoding (encodeUtf8)
import Data.HashMap.Strict (HashMap)
import qualified Data.HashMap.Strict as HashMap
@@ -95,6 +98,15 @@ instance Controller RequirementCandidatesController where
Left candidate -> render NewView { candidate, widgets, threads }
Right candidate -> do
created <- createRecord candidate
-- Dispatch webhooks fire-and-forget
let webhookPayload = object
[ "event" .= ("requirement_candidate.created" :: Text)
, "resourceId" .= created.id
, "title" .= created.title
, "category" .= created.category
]
liftIO $ void $ forkIO $
dispatchWebhooks "requirement_candidate.created" webhookPayload
setSuccessMessage "Requirement candidate created"
redirectTo ShowRequirementCandidateAction { requirementCandidateId = created.id }

View File

@@ -0,0 +1,67 @@
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)