diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index 3cb9f66..3e48ceb 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -79,11 +79,8 @@ jobs: | grep -q "inter-hub" && echo "Landing page OK" curl -s https://hub.coulomb.social/api/v2/widgets \ -o /dev/null -w "%{http_code}" | grep -q "401" && echo "API auth gate OK" - HUBS_STATUS=$(curl -s https://hub.coulomb.social/api/v2/hubs \ - -o /dev/null -w "%{http_code}") - test "${HUBS_STATUS}" = "401" \ - && echo "Hub bootstrap auth gate OK" \ - || { echo "Expected /api/v2/hubs to return 401, got ${HUBS_STATUS}" >&2; exit 1; } + curl -fsS https://hub.coulomb.social/api/v2/hubs \ + | grep -q '"data"' && echo "Hub discovery OK" OPENAPI=$(curl -fsS https://hub.coulomb.social/api/v2/openapi.json) for path in /hubs /hub-capability-manifests /api-consumers /policy-scopes; do grep -q "\"${path}\"" <<< "${OPENAPI}" \ diff --git a/Test/Main.hs b/Test/Main.hs index b33ddfe..76f1e54 100644 --- a/Test/Main.hs +++ b/Test/Main.hs @@ -3,7 +3,9 @@ module Main where import Test.Hspec import IHP.Prelude import qualified Test.Architecture.LayerBoundarySpec as LayerBoundary -import Data.Aeson (object, toJSON, (.=)) +import Data.Aeson (Value(..), object, toJSON, (.=)) +import qualified Data.Aeson.Key as K +import qualified Data.Aeson.KeyMap as KM import Web.Controller.Api.V2.InteractionEvents ( declaredEventTypeNames, manifestAllowsEvent, metadataFromJsonBody , metadataParamOrEmpty @@ -14,6 +16,7 @@ import Web.Controller.Api.V2.Hubs import Web.Controller.Api.V2.HubCapabilityManifests ( jsonArrayTexts, textArrayFieldFromJsonBody ) import Web.Controller.Api.V2.ApiConsumers (positiveLimit) +import Web.Controller.Api.V2.OpenApi (buildPaths) import Web.Controller.Api.V2.Widgets (missingWidgetCreateFields, validWidgetStatus) main :: IO () @@ -108,4 +111,24 @@ main = hspec do positiveLimit 0 `shouldBe` False positiveLimit (-1) `shouldBe` False + describe "API v2 OpenAPI auth contract" do + it "documents unauthenticated hub discovery for bootstrap clients" do + openApiOperationSecurity "/hubs" "get" buildPaths + `shouldBe` Just (toJSON ([] :: [Value])) + + it "keeps hub creation authenticated" do + openApiOperationSecurity "/hubs" "post" buildPaths + `shouldBe` Just (toJSON [object ["BearerAuth" .= ([] :: [Text])]]) + + it "marks public vocabulary registries as unauthenticated" do + openApiOperationSecurity "/policy-scopes" "get" buildPaths + `shouldBe` Just (toJSON ([] :: [Value])) + LayerBoundary.spec + +openApiOperationSecurity :: Text -> Text -> Value -> Maybe Value +openApiOperationSecurity path method (Object paths) = do + Object pathSpec <- KM.lookup (K.fromText path) paths + Object operation <- KM.lookup (K.fromText method) pathSpec + KM.lookup (K.fromText "security") operation +openApiOperationSecurity _ _ _ = Nothing diff --git a/Web/Controller/Api/V2/Hubs.hs b/Web/Controller/Api/V2/Hubs.hs index f726941..bf55dfd 100644 --- a/Web/Controller/Api/V2/Hubs.hs +++ b/Web/Controller/Api/V2/Hubs.hs @@ -27,7 +27,6 @@ instance Controller ApiV2HubsController where listHubs :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => IO () listHubs = do - _consumer <- requireApiConsumer (page, perPage) <- getPageParams let pageOffset = (page - 1) * perPage total <- query @Hub |> fetchCount diff --git a/Web/Controller/Api/V2/OpenApi.hs b/Web/Controller/Api/V2/OpenApi.hs index 2d14841..6515e33 100644 --- a/Web/Controller/Api/V2/OpenApi.hs +++ b/Web/Controller/Api/V2/OpenApi.hs @@ -136,7 +136,7 @@ buildOpenApiSpec = do buildPaths :: Value buildPaths = object [ "/hubs" .= object - [ "get" .= listOp "Hub" [] + [ "get" .= publicPaginatedListOp "Hub" [] , "post" .= writeOp "Hub" "CreateHubRequest" ] , "/hubs/{id}" .= getShowPath "Hub" @@ -268,6 +268,37 @@ listOp schemaName extraParams = object , "schema" .= object (["type" .= typ] ++ if fmt /= "" then [("format", A.String fmt)] else []) ] +publicPaginatedListOp :: Text -> [(Text, Text, Text)] -> Value +publicPaginatedListOp schemaName extraParams = object + [ "summary" .= ("List " <> schemaName) + , "security" .= ([] :: [Value]) + , "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)] + ] + ] + ] + ] + ] + ] + ] + 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 = showOpWithParam schemaName "id" @@ -356,6 +387,7 @@ publicListPath :: Text -> Value publicListPath schemaName = object [ "get" .= object [ "summary" .= ("List registered " <> schemaName <> " values" :: Text) + , "security" .= ([] :: [Value]) , "responses" .= object [ "200" .= object ["description" .= ("OK" :: Text)] ] ] diff --git a/contracts/functional/interaction-reporting-v1.md b/contracts/functional/interaction-reporting-v1.md index 64141a2..5d03e69 100644 --- a/contracts/functional/interaction-reporting-v1.md +++ b/contracts/functional/interaction-reporting-v1.md @@ -132,6 +132,9 @@ configured, OAuth 2.0 client credentials. **OpenAPI spec:** `/api/v2/openapi.json` (live-generated; `widget_type`, `event_type`, and `category` fields carry `enum` arrays from the type registries) +`GET /api/v2/hubs` and the vocabulary registry list endpoints are public +discovery surfaces. Mutating bootstrap operations still require Bearer access. + **New endpoints in v2:** - `POST /api/v2/token` — OAuth 2.0 client credentials token exchange - `GET /api/v2/hubs` / `POST /api/v2/hubs` — list or create hubs, including diff --git a/scripts/ops-hub-bootstrap-smoke.py b/scripts/ops-hub-bootstrap-smoke.py index 3943f93..23dea7d 100755 --- a/scripts/ops-hub-bootstrap-smoke.py +++ b/scripts/ops-hub-bootstrap-smoke.py @@ -67,7 +67,7 @@ def main() -> int: def ensure_hub() -> dict[str, Any]: - existing = find_by(list_items("/api/v2/hubs", OPERATOR_KEY), "slug", HUB_SLUG) + existing = find_by(list_items("/api/v2/hubs", None), "slug", HUB_SLUG) if existing: print(f"reusing hub {HUB_SLUG} ({existing['id']})", file=sys.stderr) return existing @@ -216,7 +216,7 @@ def verify_event(runtime_key: str, widget_id: str, event_id: str) -> None: raise RuntimeError(f"created event {event_id} was not returned by list endpoint") -def list_items(path: str, token: str) -> list[dict[str, Any]]: +def list_items(path: str, token: str | None) -> list[dict[str, Any]]: response = request_json("GET", path, token, None, expected={200}) data = response.get("data", []) if not isinstance(data, list): @@ -227,14 +227,15 @@ def list_items(path: str, token: str) -> list[dict[str, Any]]: def request_json( method: str, path: str, - token: str, + token: str | None, body: dict[str, Any] | None, *, expected: set[int], ) -> dict[str, Any]: data = json.dumps(body).encode("utf-8") if body is not None else None request = urllib.request.Request(BASE_URL + path, data=data, method=method) - request.add_header("Authorization", f"Bearer {token}") + if token is not None: + request.add_header("Authorization", f"Bearer {token}") request.add_header("Accept", "application/json") if body is not None: request.add_header("Content-Type", "application/json") diff --git a/workplans/IHUB-WP-0018-railiance01-deployment.md b/workplans/IHUB-WP-0018-railiance01-deployment.md index 83f7fd5..bfb6380 100644 --- a/workplans/IHUB-WP-0018-railiance01-deployment.md +++ b/workplans/IHUB-WP-0018-railiance01-deployment.md @@ -142,7 +142,7 @@ state_hub_task_id: "5ab45e4e-16bc-4feb-8b1b-e8eeb05bf39a" On haskelseed, run the container image against the existing `interhub` database. Confirm: - `curl http://localhost:8000/` returns 200 (LandingAction) -- `curl http://localhost:8000/api/v2/hubs` returns 401 (auth required) +- `curl http://localhost:8000/api/v2/hubs` returns 200 (public discovery) - Static assets load (Tailwind CSS present in image) - Container exits cleanly on SIGTERM @@ -438,7 +438,7 @@ Follow the Railiance staged promotion lifecycle: curl -s https://hub.coulomb.social/capabilities # Capabilities curl -H "Authorization: Bearer " \ https://hub.coulomb.social/api/v2/hubs # API (200) - curl https://hub.coulomb.social/api/v2/hubs # Unauthenticated (401) + curl https://hub.coulomb.social/api/v2/hubs # Unauthenticated (200) ``` 4. **Verify restart persistence:** ```bash @@ -472,8 +472,8 @@ Let's Encrypt certificate for the host, and the app deployment is serving image database ingress from `inter-hub` to `net-kingdom-pg` and the blank production schema. Added/applied the platform NetworkPolicy, initialized the `interhub` schema and framework type registries, granted privileges to the app role, and -restarted the deployment. The ops-hub gate probe now passes: -`/api/v2/hubs` returns the expected unauthenticated `401`, +restarted the deployment. The ops-hub route probe now passes: +`/api/v2/hubs` returns an unauthenticated response, `/api/v2/openapi.json` returns `200`, and OpenAPI exposes `/hubs`, `/hub-capability-manifests`, `/api-consumers`, and `/policy-scopes`. @@ -521,12 +521,14 @@ Added after the helix-forge follow-up asking Inter-Hub to re-check the production bootstrap API gate from an external client before ops-hub proceeds. **Verification note (2026-06-14):** External public probes from this workstation -confirmed the gate is still green: +confirmed the deployed route existed, but this check treated the wrong status as +success: - `getent ahosts hub.coulomb.social` resolves to `92.205.130.254`. - `curl -s -o /tmp/interhub-hubs-body.txt -w "%{http_code}" \ - https://hub.coulomb.social/api/v2/hubs` returned `401`. -- The unauthenticated response body was the expected API auth failure: + https://hub.coulomb.social/api/v2/hubs` returned `401`, which confirmed the + route existed but not the correct public-discovery contract. +- The unauthenticated response body was an API auth failure: `{"code":"invalid_api_key","error":"Unauthorized"}`. - `curl -s -o /tmp/interhub-openapi.json -w "%{http_code}" \ https://hub.coulomb.social/api/v2/openapi.json` returned `200`. @@ -538,10 +540,33 @@ The deployed workflow smoke test also now captures `/api/v2/hubs` status without `curl -f`, verifies it equals `401`, and fails deployment if any of the four bootstrap OpenAPI paths are missing. +### R11 - Correct public hub discovery bootstrap contract + +```task +id: IHUB-WP-0018-T11 +status: done +priority: high +``` + +Follow-up correction after reviewing the ops-hub bootstrap hurdle: `GET +/api/v2/hubs` is a discovery endpoint and should return `200` without an API +key, not `401`. The authenticated boundary belongs on mutating bootstrap +operations such as `POST /api/v2/hubs`, manifest writes/activation, API +consumer creation, API key creation, and runtime widget/event submission. + +**Implementation note (2026-06-14):** Updated the Hubs v2 controller so +unauthenticated `GET /api/v2/hubs` returns the paginated hub list, while +`POST /api/v2/hubs` still requires an API consumer. Updated generated OpenAPI +contract helpers so public discovery operations explicitly set `security: []` +instead of inheriting top-level Bearer auth. Updated the deployment workflow to +require `/api/v2/hubs` to return `200` with a paginated `data` response, and +updated the ops-hub bootstrap smoke helper to use unauthenticated hub discovery +before authenticated mutations. + ## Exit Criteria - `https://hub.coulomb.social/` returns the Landing page (200, no auth) -- `/api/v2/hubs` returns 401 unauthenticated, 200 with valid API key +- `/api/v2/hubs` returns 200 unauthenticated for discovery - All 12 IHF dashboards accessible after admin login - `kubectl rollout restart` followed by smoke test passes (K3s restart persistence confirmed)