fix: honor v2 interaction event contract

This commit is contained in:
2026-05-16 04:32:58 +02:00
parent 301a7b96d0
commit 0a4646bf44
3 changed files with 83 additions and 7 deletions

View File

@@ -3,6 +3,11 @@ module Main where
import Test.Hspec import Test.Hspec
import IHP.Prelude import IHP.Prelude
import qualified Test.Architecture.LayerBoundarySpec as LayerBoundary import qualified Test.Architecture.LayerBoundarySpec as LayerBoundary
import Data.Aeson (object, toJSON, (.=))
import Web.Controller.Api.V2.InteractionEvents
( declaredEventTypeNames, manifestAllowsEvent, metadataFromJsonBody
, metadataParamOrEmpty
)
main :: IO () main :: IO ()
main = hspec do main = hspec do
@@ -10,4 +15,33 @@ main = hspec do
it "should pass" do it "should pass" do
1 + 1 `shouldBe` (2 :: Int) 1 + 1 `shouldBe` (2 :: Int)
describe "API v2 interaction-event manifest validation" do
let opsEventTypes = toJSON
( [ "ops-endpoint-verified"
, "ops-workflow-started"
] :: [Text]
)
it "decodes manifest-declared event types from JSON arrays" do
declaredEventTypeNames opsEventTypes
`shouldBe` ["ops-endpoint-verified", "ops-workflow-started"]
it "allows manifest-declared ops-owned domain events" do
manifestAllowsEvent "ops-endpoint-verified" opsEventTypes
`shouldBe` True
it "rejects events absent from an active manifest declaration" do
manifestAllowsEvent "clicked" opsEventTypes
`shouldBe` False
it "keeps empty declarations unrestricted for legacy manifests" do
manifestAllowsEvent "clicked" (toJSON ([] :: [Text]))
`shouldBe` True
it "preserves submitted metadata values and defaults missing metadata" do
let metadata = object ["source" .= ("ops-hub" :: Text)]
metadataFromJsonBody (object ["metadata" .= metadata]) `shouldBe` Just metadata
metadataParamOrEmpty (Just metadata) `shouldBe` metadata
metadataParamOrEmpty Nothing `shouldBe` object []
LayerBoundary.spec LayerBoundary.spec

View File

@@ -4,8 +4,8 @@ import Web.Types
import Generated.Types import Generated.Types
import IHP.Prelude import IHP.Prelude
import IHP.ControllerPrelude import IHP.ControllerPrelude
import Data.Aeson (object, (.=)) import Data.Aeson (Value(..), object, (.=))
import qualified Data.Text as T import IHP.ControllerSupport (ControllerContext, getHeader, requestBodyJSON)
import Web.Controller.Api.V2.Auth import Web.Controller.Api.V2.Auth
( requireApiConsumer, paginatedResponse, getPageParams ( requireApiConsumer, paginatedResponse, getPageParams
, respondWithStatus ) , respondWithStatus )
@@ -13,8 +13,13 @@ import Application.Helper.TypeRegistry (validateEventType)
import Web.Job.WebhookDeliveryJob (dispatchWebhooks) import Web.Job.WebhookDeliveryJob (dispatchWebhooks)
import Control.Concurrent (forkIO) import Control.Concurrent (forkIO)
import Control.Monad (void) import Control.Monad (void)
import Data.Maybe (fromMaybe, mapMaybe)
import qualified Data.Aeson.KeyMap as KM
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy.Char8 as LBSC
import qualified Data.UUID as UUID import qualified Data.UUID as UUID
import qualified Data.Aeson as A import qualified Data.Aeson as A
import qualified Data.Vector as V
instance Controller ApiV2InteractionEventsController where instance Controller ApiV2InteractionEventsController where
@@ -47,6 +52,7 @@ instance Controller ApiV2InteractionEventsController where
let widgetIdText = paramOrNothing @Text "widgetId" let widgetIdText = paramOrNothing @Text "widgetId"
eventType = paramOrNothing @Text "eventType" eventType = paramOrNothing @Text "eventType"
viewContext = paramOrNothing @Text "viewContext" viewContext = paramOrNothing @Text "viewContext"
metadata = metadataFromRequest
let missing = catMaybes let missing = catMaybes
[ if isNothing widgetIdText then Just ("widgetId" :: Text) else Nothing [ if isNothing widgetIdText then Just ("widgetId" :: Text) else Nothing
@@ -76,9 +82,7 @@ instance Controller ApiV2InteractionEventsController where
forM_ consumer.hubCapabilityManifestId $ \manifestId -> do forM_ consumer.hubCapabilityManifestId $ \manifestId -> do
manifest <- fetch manifestId manifest <- fetch manifestId
when (manifest.status == "active") do when (manifest.status == "active") do
let declared = case manifest.declaredEventTypes of unless (manifestAllowsEvent evType manifest.declaredEventTypes) do
_ -> [] :: [Text] -- JSONB array decoded via aeson
unless (null declared || evType `elem` declared) do
respondWithStatus 422 $ object respondWithStatus 422 $ object
[ "error" .= ("Event type not declared in hub manifest" :: Text) [ "error" .= ("Event type not declared in hub manifest" :: Text)
, "code" .= ("event_type_not_in_manifest" :: Text) , "code" .= ("event_type_not_in_manifest" :: Text)
@@ -100,6 +104,7 @@ instance Controller ApiV2InteractionEventsController where
|> set #eventType evType |> set #eventType evType
|> set #actorType "api" |> set #actorType "api"
|> set #viewContextRef viewContext |> set #viewContextRef viewContext
|> set #metadata metadata
|> createRecord |> createRecord
-- Dispatch webhooks fire-and-forget -- Dispatch webhooks fire-and-forget
let webhookPayload = object let webhookPayload = object
@@ -109,7 +114,7 @@ instance Controller ApiV2InteractionEventsController where
, "eventType" .= event.eventType , "eventType" .= event.eventType
, "occurredAt" .= event.occurredAt , "occurredAt" .= event.occurredAt
] ]
liftIO $ void $ forkIO $ dispatchWebhooks "clicked" webhookPayload liftIO $ void $ forkIO $ dispatchWebhooks evType webhookPayload
renderJson (eventToJson event) renderJson (eventToJson event)
eventToJson :: InteractionEvent -> Value eventToJson :: InteractionEvent -> Value
@@ -123,3 +128,33 @@ eventToJson e = object
, "metadata" .= e.metadata , "metadata" .= e.metadata
, "occurredAt" .= e.occurredAt , "occurredAt" .= e.occurredAt
] ]
declaredEventTypeNames :: A.Value -> [Text]
declaredEventTypeNames (Array values) = mapMaybe extractText (V.toList values)
where
extractText (String value) = Just value
extractText _ = Nothing
declaredEventTypeNames _ = []
manifestAllowsEvent :: Text -> A.Value -> Bool
manifestAllowsEvent eventType declaredEventTypes =
let declared = declaredEventTypeNames declaredEventTypes
in null declared || eventType `elem` declared
metadataParamOrEmpty :: Maybe A.Value -> A.Value
metadataParamOrEmpty = fromMaybe (object [])
metadataFromRequest :: (?context :: ControllerContext) => A.Value
metadataFromRequest =
case getHeader "Content-Type" of
Just contentType | "application/json" `BS.isPrefixOf` contentType ->
metadataParamOrEmpty (metadataFromJsonBody requestBodyJSON)
_ ->
metadataParamOrEmpty (metadataFromText =<< paramOrNothing @Text "metadata")
metadataFromJsonBody :: A.Value -> Maybe A.Value
metadataFromJsonBody (Object body) = KM.lookup "metadata" body
metadataFromJsonBody _ = Nothing
metadataFromText :: Text -> Maybe A.Value
metadataFromText raw = A.decode (LBSC.pack (cs raw))

View File

@@ -177,7 +177,7 @@ command while preserving the one-time secret display invariant.
```task ```task
id: IHUB-WP-0019-T05 id: IHUB-WP-0019-T05
status: todo status: done
priority: high priority: high
state_hub_task_id: "1febfdb6-757b-420a-b4bd-709ce3cd1252" state_hub_task_id: "1febfdb6-757b-420a-b4bd-709ce3cd1252"
``` ```
@@ -193,6 +193,13 @@ Fix the current v2 interaction event create behavior:
Done when: `ops-endpoint-verified` can be submitted with metadata and routed Done when: `ops-endpoint-verified` can be submitted with metadata and routed
as an ops-owned event. as an ops-owned event.
Implementation note (2026-05-16): v2 interaction-event creation now validates
against active manifest-declared event types, persists submitted metadata from
JSON request bodies, dispatches webhooks with the submitted event type, and has
focused Hspec coverage for manifest-declared ops domain events. Local
`git diff --check` passed; `scripts/compile-check` could not run because this
shell does not have `IHP_LIB`/the IHP dev environment loaded.
--- ---
### T06 — Update OpenAPI request schemas and hub quickstart docs ### T06 — Update OpenAPI request schemas and hub quickstart docs