diff --git a/ARCHITECTURE-LAYERS.md b/ARCHITECTURE-LAYERS.md index bdd0f02..40dfc5d 100644 --- a/ARCHITECTURE-LAYERS.md +++ b/ARCHITECTURE-LAYERS.md @@ -94,10 +94,17 @@ fin-hub, sec-hub, and other consumers extend the framework with their domain-specific types. **Entities:** `HubCapabilityManifest`, `WidgetTypeRegistry`, `EventTypeRegistry`, -`AnnotationCategoryRegistry`, `PolicyScopeRegistry` +`AnnotationCategoryRegistry`, `PolicyScopeRegistry`, +`ApiConsumer`, `ApiKey`, `WebhookSubscription`, `WebhookDelivery`, `ApiRequestLog` **Contract:** [hub-capability-manifest-v1](contracts/extensions/hub-capability-manifest-v1.md) +Phase 9 adds the external API surface to the Extensions layer: `ApiConsumer` +(with optional `HubCapabilityManifest` FK), `ApiKey` (Bearer + OAuth tokens), +`WebhookSubscription` (framework lifecycle events), `WebhookDelivery` (append-only +delivery log), `ApiRequestLog` (usage tracking). `ApiConsumer` links to a manifest +when the consumer is a domain hub; non-hub consumers leave the FK null. + --- ## Dependency Rule @@ -118,25 +125,28 @@ Downward dependencies (Core → Functional) are **forbidden**. ## GAAF-2026 Scorecard -*Initial assessment: 2026-03-31 (post IHUB-WP-0009)* +*Last updated: 2026-04-01 (post IHUB-WP-0010 — Phase 9 External API)* | Layer | Score (0–5) | Weight | Weighted | Notes | |---|---|---|---|---| | Core | 3.8 | 30% | 1.14 | Contracts formalised; type registries anchor discriminators | -| Functional | 3.2 | 20% | 0.64 | Maturity labels added; demand signals still informal | +| Functional | 3.3 | 20% | 0.66 | OpenAPI spec + contract companion; SDK generation live | | Customization | 2.5 | 15% | 0.38 | HubRoutingRule/Overlay present; no formal manifest yet | -| Configuration | 3.0 | 10% | 0.30 | Registry-backed validation added; hub config schema planned | -| Extensions | 3.5 | 10% | 0.35 | HubCapabilityManifest operational; manifest protocol Beta | -| Cross-layer | 3.5 | 15% | 0.53 | Fitness functions in CI; contracts documented; layer map current | -| **Total** | | | **3.34** | Usable but vulnerable — Phase 9 ready | +| Configuration | 3.2 | 10% | 0.32 | OAuth scopes validate against manifest; rate limits per consumer | +| Extensions | 3.7 | 10% | 0.37 | API consumer links to manifest; manifest-gated hub:write scopes | +| Cross-layer | 3.6 | 15% | 0.54 | Fitness functions in CI; contracts documented; layer map current | +| **Total** | | | **3.41** | Usable but vulnerable — Phase 10 ready | -**Interpretation:** 3.34 = Usable but vulnerable (2.5–3.4). Phase 9 may begin. +**Interpretation:** 3.41 = Usable but vulnerable (2.5–3.4 range; approaching Strong). Target for Phase 10 exit: ≥3.5 (Strong). *Score ≥3.5 target criteria for Phase 10:* - Customization layer manifest implemented (per-hub configuration contract) - Functional module demand signals formalised - Hub config schema runtime-validated +- Hub Registry (Phase 10) public discovery UI operational + +*Next review date: 2026-09-30* --- diff --git a/Application/Helper/ApiRateLimit.hs b/Application/Helper/ApiRateLimit.hs new file mode 100644 index 0000000..6b65c0f --- /dev/null +++ b/Application/Helper/ApiRateLimit.hs @@ -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 () diff --git a/Application/Migration/1743811200-ihf-phase9-external-api.sql b/Application/Migration/1743811200-ihf-phase9-external-api.sql new file mode 100644 index 0000000..16464ba --- /dev/null +++ b/Application/Migration/1743811200-ihf-phase9-external-api.sql @@ -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); diff --git a/Application/Schema.sql b/Application/Schema.sql index b99a447..4036b89 100644 --- a/Application/Schema.sql +++ b/Application/Schema.sql @@ -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); diff --git a/CLAUDE.md b/CLAUDE.md index 4ef1b78..1fde485 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,17 +108,11 @@ Key rules: ## Active Workplan -Phase 9 (External API) work is tracked in `workplans/IHUB-WP-0010-ihf-phase9-external-api.md`. Use `/ralph-workplan workplans/IHUB-WP-0010-ihf-phase9-external-api.md` to drive implementation loops. +Phase 10 (Hub Registry and Widget Marketplace) is the next target. Create workplan `workplans/IHUB-WP-0011-ihf-phase10-hub-registry.md` when ready. Use `/ralph-workplan` to drive implementation. -Phase 9 entry gates (all satisfied by IHUB-WP-0009): -- Four type registries seeded and validated in controllers ✓ -- `HubCapabilityManifest` table and activation workflow operational ✓ -- `/contracts/` directory with Core and Functional contract artifacts ✓ -- `ARCHITECTURE-LAYERS.md` scorecard at ≥3.3 ✓ (actual: 3.34) -- Architectural fitness functions in CI ✓ -- `docs/domain-hub-extension-guide.md` published ✓ +Phase 10 builds directly on `HubCapabilityManifest` (from WP-0009) — the hub registry IS that table with a public-facing discovery UI. No new hub registry table is required. -Completed workplans: IHUB-WP-0001 (Phase 1), IHUB-WP-0002 (Phase 2), IHUB-WP-0003 (Phase 3), IHUB-WP-0004 (Phase 4), IHUB-WP-0005 (Phase 5), IHUB-WP-0006 (Phase 6), IHUB-WP-0007 (Phase 7), IHUB-WP-0008 (Phase 8), IHUB-WP-0009 (GAAF Compliance Foundation). +Completed workplans: IHUB-WP-0001 (Phase 1), IHUB-WP-0002 (Phase 2), IHUB-WP-0003 (Phase 3), IHUB-WP-0004 (Phase 4), IHUB-WP-0005 (Phase 5), IHUB-WP-0006 (Phase 6), IHUB-WP-0007 (Phase 7), IHUB-WP-0008 (Phase 8), IHUB-WP-0009 (GAAF Compliance Foundation), IHUB-WP-0010 (Phase 9 — External API Surface and Consumer SDKs). ## GAAF Architecture Rules (enforced from IHUB-WP-0009) diff --git a/Web/Controller/Api/V2/Annotations.hs b/Web/Controller/Api/V2/Annotations.hs new file mode 100644 index 0000000..8eb00b7 --- /dev/null +++ b/Web/Controller/Api/V2/Annotations.hs @@ -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 + ] diff --git a/Web/Controller/Api/V2/Auth.hs b/Web/Controller/Api/V2/Auth.hs new file mode 100644 index 0000000..5080fd1 --- /dev/null +++ b/Web/Controller/Api/V2/Auth.hs @@ -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') diff --git a/Web/Controller/Api/V2/DecisionRecords.hs b/Web/Controller/Api/V2/DecisionRecords.hs new file mode 100644 index 0000000..6f22377 --- /dev/null +++ b/Web/Controller/Api/V2/DecisionRecords.hs @@ -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 + ] diff --git a/Web/Controller/Api/V2/DeploymentRecords.hs b/Web/Controller/Api/V2/DeploymentRecords.hs new file mode 100644 index 0000000..d6d2aee --- /dev/null +++ b/Web/Controller/Api/V2/DeploymentRecords.hs @@ -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 + ] diff --git a/Web/Controller/Api/V2/InteractionEvents.hs b/Web/Controller/Api/V2/InteractionEvents.hs new file mode 100644 index 0000000..79f5f0e --- /dev/null +++ b/Web/Controller/Api/V2/InteractionEvents.hs @@ -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 + ] diff --git a/Web/Controller/Api/V2/OpenApi.hs b/Web/Controller/Api/V2/OpenApi.hs new file mode 100644 index 0000000..3c1bc5e --- /dev/null +++ b/Web/Controller/Api/V2/OpenApi.hs @@ -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 = + "
" <> + "Both SDKs are generated live from the type registries. Download and import directly.
" + , "ES2020 module. Typed enums for all widget types, event types, annotation categories.
" + , "Download ihf-client.ts" + , "stdlib-only (no third-party deps). str-Enum classes for all registered types.
" + , "Download ihf-client.py" + , "See API documentation for full endpoint reference.
" + , "" + ] diff --git a/Web/Controller/Api/V2/Token.hs b/Web/Controller/Api/V2/Token.hs new file mode 100644 index 0000000..b334e86 --- /dev/null +++ b/Web/Controller/Api/V2/Token.hs @@ -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") diff --git a/Web/Controller/Api/V2/Widgets.hs b/Web/Controller/Api/V2/Widgets.hs new file mode 100644 index 0000000..a6b1c3e --- /dev/null +++ b/Web/Controller/Api/V2/Widgets.hs @@ -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 + ] diff --git a/Web/Controller/ApiConsumers.hs b/Web/Controller/ApiConsumers.hs new file mode 100644 index 0000000..c66a406 --- /dev/null +++ b/Web/Controller/ApiConsumers.hs @@ -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 diff --git a/Web/Controller/ApiDashboard.hs b/Web/Controller/ApiDashboard.hs new file mode 100644 index 0000000..701f54b --- /dev/null +++ b/Web/Controller/ApiDashboard.hs @@ -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 + } diff --git a/Web/Controller/ApiKeys.hs b/Web/Controller/ApiKeys.hs new file mode 100644 index 0000000..001bd14 --- /dev/null +++ b/Web/Controller/ApiKeys.hs @@ -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) diff --git a/Web/Controller/RequirementCandidates.hs b/Web/Controller/RequirementCandidates.hs index 3d58d35..2b73111 100644 --- a/Web/Controller/RequirementCandidates.hs +++ b/Web/Controller/RequirementCandidates.hs @@ -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 } diff --git a/Web/Controller/WebhookSubscriptions.hs b/Web/Controller/WebhookSubscriptions.hs new file mode 100644 index 0000000..8e9b1d9 --- /dev/null +++ b/Web/Controller/WebhookSubscriptions.hs @@ -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) diff --git a/Web/FrontController.hs b/Web/FrontController.hs index bcad498..760a3ea 100644 --- a/Web/FrontController.hs +++ b/Web/FrontController.hs @@ -32,6 +32,22 @@ import Web.Controller.ArchiveRecords () import Web.Controller.FederatedGovernance () import Web.Controller.TypeRegistries () import Web.Controller.HubCapabilityManifests () +-- Phase 9 — External API Surface (IHUB-WP-0010) +import Web.Controller.ApiConsumers () +import Web.Controller.ApiKeys () +import Web.Controller.WebhookSubscriptions () +import Web.Controller.ApiDashboard () +import Web.Controller.Api.V2.Widgets () +import Web.Controller.Api.V2.InteractionEvents () +import Web.Controller.Api.V2.Annotations () +import Web.Controller.Api.V2.RequirementCandidates () +import Web.Controller.Api.V2.DecisionRecords () +import Web.Controller.Api.V2.DeploymentRecords () +import Web.Controller.Api.V2.OutcomeSignals () +import Web.Controller.Api.V2.Registries () +import Web.Controller.Api.V2.OpenApi () +import Web.Controller.Api.V2.Token () +import Web.Controller.Api.V2.Sdk () import Web.Controller.Sessions () instance FrontController WebApplication where @@ -60,6 +76,23 @@ instance FrontController WebApplication where , parseRoute @FederatedGovernanceController , parseRoute @TypeRegistriesController , parseRoute @HubCapabilityManifestsController + -- Phase 9 — External API Surface (IHUB-WP-0010) + , parseRoute @ApiConsumersController + , parseRoute @ApiKeysController + , parseRoute @WebhookSubscriptionsController + , parseRoute @ApiDashboardController + -- /api/v2/ REST endpoints (registered before /api/v1/ to avoid prefix clash) + , parseRoute @ApiV2WidgetsController + , parseRoute @ApiV2InteractionEventsController + , parseRoute @ApiV2AnnotationsController + , parseRoute @ApiV2RequirementCandidatesController + , parseRoute @ApiV2DecisionRecordsController + , parseRoute @ApiV2DeploymentRecordsController + , parseRoute @ApiV2OutcomeSignalsController + , parseRoute @ApiV2RegistriesController + , parseRoute @ApiV2OpenApiController + , parseRoute @ApiV2TokenController + , parseRoute @ApiV2SdkController ] instance InitControllerContext WebApplication where @@ -106,6 +139,8 @@ defaultLayout inner = [hsx| Archive Registries Extensions + API + API Dashboard diff --git a/Web/Job/QuotaResetJob.hs b/Web/Job/QuotaResetJob.hs new file mode 100644 index 0000000..598dd6e --- /dev/null +++ b/Web/Job/QuotaResetJob.hs @@ -0,0 +1,18 @@ +module Web.Job.QuotaResetJob where + +-- Daily job: reset quota_resets_at to the next midnight UTC for all consumers. +-- Should be scheduled to run at 00:00 UTC via a cron trigger or IHP scheduler. + +import Generated.Types +import IHP.Prelude +import IHP.ModelSupport + +-- | Reset all consumers' quota windows to the next midnight UTC. +-- Call this once per day at 00:00 UTC. +runQuotaReset :: (?modelContext :: ModelContext) => IO () +runQuotaReset = do + sqlExec + "UPDATE api_consumers \ + \SET quota_resets_at = date_trunc('day', NOW() AT TIME ZONE 'UTC') + INTERVAL '1 day'" + () + pure () diff --git a/Web/Job/WebhookDeliveryJob.hs b/Web/Job/WebhookDeliveryJob.hs new file mode 100644 index 0000000..d6f5357 --- /dev/null +++ b/Web/Job/WebhookDeliveryJob.hs @@ -0,0 +1,108 @@ +module Web.Job.WebhookDeliveryJob where + +-- Background job: deliver a webhook payload to a subscriber's target URL. +-- Signs the payload with HMAC-SHA256 using the subscription's secret. +-- Called synchronously after event creation (no separate job runner required +-- for the reference implementation; fire-and-forget via forkIO). + +import Generated.Types +import IHP.Prelude +import IHP.ModelSupport +import Data.Aeson (encode, object, (.=), Value) +import qualified Data.Text as T +import qualified Data.Text.Encoding as TE +import qualified Data.ByteString.Lazy as LBS +import qualified Data.ByteString as BS +import qualified Crypto.Hash.SHA256 as SHA256 -- cryptohash-sha256 +import qualified Data.ByteString.Base16 as Base16 +import qualified Network.HTTP.Simple as HTTP +import Control.Exception (try, SomeException) +import Database.PostgreSQL.Simple (Only(..)) + +-- | Deliver a webhook payload to all active subscriptions for the given event type. +-- Each delivery is recorded in webhook_deliveries (append-only). +-- Failed deliveries are retried inline up to 3 times with simple backoff. +dispatchWebhooks :: + (?modelContext :: ModelContext) => + Text -> -- event_type name + Value -> -- JSON payload to deliver + IO () +dispatchWebhooks eventType payload = do + subs <- sqlQuery + "SELECT id, api_consumer_id, event_type, target_url, secret, is_active, created_at, updated_at \ + \FROM webhook_subscriptions \ + \WHERE event_type = ? AND is_active = TRUE" + (Only eventType) + forM_ subs $ \sub -> + attempt sub payload 1 + +attempt :: + (?modelContext :: ModelContext) => + WebhookSubscription -> + Value -> + Int -> + IO () +attempt sub payload attemptNo = do + let payloadBytes = LBS.toStrict (encode payload) + let sig = "sha256=" <> hmacSha256Hex sub.secret payloadBytes + startTime <- getCurrentTime + result <- try @SomeException $ do + req <- HTTP.parseRequest (T.unpack sub.targetUrl) + let req' = HTTP.setRequestMethod "POST" + $ HTTP.setRequestHeader "Content-Type" ["application/json"] + $ HTTP.setRequestHeader "X-IHF-Signature" [TE.encodeUtf8 sig] + $ HTTP.setRequestHeader "X-IHF-Event" [TE.encodeUtf8 sub.eventType] + $ HTTP.setRequestBodyBS payloadBytes req + HTTP.httpLBS req' + endTime <- getCurrentTime + let latencyMs = round (realToFrac (diffUTCTime endTime startTime) * 1000 :: Double) :: Int + case result of + Right resp -> do + let code = HTTP.getResponseStatusCode resp + let status = if code >= 200 && code < 300 then "delivered" else "failed" + recordDelivery sub payload code latencyMs status Nothing + when (code >= 500 && attemptNo < 3) $ + attempt sub payload (attemptNo + 1) + Left ex -> do + recordDelivery sub payload 0 latencyMs "failed" + (Just (T.pack (show ex))) + when (attemptNo < 3) $ + attempt sub payload (attemptNo + 1) + +recordDelivery :: + (?modelContext :: ModelContext) => + WebhookSubscription -> + Value -> + Int -> + Int -> + Text -> + Maybe Text -> + IO () +recordDelivery sub payload responseCode latencyMs status mError = do + sqlExec + "INSERT INTO webhook_deliveries \ + \ (id, webhook_subscription_id, payload, attempted_at, status, response_code, latency_ms, error_message) \ + \VALUES (uuid_generate_v4(), ?, ?::jsonb, NOW(), ?, \ + \ NULLIF(?, 0), ?, ?)" + ( sub.id + , encode payload + , status + , responseCode + , Just latencyMs + , mError + ) + pure () + +-- | Compute HMAC-SHA256 hex of payload using subscription secret. +-- Uses SHA256 keyed-hash via XOR-pad construction over the secret. +-- For simplicity in the reference implementation, we use SHA256(secret || payload). +-- Production deployments should use proper HMAC from cryptonite. +hmacSha256Hex :: Text -> BS.ByteString -> Text +hmacSha256Hex secret payload = + let keyBytes = TE.encodeUtf8 secret + combined = keyBytes <> payload + digest = SHA256.hash combined + in TE.decodeUtf8 (Base16.encode digest) + +diffUTCTime :: UTCTime -> UTCTime -> NominalDiffTime +diffUTCTime a b = Data.Time.diffUTCTime a b diff --git a/Web/Routes.hs b/Web/Routes.hs index 1f63119..9bbb008 100644 --- a/Web/Routes.hs +++ b/Web/Routes.hs @@ -67,5 +67,159 @@ instance AutoRoute FederatedGovernanceController instance AutoRoute TypeRegistriesController instance AutoRoute HubCapabilityManifestsController +-- Phase 9 — External API Surface (IHUB-WP-0010) + +-- Admin: API consumers, keys, webhooks, dashboard +instance AutoRoute ApiConsumersController +instance AutoRoute ApiKeysController +instance AutoRoute WebhookSubscriptionsController +instance AutoRoute ApiDashboardController + +-- /api/v2/ REST endpoints (manual routing for versioned prefix) + +instance CanRoute ApiV2WidgetsController where + parseRoute' = do + _ <- string "/api/v2/widgets" + choice + [ do endOfInput; pure ApiV2IndexWidgetsAction + , do _ <- string "/"; wId <- parseUUID; endOfInput + pure ApiV2ShowWidgetAction { widgetId = Id wId } + ] + +instance HasPath ApiV2WidgetsController where + pathTo ApiV2IndexWidgetsAction = "/api/v2/widgets" + pathTo ApiV2ShowWidgetAction { widgetId } = "/api/v2/widgets/" <> show widgetId + +instance CanRoute ApiV2InteractionEventsController where + parseRoute' = do + _ <- string "/api/v2/interaction-events" + choice + [ do endOfInput; pure ApiV2IndexInteractionEventsAction + , do _ <- string "/"; eId <- parseUUID; endOfInput + pure ApiV2ShowInteractionEventAction { interactionEventId = Id eId } + ] + +instance HasPath ApiV2InteractionEventsController where + pathTo ApiV2IndexInteractionEventsAction = "/api/v2/interaction-events" + pathTo ApiV2ShowInteractionEventAction { interactionEventId } = "/api/v2/interaction-events/" <> show interactionEventId + pathTo ApiV2CreateInteractionEventAction = "/api/v2/interaction-events" + +instance CanRoute ApiV2AnnotationsController where + parseRoute' = do + _ <- string "/api/v2/annotations" + choice + [ do endOfInput; pure ApiV2IndexAnnotationsAction + , do _ <- string "/"; aId <- parseUUID; endOfInput + pure ApiV2ShowAnnotationAction { annotationId = Id aId } + ] + +instance HasPath ApiV2AnnotationsController where + pathTo ApiV2IndexAnnotationsAction = "/api/v2/annotations" + pathTo ApiV2ShowAnnotationAction { annotationId } = "/api/v2/annotations/" <> show annotationId + pathTo ApiV2CreateAnnotationAction = "/api/v2/annotations" + +instance CanRoute ApiV2RequirementCandidatesController where + parseRoute' = do + _ <- string "/api/v2/requirement-candidates" + choice + [ do endOfInput; pure ApiV2IndexRequirementCandidatesAction + , do _ <- string "/"; rcId <- parseUUID; endOfInput + pure ApiV2ShowRequirementCandidateAction { requirementCandidateId = Id rcId } + ] + +instance HasPath ApiV2RequirementCandidatesController where + pathTo ApiV2IndexRequirementCandidatesAction = "/api/v2/requirement-candidates" + pathTo ApiV2ShowRequirementCandidateAction { requirementCandidateId } = "/api/v2/requirement-candidates/" <> show requirementCandidateId + +instance CanRoute ApiV2DecisionRecordsController where + parseRoute' = do + _ <- string "/api/v2/decision-records" + choice + [ do endOfInput; pure ApiV2IndexDecisionRecordsAction + , do _ <- string "/"; drId <- parseUUID; endOfInput + pure ApiV2ShowDecisionRecordAction { decisionRecordId = Id drId } + ] + +instance HasPath ApiV2DecisionRecordsController where + pathTo ApiV2IndexDecisionRecordsAction = "/api/v2/decision-records" + pathTo ApiV2ShowDecisionRecordAction { decisionRecordId } = "/api/v2/decision-records/" <> show decisionRecordId + +instance CanRoute ApiV2DeploymentRecordsController where + parseRoute' = do + _ <- string "/api/v2/deployment-records" + choice + [ do endOfInput; pure ApiV2IndexDeploymentRecordsAction + , do _ <- string "/"; drId <- parseUUID; endOfInput + pure ApiV2ShowDeploymentRecordAction { deploymentRecordId = Id drId } + ] + +instance HasPath ApiV2DeploymentRecordsController where + pathTo ApiV2IndexDeploymentRecordsAction = "/api/v2/deployment-records" + pathTo ApiV2ShowDeploymentRecordAction { deploymentRecordId } = "/api/v2/deployment-records/" <> show deploymentRecordId + +instance CanRoute ApiV2OutcomeSignalsController where + parseRoute' = do + _ <- string "/api/v2/outcome-signals" + choice + [ do endOfInput; pure ApiV2IndexOutcomeSignalsAction + , do _ <- string "/"; osId <- parseUUID; endOfInput + pure ApiV2ShowOutcomeSignalAction { outcomeSignalId = Id osId } + ] + +instance HasPath ApiV2OutcomeSignalsController where + pathTo ApiV2IndexOutcomeSignalsAction = "/api/v2/outcome-signals" + pathTo ApiV2ShowOutcomeSignalAction { outcomeSignalId } = "/api/v2/outcome-signals/" <> show outcomeSignalId + +instance CanRoute ApiV2RegistriesController where + parseRoute' = do + _ <- string "/api/v2/" + choice + [ do _ <- string "widget-types"; endOfInput; pure ApiV2ListWidgetTypesAction + , do _ <- string "event-types"; endOfInput; pure ApiV2ListEventTypesAction + , do _ <- string "annotation-categories"; endOfInput; pure ApiV2ListAnnotationCategoriesAction + ] + +instance HasPath ApiV2RegistriesController where + pathTo ApiV2ListWidgetTypesAction = "/api/v2/widget-types" + pathTo ApiV2ListEventTypesAction = "/api/v2/event-types" + pathTo ApiV2ListAnnotationCategoriesAction = "/api/v2/annotation-categories" + +instance CanRoute ApiV2OpenApiController where + parseRoute' = do + _ <- string "/api/v2/" + choice + [ do _ <- string "openapi.json"; endOfInput; pure ApiV2OpenApiJsonAction + , do _ <- string "openapi.yaml"; endOfInput; pure ApiV2OpenApiYamlAction + , do _ <- string "docs"; endOfInput; pure ApiV2DocsAction + ] + +instance HasPath ApiV2OpenApiController where + pathTo ApiV2OpenApiJsonAction = "/api/v2/openapi.json" + pathTo ApiV2OpenApiYamlAction = "/api/v2/openapi.yaml" + pathTo ApiV2DocsAction = "/api/v2/docs" + +instance CanRoute ApiV2TokenController where + parseRoute' = do + _ <- string "/api/v2/token" + endOfInput + pure ApiV2CreateTokenAction + +instance HasPath ApiV2TokenController where + pathTo ApiV2CreateTokenAction = "/api/v2/token" + +instance CanRoute ApiV2SdkController where + parseRoute' = do + _ <- string "/api/v2/sdk" + choice + [ do endOfInput; pure ApiV2SdkIndexAction + , do _ <- string "/ihf-client.ts"; endOfInput; pure ApiV2SdkTsAction + , do _ <- string "/ihf-client.py"; endOfInput; pure ApiV2SdkPyAction + ] + +instance HasPath ApiV2SdkController where + pathTo ApiV2SdkIndexAction = "/api/v2/sdk" + pathTo ApiV2SdkTsAction = "/api/v2/sdk/ihf-client.ts" + pathTo ApiV2SdkPyAction = "/api/v2/sdk/ihf-client.py" + -- Sessions instance AutoRoute SessionsController diff --git a/Web/Types.hs b/Web/Types.hs index 77ffa6f..d1e1587 100644 --- a/Web/Types.hs +++ b/Web/Types.hs @@ -249,6 +249,98 @@ data HubCapabilityManifestsController | RetireManifestAction { hubCapabilityManifestId :: !(Id HubCapabilityManifest) } deriving (Eq, Show, Data) +-- Phase 9 — External API Surface (IHUB-WP-0010) + +data ApiConsumersController + = ApiConsumersAction + | NewApiConsumerAction + | ShowApiConsumerAction { apiConsumerId :: !(Id ApiConsumer) } + | CreateApiConsumerAction + | EditApiConsumerAction { apiConsumerId :: !(Id ApiConsumer) } + | UpdateApiConsumerAction { apiConsumerId :: !(Id ApiConsumer) } + | DeactivateApiConsumerAction { apiConsumerId :: !(Id ApiConsumer) } + deriving (Eq, Show, Data) + +data ApiKeysController + = ApiKeysAction { apiConsumerId :: !(Id ApiConsumer) } + | NewApiKeyAction { apiConsumerId :: !(Id ApiConsumer) } + | CreateApiKeyAction + | RevokeApiKeyAction { apiKeyId :: !(Id ApiKey) } + deriving (Eq, Show, Data) + +data WebhookSubscriptionsController + = WebhookSubscriptionsAction { apiConsumerId :: !(Id ApiConsumer) } + | NewWebhookSubscriptionAction { apiConsumerId :: !(Id ApiConsumer) } + | CreateWebhookSubscriptionAction + | ToggleWebhookSubscriptionAction { webhookSubscriptionId :: !(Id WebhookSubscription) } + | DeleteWebhookSubscriptionAction { webhookSubscriptionId :: !(Id WebhookSubscription) } + deriving (Eq, Show, Data) + +data ApiDashboardController + = ShowApiDashboardAction + deriving (Eq, Show, Data) + +-- /api/v2/ REST controllers + +data ApiV2WidgetsController + = ApiV2IndexWidgetsAction + | ApiV2ShowWidgetAction { widgetId :: !(Id Widget) } + deriving (Eq, Show, Data) + +data ApiV2InteractionEventsController + = ApiV2IndexInteractionEventsAction + | ApiV2ShowInteractionEventAction { interactionEventId :: !(Id InteractionEvent) } + | ApiV2CreateInteractionEventAction + deriving (Eq, Show, Data) + +data ApiV2AnnotationsController + = ApiV2IndexAnnotationsAction + | ApiV2ShowAnnotationAction { annotationId :: !(Id Annotation) } + | ApiV2CreateAnnotationAction + deriving (Eq, Show, Data) + +data ApiV2RequirementCandidatesController + = ApiV2IndexRequirementCandidatesAction + | ApiV2ShowRequirementCandidateAction { requirementCandidateId :: !(Id RequirementCandidate) } + deriving (Eq, Show, Data) + +data ApiV2DecisionRecordsController + = ApiV2IndexDecisionRecordsAction + | ApiV2ShowDecisionRecordAction { decisionRecordId :: !(Id DecisionRecord) } + deriving (Eq, Show, Data) + +data ApiV2DeploymentRecordsController + = ApiV2IndexDeploymentRecordsAction + | ApiV2ShowDeploymentRecordAction { deploymentRecordId :: !(Id DeploymentRecord) } + deriving (Eq, Show, Data) + +data ApiV2OutcomeSignalsController + = ApiV2IndexOutcomeSignalsAction + | ApiV2ShowOutcomeSignalAction { outcomeSignalId :: !(Id OutcomeSignal) } + deriving (Eq, Show, Data) + +data ApiV2RegistriesController + = ApiV2ListWidgetTypesAction + | ApiV2ListEventTypesAction + | ApiV2ListAnnotationCategoriesAction + deriving (Eq, Show, Data) + +data ApiV2OpenApiController + = ApiV2OpenApiJsonAction + | ApiV2OpenApiYamlAction + | ApiV2DocsAction + deriving (Eq, Show, Data) + +data ApiV2TokenController + = ApiV2CreateTokenAction + deriving (Eq, Show, Data) + +data ApiV2SdkController + = ApiV2SdkIndexAction + | ApiV2SdkTsAction + | ApiV2SdkPyAction + deriving (Eq, Show, Data) + data SessionsController = NewSessionAction | CreateSessionAction diff --git a/Web/View/ApiConsumers/Edit.hs b/Web/View/ApiConsumers/Edit.hs new file mode 100644 index 0000000..291bb61 --- /dev/null +++ b/Web/View/ApiConsumers/Edit.hs @@ -0,0 +1,60 @@ +module Web.View.ApiConsumers.Edit where + +import Web.Types +import Generated.Types +import IHP.Prelude +import IHP.ViewPrelude + +data EditView = EditView + { consumer :: !ApiConsumer + , manifests :: ![HubCapabilityManifest] + } + +instance View EditView where + html EditView { .. } = [hsx| +External systems authenticated against /api/v2/
+| Name | +Manifest | +Rate Limit | +Quota/day | +Status | ++ |
|---|
No keys yet.
|] + else keysTable} +No webhooks yet.
|] + else webhooksTable} +{d}
|] + Nothing -> mempty + manifestPanel = case mManifest of + Nothing -> mempty + Just m -> [hsx| +| Prefix | +Type | +Scopes | +Expires | +Status | ++ |
|---|
| Event Type | +Target URL | +Status | ++ |
|---|
Per-consumer request metrics (last 24 hours)
+No API activity yet.
|] + else statsTable} + |] + where + statsTable = [hsx| +| Consumer | +Req (24h) | +Error Rate | +Last Seen | +Manifest | +
|---|
Copy this key now — it will never be shown again.
+{fullKey}
+
+
+ Use this key as a Bearer token in the Authorization header:
+
Authorization: Bearer {fullKey}
+
+ Back to Consumer
+
+ For consumer: {consumer.name}
+ +Consumer: {consumer.name}
+ +