From 19021e7128cbd722bc79de89ee2c6f0855d87104 Mon Sep 17 00:00:00 2001 From: cjimti Date: Tue, 12 May 2026 16:48:29 -0700 Subject: [PATCH] audit,portal: render bodies as utf-8 strings; propagate identity through Try-It and replay dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects surfaced in the live portal: 1. audit_payloads.request_body/response_body are []byte, which Go's default JSON encoder emits as base64. The audit detail panel showed raw base64 instead of the captured request/response JSON. Add a custom MarshalJSON on Payload that emits the body as a utf-8 string when valid; falls back to base64 with a sibling _encoding=base64 flag for non-utf-8 bytes so binary payloads still round-trip. 2. Try-It (and audit replay) dispatch through the local mux via replayTarget.ServeHTTP. The dispatched request carries no Authorization/X-API-Key on the wire (Try-It deliberately strips operator-supplied Authorization/Cookie), so the inbound auth middleware would 401 a request the portal had already accepted. Fix by translating the portal-resolved auth.Identity into an inbound.Identity on the dispatched ctx, and short-circuiting httpmw.Identity when one is already set — except when a real credential is on the wire, so "type X-API-Key into Try-It to test as a different principal" still resolves through the chain. Replay also has to drop credential headers (Authorization, X-API-Key, Cookie) and the api_key query param before re-emission: those values are persisted as the "[redacted]" sentinel and would otherwise look like wire credentials, defeating the bypass and 401'ing through chain validation of the redaction string. Tests: - Payload MarshalJSON: utf-8, binary, empty cases. - httpmw.Identity: pre-set bypass; wire-cred override of pre-set. - portal_audit_replay: end-to-end through real httpmw.Identity with a chain that rejects "[redacted]" — would 401 if the redacted-header filter regressed. Known limitation (deferred): - hasInboundCredential and redactedReplayHeaders hardcode the default api-key header/query names (X-API-Key, api_key). Deployments that customize APIKeysConfig.HeaderName / QueryParamName will silently lose Try-It "test as someone else" semantics under the custom name, and replay will leak the captured redaction sentinel for the custom header. The live api-test-server.plexara.io deployment runs the defaults so this is not a regression for the immediate rollout; TODOs left in code at the two call sites. make verify clean. --- pkg/audit/event.go | 41 +++++++++++- pkg/audit/event_test.go | 62 ++++++++++++++++++ pkg/httpmw/identity.go | 42 ++++++++++++ pkg/httpmw/middleware_test.go | 52 +++++++++++++++ pkg/httpsrv/portal_audit_replay.go | 55 +++++++++++++++- pkg/httpsrv/portal_audit_replay_test.go | 85 +++++++++++++++++++++++++ pkg/httpsrv/portal_tryit.go | 36 ++++++++++- 7 files changed, 368 insertions(+), 5 deletions(-) diff --git a/pkg/audit/event.go b/pkg/audit/event.go index 99bbb1e..274b636 100644 --- a/pkg/audit/event.go +++ b/pkg/audit/event.go @@ -9,9 +9,12 @@ package audit import ( + "encoding/base64" + "encoding/json" "net/http" "strings" "time" + "unicode/utf8" ) // ReplayHeaderName is the header attached to replayed requests so the @@ -59,7 +62,7 @@ type Payload struct { RequestHeaders map[string][]string `json:"request_headers,omitempty"` RequestQuery map[string][]string `json:"request_query,omitempty"` RequestContentType string `json:"request_content_type,omitempty"` - RequestBody []byte `json:"request_body,omitempty"` + RequestBody []byte `json:"-"` RequestSizeBytes int `json:"request_size_bytes,omitempty"` RequestTruncated bool `json:"request_truncated,omitempty"` RequestRemoteAddr string `json:"request_remote_addr,omitempty"` @@ -67,7 +70,7 @@ type Payload struct { // Response side ResponseHeaders map[string][]string `json:"response_headers,omitempty"` ResponseContentType string `json:"response_content_type,omitempty"` - ResponseBody []byte `json:"response_body,omitempty"` + ResponseBody []byte `json:"-"` ResponseSizeBytes int `json:"response_size_bytes,omitempty"` ResponseTruncated bool `json:"response_truncated,omitempty"` @@ -76,6 +79,40 @@ type Payload struct { ReplayedFrom string `json:"replayed_from,omitempty"` } +// MarshalJSON renders Payload so the portal SPA and any human reader see +// request/response bodies as utf-8 strings, not the base64 dump Go's +// default []byte encoder produces. When a body isn't valid utf-8 we fall +// back to base64 and flag it via a sibling `_encoding` field so callers +// can decode unambiguously. +func (p Payload) MarshalJSON() ([]byte, error) { + type alias Payload + out := struct { + alias + RequestBody string `json:"request_body,omitempty"` + RequestBodyEncoding string `json:"request_body_encoding,omitempty"` + ResponseBody string `json:"response_body,omitempty"` + ResponseBodyEncoding string `json:"response_body_encoding,omitempty"` + }{alias: alias(p)} + + if len(p.RequestBody) > 0 { + if utf8.Valid(p.RequestBody) { + out.RequestBody = string(p.RequestBody) + } else { + out.RequestBody = base64.StdEncoding.EncodeToString(p.RequestBody) + out.RequestBodyEncoding = "base64" + } + } + if len(p.ResponseBody) > 0 { + if utf8.Valid(p.ResponseBody) { + out.ResponseBody = string(p.ResponseBody) + } else { + out.ResponseBody = base64.StdEncoding.EncodeToString(p.ResponseBody) + out.ResponseBodyEncoding = "base64" + } + } + return json.Marshal(out) +} + // NewEvent constructs an Event with sensible defaults filled in. func NewEvent(method, path string) *Event { return &Event{ diff --git a/pkg/audit/event_test.go b/pkg/audit/event_test.go index 9684d52..7900f57 100644 --- a/pkg/audit/event_test.go +++ b/pkg/audit/event_test.go @@ -1,10 +1,72 @@ package audit import ( + "encoding/json" "net/http" "testing" ) +func TestPayload_MarshalJSON_UTF8BodiesAsStrings(t *testing.T) { + p := Payload{ + RequestBody: []byte(`{"hello":"world","n":42}`), + ResponseBody: []byte("plain text response"), + } + out, err := json.Marshal(p) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var got map[string]any + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got["request_body"] != `{"hello":"world","n":42}` { + t.Errorf("request_body = %v, want literal JSON string", got["request_body"]) + } + if got["response_body"] != "plain text response" { + t.Errorf("response_body = %v, want plain text", got["response_body"]) + } + if _, present := got["request_body_encoding"]; present { + t.Errorf("request_body_encoding should be absent for utf-8 bodies") + } +} + +func TestPayload_MarshalJSON_BinaryBodyBase64Flagged(t *testing.T) { + p := Payload{ + ResponseBody: []byte{0xff, 0xfe, 0x00, 0x01}, + } + out, err := json.Marshal(p) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var got map[string]any + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got["response_body_encoding"] != "base64" { + t.Errorf("response_body_encoding = %v, want base64", got["response_body_encoding"]) + } + if got["response_body"] == "" { + t.Errorf("response_body empty for binary input") + } +} + +func TestPayload_MarshalJSON_EmptyBodiesOmitted(t *testing.T) { + p := Payload{RequestContentType: "application/json"} + out, err := json.Marshal(p) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var got map[string]any + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + for _, k := range []string{"request_body", "response_body", "request_body_encoding", "response_body_encoding"} { + if _, present := got[k]; present { + t.Errorf("%s should be omitted when body is empty", k) + } + } +} + func TestSanitizeHeaders_Redacts(t *testing.T) { h := http.Header{} h.Set("Authorization", "Bearer secret") diff --git a/pkg/httpmw/identity.go b/pkg/httpmw/identity.go index 38b8d0d..4088a6a 100644 --- a/pkg/httpmw/identity.go +++ b/pkg/httpmw/identity.go @@ -5,10 +5,36 @@ import ( "errors" "log/slog" "net/http" + "strings" "github.com/plexara/api-test/pkg/auth/inbound" ) +// hasInboundCredential reports whether the request carries a credential +// the inbound chain could resolve: an X-API-Key header, an ?api_key= +// query param, or an Authorization: Bearer token. Used to decide +// whether the pre-identity bypass should yield to a wire-level +// credential. +// +// TODO: this hardcodes the default api-key header/query names. If a +// deployment customizes APIKeysConfig.HeaderName / QueryParamName, an +// operator-typed credential using the custom name will silently slip +// past the predicate and the pre-identity bypass will win instead of +// the chain. The api-test-server.plexara.io deployment runs the +// defaults so this is not a live regression; revisit when wiring +// Identity() to know about the configured names (or when the chain +// gains a HasCredential method that authenticators implement). +func hasInboundCredential(r *http.Request) bool { + if r.Header.Get("X-API-Key") != "" { + return true + } + if r.URL != nil && r.URL.Query().Get("api_key") != "" { + return true + } + a := r.Header.Get("Authorization") + return a != "" && strings.HasPrefix(strings.ToLower(a), "bearer ") +} + // Identity returns middleware that runs the inbound auth chain and // either: // - attaches the resolved Identity to the context and proceeds, or @@ -20,9 +46,25 @@ import ( // // The "401 includes WWW-Authenticate" RFC 6750 convention is honored: // requests that came in without a credential get a Bearer challenge. +// +// When an inbound.Identity is already present on the request context AND +// no credential is on the wire, the chain is bypassed. This is the +// Try-It / audit-replay dispatch path: the portal handler has already +// auth'd the operator via the portal session, so re-running the inbound +// chain (which only knows about API keys / bearer tokens on the wire) +// would 401 a request the portal already accepted. If the dispatched +// request DOES carry a credential header (e.g. an operator typed +// X-API-Key into the Try-It headers field to test as a different +// principal), the chain runs and wins — that's the documented way to +// "test as someone else" through Try-It. func Identity(chain *inbound.Chain, logger *slog.Logger) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if pre := inbound.FromContext(r.Context()); pre != nil && !hasInboundCredential(r) { + recordIdentity(r.Context(), pre) + next.ServeHTTP(w, r) + return + } id, err := chain.Authenticate(r.Context(), r) if err != nil { if errors.Is(err, inbound.ErrNoCredential) { diff --git a/pkg/httpmw/middleware_test.go b/pkg/httpmw/middleware_test.go index 948aba6..76e915c 100644 --- a/pkg/httpmw/middleware_test.go +++ b/pkg/httpmw/middleware_test.go @@ -73,6 +73,58 @@ func TestIdentity_401WhenNoCredAndNoAnonymous(t *testing.T) { } } +func TestIdentity_PreSetSkipsChain(t *testing.T) { + // Try-It dispatch path: the portal handler has already authed the + // operator and attached an inbound.Identity to the request context + // before re-entering the mux. The chain — which would otherwise 401 + // because no credential is on the wire — must yield. + chain := inbound.NewChain(false, inbound.NewBearer([]config.FileBearerToken{{Name: "x", Token: "t"}})) + var saw *inbound.Identity + h := Identity(chain, discardLogger())(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + saw = inbound.FromContext(r.Context()) + })) + pre := &inbound.Identity{Subject: "alice", AuthType: "portal", KeyName: "session"} + r := httptest.NewRequest(http.MethodGet, "/", nil) + r = r.WithContext(inbound.WithIdentity(r.Context(), pre)) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusOK { + t.Errorf("status %d want 200", w.Code) + } + if saw == nil || saw.Subject != "alice" || saw.AuthType != "portal" { + t.Errorf("identity = %+v, want pre-set alice/portal", saw) + } +} + +func TestIdentity_WireCredentialOverridesPreSet(t *testing.T) { + // Operator types X-API-Key into the Try-It headers field to test + // "does this key resolve to the right principal?". The chain must + // still run so the resolved Identity reflects the wire credential, + // not the portal session that planted the pre-identity. + store := inbound.NewFileAPIKeyStore([]config.FileAPIKey{{Name: "ci-runner", Key: "secret"}}) + apikey := inbound.NewAPIKey(store, "X-API-Key", "api_key") + chain := inbound.NewChain(false, apikey) + + var saw *inbound.Identity + h := Identity(chain, discardLogger())(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + saw = inbound.FromContext(r.Context()) + })) + + portalPre := &inbound.Identity{Subject: "alice", AuthType: "portal", KeyName: "session"} + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("X-API-Key", "secret") + r = r.WithContext(inbound.WithIdentity(r.Context(), portalPre)) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Errorf("status %d want 200", w.Code) + } + if saw == nil || saw.KeyName != "ci-runner" || saw.AuthType != "apikey" { + t.Errorf("identity = %+v, want wire creds (ci-runner/apikey) not portal pre-set", saw) + } +} + func TestIdentity_AnonymousFallthrough(t *testing.T) { chain := inbound.NewChain(true) var saw *inbound.Identity diff --git a/pkg/httpsrv/portal_audit_replay.go b/pkg/httpsrv/portal_audit_replay.go index 522ddf3..35f35ec 100644 --- a/pkg/httpsrv/portal_audit_replay.go +++ b/pkg/httpsrv/portal_audit_replay.go @@ -11,6 +11,8 @@ import ( "github.com/google/uuid" "github.com/plexara/api-test/pkg/audit" + "github.com/plexara/api-test/pkg/auth" + "github.com/plexara/api-test/pkg/auth/inbound" ) // replayHeaderMarker is the header attached to every replayed request @@ -25,6 +27,35 @@ const replayHeaderMarker = audit.ReplayHeaderName // allocate large amounts of memory at replay time. const replayMaxBodyBytes = 1 << 20 // 1 MiB +// redactedReplayHeaders are credential-carrying header names whose +// captured values are guaranteed to be the "[redacted]" sentinel +// written by audit.SanitizeHeaders. Re-emitting them would put a +// non-empty value on the wire that httpmw.Identity would treat as a +// real credential, defeating the portal-identity bypass. The replay +// path carries identity through context, so dropping them is safe. +// +// TODO: paired with the same gap in pkg/httpmw/identity.go — if a +// deployment customizes APIKeysConfig.HeaderName, the captured +// redaction sentinel under the custom name will leak back into the +// replayed request. Fix when the chain gains a self-describing +// HasCredential surface. +var redactedReplayHeaders = map[string]struct{}{ + "authorization": {}, + "x-api-key": {}, + "cookie": {}, +} + +func isRedactedCredentialHeader(name string) bool { + _, ok := redactedReplayHeaders[strings.ToLower(name)] + return ok +} + +// isRedactedCredentialQuery matches the ?api_key= form the apikey +// authenticator also accepts; audit.SanitizeQuery redacts it identically. +func isRedactedCredentialQuery(name string) bool { + return strings.EqualFold(name, "api_key") +} + // auditReplay re-issues a captured request through the local mux. // Requires the audit Logger to implement PayloadLogger (so we can // reconstruct headers + body) and the composition layer to have wired @@ -103,6 +134,9 @@ func (p *PortalAPI) auditReplay(w http.ResponseWriter, r *http.Request) { if len(payload.RequestQuery) > 0 { values := url.Values{} for k, vs := range payload.RequestQuery { + if isRedactedCredentialQuery(k) { + continue + } for _, v := range vs { values.Add(k, v) } @@ -114,7 +148,17 @@ func (p *PortalAPI) auditReplay(w http.ResponseWriter, r *http.Request) { if len(body) > replayMaxBodyBytes { body = body[:replayMaxBodyBytes] } - replayReq, err := http.NewRequestWithContext(r.Context(), + + // The captured request's Authorization / X-API-Key are persisted as + // "[redacted]", so replaying them verbatim would 401 the call. Carry + // the operator's portal-resolved identity into the replayed request's + // context — same trust model as Try-It dispatch. + replayCtx := r.Context() + if portalID := auth.GetIdentity(replayCtx); portalID != nil { + replayCtx = inbound.WithIdentity(replayCtx, portalIdentityToInbound(portalID)) + } + + replayReq, err := http.NewRequestWithContext(replayCtx, ev.Method, u.String(), bytes.NewReader(body)) if err != nil { writeError(w, http.StatusInternalServerError, fmt.Errorf("construct replay request: %w", err)) @@ -122,13 +166,20 @@ func (p *PortalAPI) auditReplay(w http.ResponseWriter, r *http.Request) { } // Copy captured headers but refuse to replay anything already - // carrying our marker (loop guard). + // carrying our marker (loop guard). Skip credential headers — the + // audit middleware persists their values as "[redacted]", so + // re-emitting them would put a redaction sentinel on the wire that + // httpmw.Identity treats as a real credential and then fails to + // validate. Identity flows through the dispatch context instead. for k, vs := range payload.RequestHeaders { if strings.EqualFold(k, replayHeaderMarker) { writeError(w, http.StatusBadRequest, errors.New("captured request already carries the replay marker — refusing to replay a replay")) return } + if isRedactedCredentialHeader(k) { + continue + } for _, v := range vs { replayReq.Header.Add(k, v) } diff --git a/pkg/httpsrv/portal_audit_replay_test.go b/pkg/httpsrv/portal_audit_replay_test.go index c990e57..c282226 100644 --- a/pkg/httpsrv/portal_audit_replay_test.go +++ b/pkg/httpsrv/portal_audit_replay_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "io" + "log/slog" "net/http" "net/http/httptest" "strings" @@ -14,6 +15,10 @@ import ( "github.com/google/uuid" "github.com/plexara/api-test/pkg/audit" + "github.com/plexara/api-test/pkg/auth" + "github.com/plexara/api-test/pkg/auth/inbound" + "github.com/plexara/api-test/pkg/config" + "github.com/plexara/api-test/pkg/httpmw" ) // payloadLoggingMemory wraps audit.MemoryLogger and adds a per-ID @@ -134,6 +139,86 @@ func TestAuditReplay_DispatchesThroughTarget(t *testing.T) { } } +// TestAuditReplay_RedactedCredentialsThroughRealIdentityMW exercises +// the full replay path through httpmw.Identity to guard against the +// regression where redacted-credential headers ("Authorization: +// [redacted]" / "X-API-Key: [redacted]" / "?api_key=[redacted]") on a +// captured request would be re-emitted by the replay handler, then +// trip httpmw.Identity's wire-credential check, run the inbound chain +// against the redaction sentinel, and 401 the replay. +func TestAuditReplay_RedactedCredentialsThroughRealIdentityMW(t *testing.T) { + log := newPayloadLoggingMemory() + p := NewPortalAPI(nil, nil, log, nil) + + // v1 mux wrapped with the REAL identity middleware. The chain has + // one bearer authenticator that would reject "[redacted]" — so if + // the bypass is broken, this test 401s instead of reaching the + // inner handler. + innerReached := false + v1 := http.NewServeMux() + v1.HandleFunc("POST /v1/echo", func(w http.ResponseWriter, r *http.Request) { + innerReached = true + id := inbound.FromContext(r.Context()) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]string{ + "auth_type": id.AuthType, + "subject": id.Subject, + }) + }) + chain := inbound.NewChain(false, + inbound.NewBearer([]config.FileBearerToken{{Name: "real", Token: "real-token"}})) + target := httpmw.Identity(chain, slog.New(slog.NewTextHandler(io.Discard, nil)))(v1) + p.WithReplayTarget(target) + + id := uuid.NewString() + log.seedEventWithPayload(t, + audit.Event{ + ID: id, Timestamp: time.Now().UTC(), + Method: http.MethodPost, Path: "/v1/echo", Status: 200, Success: true, + }, + &audit.Payload{ + RequestHeaders: map[string][]string{ + "Authorization": {"[redacted]"}, + "X-Api-Key": {"[redacted]"}, + "X-Custom": {"keep-me"}, + }, + RequestQuery: map[string][]string{"api_key": {"[redacted]"}}, + RequestBody: []byte(`{"hi":1}`), + RequestContentType: "application/json", + }, + ) + + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + + // Carry an auth.Identity in context so the replay handler's + // portal-identity bridge fires (production sets this via + // PortalAuth.Middleware; we mimic the post-auth state). + req := httptest.NewRequest(http.MethodPost, + "/api/v1/portal/audit/replay/"+id, nil).WithContext( + auth.WithIdentity(context.Background(), + &auth.Identity{Subject: "alice", AuthType: "session", APIKeyID: "portal"})) + req.Header.Set("X-Requested-With", "XMLHttpRequest") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("portal replay returned %d (body=%s)", w.Code, w.Body.String()) + } + var env struct { + Status int `json:"status"` + } + if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { + t.Fatalf("decode portal envelope: %v", err) + } + if env.Status != http.StatusOK { + t.Fatalf("dispatched status = %d, want 200 — redacted creds re-emitted into v1 mux probably ate the bypass", env.Status) + } + if !innerReached { + t.Fatal("v1 handler never reached — replay 401'd somewhere upstream") + } +} + func TestAuditReplay_DisabledWhenNoTarget(t *testing.T) { log := newPayloadLoggingMemory() p := NewPortalAPI(nil, nil, log, nil) diff --git a/pkg/httpsrv/portal_tryit.go b/pkg/httpsrv/portal_tryit.go index b34a0c8..db7ed2f 100644 --- a/pkg/httpsrv/portal_tryit.go +++ b/pkg/httpsrv/portal_tryit.go @@ -17,6 +17,8 @@ import ( "regexp" "strings" + "github.com/plexara/api-test/pkg/auth" + "github.com/plexara/api-test/pkg/auth/inbound" "github.com/plexara/api-test/pkg/endpoints" ) @@ -142,7 +144,19 @@ func (p *PortalAPI) tryIt(w http.ResponseWriter, r *http.Request) { if len(body) > tryItMaxBodyBytes { body = body[:tryItMaxBodyBytes] } - dispatched, err := http.NewRequestWithContext(r.Context(), + + // Carry the portal-resolved identity into the dispatched request so + // httpmw.Identity short-circuits the inbound auth chain. Without this, + // a logged-in operator clicking "Send" gets 401 "missing credential": + // the dispatched request has no Authorization/X-API-Key on the wire + // (and we deliberately strip operator-supplied ones above), so the + // chain has nothing to validate. + dispatchCtx := r.Context() + if portalID := auth.GetIdentity(dispatchCtx); portalID != nil { + dispatchCtx = inbound.WithIdentity(dispatchCtx, portalIdentityToInbound(portalID)) + } + + dispatched, err := http.NewRequestWithContext(dispatchCtx, method, u.String(), bytes.NewReader(body)) if err != nil { writeError(w, http.StatusBadRequest, fmt.Errorf("construct request: %w", err)) @@ -172,6 +186,26 @@ func (p *PortalAPI) tryIt(w http.ResponseWriter, r *http.Request) { }) } +// portalIdentityToInbound translates the portal session identity into +// an inbound.Identity so httpmw.Identity can read it off the context. +// Mirrors adaptInboundIdentity in portalauth.go in the opposite direction. +func portalIdentityToInbound(in *auth.Identity) *inbound.Identity { + if in == nil { + return nil + } + authType := in.AuthType + if authType == "" { + authType = "portal" + } + return &inbound.Identity{ + Subject: in.Subject, + Email: in.Email, + AuthType: authType, + KeyName: in.APIKeyID, + Claims: in.Claims, + } +} + // findRoute locates the EndpointMeta whose Group and Name match. Both // match exactly; the operator is expected to copy these from the // portal's endpoints catalog (/api/v1/portal/endpoints), not type them.