generated from coulomb/repo-seed
Make hub discovery public
All checks were successful
Build and Deploy / build-push-deploy (push) Successful in 3m6s
All checks were successful
Build and Deploy / build-push-deploy (push) Successful in 3m6s
This commit is contained in:
@@ -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}" \
|
||||
|
||||
25
Test/Main.hs
25
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)] ]
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,13 +227,14 @@ 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)
|
||||
if token is not None:
|
||||
request.add_header("Authorization", f"Bearer {token}")
|
||||
request.add_header("Accept", "application/json")
|
||||
if body is not None:
|
||||
|
||||
@@ -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 <key>" \
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user