diff --git a/Test/Main.hs b/Test/Main.hs index 836c667..20ee875 100644 --- a/Test/Main.hs +++ b/Test/Main.hs @@ -3,6 +3,11 @@ module Main where import Test.Hspec import IHP.Prelude 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 = hspec do @@ -10,4 +15,33 @@ main = hspec do it "should pass" do 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 diff --git a/Web/Controller/Api/V2/InteractionEvents.hs b/Web/Controller/Api/V2/InteractionEvents.hs index bb46bea..fe1aff7 100644 --- a/Web/Controller/Api/V2/InteractionEvents.hs +++ b/Web/Controller/Api/V2/InteractionEvents.hs @@ -4,8 +4,8 @@ import Web.Types import Generated.Types import IHP.Prelude import IHP.ControllerPrelude -import Data.Aeson (object, (.=)) -import qualified Data.Text as T +import Data.Aeson (Value(..), object, (.=)) +import IHP.ControllerSupport (ControllerContext, getHeader, requestBodyJSON) import Web.Controller.Api.V2.Auth ( requireApiConsumer, paginatedResponse, getPageParams , respondWithStatus ) @@ -13,8 +13,13 @@ import Application.Helper.TypeRegistry (validateEventType) import Web.Job.WebhookDeliveryJob (dispatchWebhooks) import Control.Concurrent (forkIO) 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.Aeson as A +import qualified Data.Vector as V instance Controller ApiV2InteractionEventsController where @@ -47,6 +52,7 @@ instance Controller ApiV2InteractionEventsController where let widgetIdText = paramOrNothing @Text "widgetId" eventType = paramOrNothing @Text "eventType" viewContext = paramOrNothing @Text "viewContext" + metadata = metadataFromRequest let missing = catMaybes [ if isNothing widgetIdText then Just ("widgetId" :: Text) else Nothing @@ -76,9 +82,7 @@ instance Controller ApiV2InteractionEventsController where 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 + unless (manifestAllowsEvent evType manifest.declaredEventTypes) do respondWithStatus 422 $ object [ "error" .= ("Event type not declared in hub manifest" :: Text) , "code" .= ("event_type_not_in_manifest" :: Text) @@ -100,6 +104,7 @@ instance Controller ApiV2InteractionEventsController where |> set #eventType evType |> set #actorType "api" |> set #viewContextRef viewContext + |> set #metadata metadata |> createRecord -- Dispatch webhooks fire-and-forget let webhookPayload = object @@ -109,7 +114,7 @@ instance Controller ApiV2InteractionEventsController where , "eventType" .= event.eventType , "occurredAt" .= event.occurredAt ] - liftIO $ void $ forkIO $ dispatchWebhooks "clicked" webhookPayload + liftIO $ void $ forkIO $ dispatchWebhooks evType webhookPayload renderJson (eventToJson event) eventToJson :: InteractionEvent -> Value @@ -123,3 +128,33 @@ eventToJson e = object , "metadata" .= e.metadata , "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)) diff --git a/workplans/IHUB-WP-0019-vsm-hub-bootstrap-api.md b/workplans/IHUB-WP-0019-vsm-hub-bootstrap-api.md index ba50225..d65f0ea 100644 --- a/workplans/IHUB-WP-0019-vsm-hub-bootstrap-api.md +++ b/workplans/IHUB-WP-0019-vsm-hub-bootstrap-api.md @@ -177,7 +177,7 @@ command while preserving the one-time secret display invariant. ```task id: IHUB-WP-0019-T05 -status: todo +status: done priority: high 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 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