From 9c22f79137d14971f5d208c0bca407e93dabe7ed Mon Sep 17 00:00:00 2001 From: Lasantha Samarakoon Date: Fri, 7 Aug 2026 16:00:31 +0530 Subject: [PATCH 1/6] [APICP] Update README file with local dev steps --- portals/api-control-plane/README.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/portals/api-control-plane/README.md b/portals/api-control-plane/README.md index 82ba0ce63..ca945dee8 100644 --- a/portals/api-control-plane/README.md +++ b/portals/api-control-plane/README.md @@ -28,13 +28,21 @@ supports two auth modes (`[auth] mode` in `configs/config.toml`): ## Development -Two processes, run side by side: +Three processes, run side by side: Platform API, the BFF, then the portal's +own dev server. ```bash -# Terminal 1 — the BFF, proxying to a running Platform API +# Terminal 1 — Platform API (one-time setup, then run it) +cd /platform-api +./scripts/setup-local-dev.sh # first time only — generates local certs/keys/admin creds +make run-local # or: make setup-local-dev && make run-local + +# Terminal 2 — the BFF, proxying to the running Platform API +cd /portals/api-control-plane CONTROL_PLANE_URL=https://localhost:9243 make bff-run -# Terminal 2 — the Vite dev server, proxying same-origin BFF paths to it +# Terminal 3 — the Vite dev server, proxying same-origin BFF paths to it +cd /portals/api-control-plane npm install npm run dev ``` From 0c94eb8853e890587e227e80db5942038da68215 Mon Sep 17 00:00:00 2001 From: Lasantha Samarakoon Date: Mon, 10 Aug 2026 07:11:11 +0530 Subject: [PATCH 2/6] api-control-plane/bff: add extension contract for host binaries --- portals/api-control-plane/bff/app/app.go | 89 +++++++ portals/api-control-plane/bff/app/app_test.go | 77 ++++++ .../bff/internal/server/middleware.go | 21 ++ .../bff/internal/server/options.go | 52 ++++ .../bff/internal/server/options_test.go | 237 ++++++++++++++++++ .../bff/internal/server/server.go | 66 ++++- .../bff/internal/session/context.go | 37 +++ 7 files changed, 567 insertions(+), 12 deletions(-) create mode 100644 portals/api-control-plane/bff/app/app.go create mode 100644 portals/api-control-plane/bff/app/app_test.go create mode 100644 portals/api-control-plane/bff/internal/server/options.go create mode 100644 portals/api-control-plane/bff/internal/server/options_test.go create mode 100644 portals/api-control-plane/bff/internal/session/context.go diff --git a/portals/api-control-plane/bff/app/app.go b/portals/api-control-plane/bff/app/app.go new file mode 100644 index 000000000..7a1c5e71e --- /dev/null +++ b/portals/api-control-plane/bff/app/app.go @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the + * License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Package app is api-control-plane-bff's public surface for a host binary +// that wants to embed and extend it — the Go equivalent of api-control- +// plane's own src/index.ts on the frontend side (see +// docs/remote-app-wrapper-app-architecture.md there). Every other package in +// this module is under internal/ and can never be imported from outside this +// module's own tree, by Go's own import-visibility rule — this package is +// the only one a host module (e.g. cloud-control-plane's own BFF) may +// import. Keep this file's surface minimal: it should only ever grow to +// support a concrete extension need, never speculatively. +package app + +import ( + "context" + "net/http" + + "api-control-plane-bff/internal/config" + "api-control-plane-bff/internal/server" + "api-control-plane-bff/internal/session" +) + +// Config is api-control-plane-bff's full configuration shape (koanf-loaded +// TOML + APIP_ACP_ env overlay) — identical to what the standalone binary +// loads. A host typically points LoadConfig at its own config.toml with +// cloud-specific [auth.oidc] / [control_plane.upstreams] sections. +type Config = config.Config + +// LoadConfig loads and validates a Config exactly as the standalone main.go +// does (same koanf sources, same env prefix, same defaults, same +// [server.http]/[server.https] validation). +func LoadConfig(paths ...string) (*Config, error) { return config.Load(paths...) } + +// SessionUser is the resolved caller identity available via +// SessionFromContext — the same shape GET /api/session returns to the +// browser (name, email, role, scopes, and org when present). +type SessionUser = session.User + +// SessionFromContext returns the caller's resolved session for this request, +// if it carried a valid, unexpired session cookie. Works identically for a +// default route and for an Options.ExtraRoutes handler — both run behind the +// same session-resolving middleware (see internal/server/middleware.go's +// sessionContext). ok is false for an unauthenticated request; a handler +// that requires auth must check ok itself; this function makes no +// authorization decision on its own. +func SessionFromContext(ctx context.Context) (SessionUser, bool) { + return session.FromContext(ctx) +} + +// Options extends the BFF's default route set. See +// internal/server/options.go for the full field-by-field contract +// (ExtraRoutes, DisabledRoutes, RouteOverrides, WrapRoute). The zero value +// reproduces standalone behavior exactly. +type Options = server.Options + +// Server is the subset of the BFF's lifecycle a host needs: the fully-wired +// http.Handler, and Close to release background resources on shutdown. +// Declared as an interface here — rather than re-exporting *server.Server +// directly — so this package's public signatures never spell out an +// internal type name; *server.Server already satisfies it. +type Server interface { + Handler() http.Handler + Close() error +} + +// New builds the BFF exactly as the standalone binary does, with opts +// layered on top: opts.ExtraRoutes are registered alongside the default +// route set, opts.DisabledRoutes are skipped, opts.RouteOverrides/WrapRoute +// replace or wrap a default route's handler. Every extra/overriding handler +// runs inside the same middleware chain (CSRF, security headers, session +// resolution) as every default route — there is no second auth path for a +// host to implement. +func New(ctx context.Context, cfg *Config, opts Options) (Server, error) { + return server.New(ctx, cfg, opts) +} diff --git a/portals/api-control-plane/bff/app/app_test.go b/portals/api-control-plane/bff/app/app_test.go new file mode 100644 index 000000000..d0a30ad24 --- /dev/null +++ b/portals/api-control-plane/bff/app/app_test.go @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the + * License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// package app_test (external, black-box) deliberately imports ONLY +// api-control-plane-bff/app — the same constraint a real host module (e.g. +// cloud-control-plane's own BFF) is under. If this file ever needed to +// import an internal/ package to build a working server, that would mean +// the app package's public surface is insufficient on its own. +package app_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "api-control-plane-bff/app" +) + +func TestApp_New_WithExtraRoute(t *testing.T) { + cfg := &app.Config{} + cfg.Server.HTTP.Enabled = true + cfg.ControlPlane.URL = "https://unused.example.com" + cfg.ControlPlane.ProxyPrefix = "/proxy" + cfg.Session.Store = "memory" + cfg.Session.AbsoluteTTL = 8 * time.Hour + cfg.Session.Cookie.Name = "_test_session" + cfg.Auth.Mode = "basic" + + srv, err := app.New(context.Background(), cfg, app.Options{ + ExtraRoutes: map[string]http.Handler{ + "GET /api/environments": http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, ok := app.SessionFromContext(r.Context()); ok { + t.Error("expected no session for an unauthenticated request") + } + w.WriteHeader(http.StatusOK) + }), + }, + }) + if err != nil { + t.Fatalf("app.New: %v", err) + } + defer srv.Close() + + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + res, err := http.Get(ts.URL + "/api/environments") + if err != nil { + t.Fatalf("request: %v", err) + } + if res.StatusCode != http.StatusOK { + t.Errorf("status = %d, want 200", res.StatusCode) + } + + res, err = http.Get(ts.URL + "/healthz") + if err != nil { + t.Fatalf("request: %v", err) + } + if res.StatusCode != http.StatusOK { + t.Errorf("healthz status = %d, want 200 (default routes must still be present)", res.StatusCode) + } +} diff --git a/portals/api-control-plane/bff/internal/server/middleware.go b/portals/api-control-plane/bff/internal/server/middleware.go index 705123163..b9cb60840 100644 --- a/portals/api-control-plane/bff/internal/server/middleware.go +++ b/portals/api-control-plane/bff/internal/server/middleware.go @@ -24,6 +24,7 @@ import ( "time" "api-control-plane-bff/internal/config" + "api-control-plane-bff/internal/session" ) // chain applies middlewares in order (outermost first). @@ -101,6 +102,26 @@ func (s *Server) requireCSRF(next http.Handler) http.Handler { }) } +// sessionContext resolves the caller's session (if any) once per request and +// stashes it on the request context via session.WithContext, using the exact +// same cookie lookup + decode path handleSession itself uses. Runs for every +// route on this mux — including a host's Options.ExtraRoutes handlers — so +// any of them can read identity via session.FromContext the same way a +// default handler would, with no per-feature auth wiring. A request with no +// (or an invalid) session cookie simply proceeds with nothing stashed; +// FromContext's ok=false is how a handler distinguishes that case, and +// whether that's an error is up to the handler — this middleware never +// rejects a request itself (routes below decide their own auth requirement). +func (s *Server) sessionContext(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if token, ok := s.tokenFromCookie(r); ok && !tokenExpired(token) { + u := s.userFromToken(r.Context(), token) + r = r.WithContext(session.WithContext(r.Context(), u)) + } + next.ServeHTTP(w, r) + }) +} + // logRequests emits a structured access log line per request. func logRequests(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/portals/api-control-plane/bff/internal/server/options.go b/portals/api-control-plane/bff/internal/server/options.go new file mode 100644 index 000000000..2b6fee1ab --- /dev/null +++ b/portals/api-control-plane/bff/internal/server/options.go @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the + * License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package server + +import "net/http" + +// Options extends the BFF's default route set for a host binary that embeds +// this module (see the exported bff/app package) — e.g. cloud-control-plane's +// own BFF, which registers cloud-only routes and hides/overrides a handful of +// standalone ones without forking this module. The zero value reproduces +// today's exact standalone behavior: every field is additive/optional, and a +// caller that never sets one gets the unmodified default for it. New(...) +// accepts Options variadically specifically so every existing call site +// (this module's own main.go, its tests) keeps compiling unchanged. +type Options struct { + // ExtraRoutes are registered on the same mux as every default route, so + // they run through the same middleware chain (CSRF, security headers, + // session resolution) automatically. Keyed like http.ServeMux patterns, + // e.g. "POST /api/environments". A pattern that collides with a default + // route's pattern is a caller bug (net/http.ServeMux.Handle panics on a + // duplicate registration) — construct these with care, since it is not + // validated here. + ExtraRoutes map[string]http.Handler + + // DisabledRoutes lists default route patterns (matching the exact string + // passed to register() in routes()) to skip registering entirely. A real + // 404 for that pattern, not merely a hidden UI element. + DisabledRoutes []string + + // RouteOverrides replaces a default route's handler outright, keyed by + // the same pattern the default registration uses. + RouteOverrides map[string]http.Handler + + // WrapRoute wraps a default route's handler instead of replacing it — for + // augmenting behavior (e.g. injecting host-resolved data into a proxied + // request) while still delegating to the original handler. + WrapRoute map[string]func(http.Handler) http.Handler +} diff --git a/portals/api-control-plane/bff/internal/server/options_test.go b/portals/api-control-plane/bff/internal/server/options_test.go new file mode 100644 index 000000000..c47ffbb0c --- /dev/null +++ b/portals/api-control-plane/bff/internal/server/options_test.go @@ -0,0 +1,237 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the + * License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "strings" + "testing" + + "api-control-plane-bff/internal/config" + "api-control-plane-bff/internal/session" +) + +// A caller that never sets Options must observe byte-for-byte the same +// behavior as before this contract existed — proven by re-running an +// existing end-to-end scenario through New(ctx, cfg) with no opts at all. +func TestOptions_ZeroValue_IsStandaloneBehavior(t *testing.T) { + tok := makeJWT(map[string]any{"username": "admin", "scope": "ap:project:read"}) + platform := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{"token": tok}) + })) + defer platform.Close() + + cfg := newTestConfig(platform.URL) + srv, err := New(context.Background(), cfg) // no Options argument at all + if err != nil { + t.Fatalf("New: %v", err) + } + defer srv.Close() + bff := httptest.NewServer(srv.Handler()) + defer bff.Close() + + res, err := http.Get(bff.URL + "/healthz") + if err != nil { + t.Fatalf("healthz request: %v", err) + } + assertStatus(t, res, http.StatusOK) +} + +func TestOptions_ExtraRoutes_Reachable(t *testing.T) { + cfg := newTestConfig("https://unused.example.com") + opts := Options{ + ExtraRoutes: map[string]http.Handler{ + "GET /api/environments": http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("environments")) + }), + }, + } + srv, err := New(context.Background(), cfg, opts) + if err != nil { + t.Fatalf("New: %v", err) + } + defer srv.Close() + bff := httptest.NewServer(srv.Handler()) + defer bff.Close() + + res, err := http.Get(bff.URL + "/api/environments") + if err != nil { + t.Fatalf("request: %v", err) + } + assertStatus(t, res, http.StatusOK) +} + +func TestOptions_DisabledRoutes_404s(t *testing.T) { + cfg := newTestConfig("https://unused.example.com") + opts := Options{DisabledRoutes: []string{"GET /healthz"}} + srv, err := New(context.Background(), cfg, opts) + if err != nil { + t.Fatalf("New: %v", err) + } + defer srv.Close() + bff := httptest.NewServer(srv.Handler()) + defer bff.Close() + + res, err := http.Get(bff.URL + "/healthz") + if err != nil { + t.Fatalf("request: %v", err) + } + assertStatus(t, res, http.StatusNotFound) +} + +func TestOptions_RouteOverrides_ReplacesDefaultHandler(t *testing.T) { + cfg := newTestConfig("https://unused.example.com") + opts := Options{ + RouteOverrides: map[string]http.Handler{ + "GET /healthz": http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTeapot) + }), + }, + } + srv, err := New(context.Background(), cfg, opts) + if err != nil { + t.Fatalf("New: %v", err) + } + defer srv.Close() + bff := httptest.NewServer(srv.Handler()) + defer bff.Close() + + res, err := http.Get(bff.URL + "/healthz") + if err != nil { + t.Fatalf("request: %v", err) + } + assertStatus(t, res, http.StatusTeapot) +} + +func TestOptions_WrapRoute_DelegatesToOriginal(t *testing.T) { + cfg := newTestConfig("https://unused.example.com") + var wrapperRan bool + opts := Options{ + WrapRoute: map[string]func(http.Handler) http.Handler{ + "GET /healthz": func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + wrapperRan = true + w.Header().Set("X-Wrapped", "true") + next.ServeHTTP(w, r) // delegates to the original handleHealth + }) + }, + }, + } + srv, err := New(context.Background(), cfg, opts) + if err != nil { + t.Fatalf("New: %v", err) + } + defer srv.Close() + bff := httptest.NewServer(srv.Handler()) + defer bff.Close() + + res, err := http.Get(bff.URL + "/healthz") + if err != nil { + t.Fatalf("request: %v", err) + } + assertStatus(t, res, http.StatusOK) // original handleHealth still ran + if !wrapperRan { + t.Error("expected the wrapper to run") + } + if res.Header.Get("X-Wrapped") != "true" { + t.Error("expected the wrapper's header to be present") + } + var body map[string]string + json.NewDecoder(res.Body).Decode(&body) + if body["status"] != "ok" { + t.Errorf("expected original handleHealth body to pass through, got %v", body) + } +} + +// An ExtraRoutes handler reads identity via session.FromContext exactly like +// a default handler would — proving the shared middleware chain (not a +// second auth path) is what makes this "seamless." +func TestOptions_ExtraRoutes_SeesSessionViaContext(t *testing.T) { + tok := makeJWT(map[string]any{ + "username": "admin", "scope": "ap:project:read", + "organization": "org-123", "org_name": "Acme", "org_handle": "acme", + }) + platform := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{"token": tok}) + })) + defer platform.Close() + + cfg := newTestConfig(platform.URL) + + var gotOrgID string + var gotOK bool + opts := Options{ + ExtraRoutes: map[string]http.Handler{ + "GET /api/environments": http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u, ok := session.FromContext(r.Context()) + gotOK = ok + if ok && u.Org != nil { + gotOrgID = u.Org.ID + } + w.WriteHeader(http.StatusOK) + }), + }, + } + srv, err := New(context.Background(), cfg, opts) + if err != nil { + t.Fatalf("New: %v", err) + } + defer srv.Close() + bff := httptest.NewServer(srv.Handler()) + defer bff.Close() + + jar, _ := cookiejar.New(nil) + client := &http.Client{Jar: jar} + + // Unauthenticated call: no session in context, handler must see ok=false. + res, err := client.Get(bff.URL + "/api/environments") + if err != nil { + t.Fatalf("request: %v", err) + } + assertStatus(t, res, http.StatusOK) + if gotOK { + t.Error("expected ok=false for an unauthenticated request") + } + + // Log in, then call again: handler must now see the resolved org. + loginReq, _ := http.NewRequest(http.MethodPost, bff.URL+"/api/login", + strings.NewReader(`{"username":"admin","password":"secret"}`)) + loginReq.Header.Set("Content-Type", "application/json") + loginReq.Header.Set(config.CSRFHeaderName, "api-control-plane") + res, err = client.Do(loginReq) + if err != nil { + t.Fatalf("login: %v", err) + } + assertStatus(t, res, http.StatusOK) + + res, err = client.Get(bff.URL + "/api/environments") + if err != nil { + t.Fatalf("request: %v", err) + } + assertStatus(t, res, http.StatusOK) + if !gotOK { + t.Fatal("expected ok=true for an authenticated request") + } + if gotOrgID != "org-123" { + t.Errorf("org ID = %q, want org-123", gotOrgID) + } +} diff --git a/portals/api-control-plane/bff/internal/server/server.go b/portals/api-control-plane/bff/internal/server/server.go index 03e4ee38d..193dffc4c 100644 --- a/portals/api-control-plane/bff/internal/server/server.go +++ b/portals/api-control-plane/bff/internal/server/server.go @@ -70,7 +70,16 @@ type Server struct { // session store, the file-based authenticator, and (when enabled) the OIDC // authenticator — discovering the IDP endpoints up front when discovery is // configured. -func New(ctx context.Context, cfg *config.Config) (*Server, error) { +// +// opts is variadic so every existing caller (this module's own main.go, its +// tests) keeps compiling unchanged; at most the first value is used. A host +// binary that embeds this module (see the exported bff/app package) passes +// one Options to add, hide, or override routes — see options.go. +func New(ctx context.Context, cfg *config.Config, opts ...Options) (*Server, error) { + var o Options + if len(opts) > 0 { + o = opts[0] + } primaryTransport, err := proxy.NewTransport(proxy.TLSClientOptions{ CAFile: cfg.ControlPlane.CAFile, SkipVerify: cfg.ControlPlane.TLSSkipVerify, @@ -159,7 +168,7 @@ func New(ctx context.Context, cfg *config.Config) (*Server, error) { s.oidc = o } - s.handler = s.routes() + s.handler = s.routes(o) return s, nil } @@ -198,29 +207,61 @@ func withWriteDeadline(next http.HandlerFunc) http.HandlerFunc { } // routes builds the mux and wraps it with the global middleware chain. -func (s *Server) routes() http.Handler { +// +// Every default route is registered through register() rather than calling +// mux.Handle/HandleFunc directly, so opts.DisabledRoutes/RouteOverrides/ +// WrapRoute apply uniformly without touching each handler's own logic. +// opts.ExtraRoutes bypasses register() deliberately — those patterns are the +// addition, not subject to being disabled/overridden/wrapped themselves. +func (s *Server) routes(opts Options) http.Handler { mux := http.NewServeMux() + disabled := make(map[string]bool, len(opts.DisabledRoutes)) + for _, p := range opts.DisabledRoutes { + disabled[p] = true + } + + register := func(pattern string, h http.Handler) { + if disabled[pattern] { + return + } + if override, ok := opts.RouteOverrides[pattern]; ok { + h = override + } + if wrap, ok := opts.WrapRoute[pattern]; ok { + h = wrap(h) + } + mux.Handle(pattern, h) + } + // Health (no auth, no CSRF). - mux.HandleFunc("GET /healthz", withWriteDeadline(handleHealth)) + register("GET /healthz", withWriteDeadline(handleHealth)) // Runtime config consumed by the SPA before app init. - mux.HandleFunc("GET /api-platform.env.config.js", withWriteDeadline(s.handleRuntimeConfig)) - mux.HandleFunc("GET /api-platform.common.config.js", withWriteDeadline(s.handleCommonConfig)) + register("GET /api-platform.env.config.js", withWriteDeadline(s.handleRuntimeConfig)) + register("GET /api-platform.common.config.js", withWriteDeadline(s.handleCommonConfig)) // Auth endpoints. - mux.HandleFunc("POST /api/login", withWriteDeadline(s.handleLogin)) - mux.HandleFunc("POST /api/logout", withWriteDeadline(s.handleLogout)) - mux.HandleFunc("GET /api/session", withWriteDeadline(s.handleSession)) - mux.HandleFunc("GET /api/auth/login", withWriteDeadline(s.handleOIDCLogin)) - mux.HandleFunc("GET /api/auth/callback", withWriteDeadline(s.handleOIDCCallback)) + register("POST /api/login", withWriteDeadline(s.handleLogin)) + register("POST /api/logout", withWriteDeadline(s.handleLogout)) + register("GET /api/session", withWriteDeadline(s.handleSession)) + register("GET /api/auth/login", withWriteDeadline(s.handleOIDCLogin)) + register("GET /api/auth/callback", withWriteDeadline(s.handleOIDCCallback)) // Same-origin reverse proxy(ies): the primary control plane, plus any // named upstream. Each Rewrite hook already strips its own prefix, so the // subtree is registered directly. Deliberately NOT wrapped in // withWriteDeadline — see its doc comment. for _, p := range s.proxies { - mux.HandleFunc(p.prefix+"/", s.proxyHandler(p.rp)) + register(p.prefix+"/", s.proxyHandler(p.rp)) + } + + // Host-supplied additions. Registered directly (not through register()) + // since Disabled/RouteOverrides/WrapRoute target default routes, not + // these — they run through the same middleware chain as every route + // above regardless. + for pattern, h := range opts.ExtraRoutes { + mux.Handle(pattern, h) } // SPA static files + client-side routing fallback. Must be registered @@ -233,6 +274,7 @@ func (s *Server) routes() http.Handler { logRequests, s.securityHeaders, s.requireCSRF, + s.sessionContext, ) } diff --git a/portals/api-control-plane/bff/internal/session/context.go b/portals/api-control-plane/bff/internal/session/context.go new file mode 100644 index 000000000..d389a386d --- /dev/null +++ b/portals/api-control-plane/bff/internal/session/context.go @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the + * License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package session + +import "context" + +// contextKey is unexported so no other package can construct a colliding key. +type contextKey struct{} + +// WithContext returns a copy of ctx carrying the resolved session User. +func WithContext(ctx context.Context, u User) context.Context { + return context.WithValue(ctx, contextKey{}, u) +} + +// FromContext returns the session User stashed by the BFF's session +// middleware (server.sessionContext), if the request was authenticated. Any +// handler registered on the server's mux — a default route or a host-supplied +// one via server.Options.ExtraRoutes — can call this instead of re-deriving +// identity from the cookie itself. +func FromContext(ctx context.Context) (User, bool) { + u, ok := ctx.Value(contextKey{}).(User) + return u, ok +} From bed4ffc9f44020aa2be9b757d3388f147d870226 Mon Sep 17 00:00:00 2001 From: Lasantha Samarakoon Date: Tue, 11 Aug 2026 23:27:08 +0530 Subject: [PATCH 3/6] Add native cloud-extension seam to api-control-plane --- .../apip-api-control-plane-release.yml | 109 ++++++++++++++++++ Makefile | 15 +++ portals/api-control-plane/Makefile | 49 +++++++- portals/api-control-plane/src/App.tsx | 14 ++- portals/api-control-plane/src/cloud/index.ts | 27 +++++ portals/api-control-plane/src/extensions.tsx | 85 ++++++++++++++ portals/api-control-plane/src/index.ts | 3 + portals/api-control-plane/src/main.tsx | 3 +- .../src/navigation/useNavigationItems.ts | 96 ++++++++++----- .../src/routes/AppRoutes.tsx | 23 +++- 10 files changed, 388 insertions(+), 36 deletions(-) create mode 100644 .github/workflows/apip-api-control-plane-release.yml create mode 100644 portals/api-control-plane/src/cloud/index.ts create mode 100644 portals/api-control-plane/src/extensions.tsx diff --git a/.github/workflows/apip-api-control-plane-release.yml b/.github/workflows/apip-api-control-plane-release.yml new file mode 100644 index 000000000..b2cacd83d --- /dev/null +++ b/.github/workflows/apip-api-control-plane-release.yml @@ -0,0 +1,109 @@ +name: API Control Plane Release + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g., 0.1.0)' + required: true + type: string + next_dev_version: + description: 'Next development version (with -SNAPSHOT suffix, e.g. 0.2.0-SNAPSHOT)' + required: true + type: string + +env: + DOCKER_REGISTRY: ghcr.io/wso2/api-platform + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set version + env: + VERSION: ${{ inputs.version }} + run: | + make -C portals/api-control-plane version-set "VERSION=${VERSION}" + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.26.5' + cache: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + install: true + driver: docker-container + + - name: Run tests + run: make test-api-control-plane + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.API_PLATFORM_BOT_TOKEN }} + + - name: Build and push multi arch Docker images + run: make build-and-push-api-control-plane-multiarch + + - name: Create and push tag + env: + VERSION: ${{ inputs.version }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git commit -am "Release api-control-plane version ${VERSION}" + git tag -a "apip-api-control-plane/v${VERSION}" -m "API Control Plane ${VERSION}" + git push origin "apip-api-control-plane/v${VERSION}" + + - name: Set next dev version + env: + NEXT_DEV_VERSION: ${{ inputs.next_dev_version }} + run: | + if [ -z "${NEXT_DEV_VERSION}" ]; then + echo "Error: next_dev_version input is required" + exit 1 + fi + if [[ "${NEXT_DEV_VERSION}" != *-SNAPSHOT ]]; then + echo "Error: next_dev_version must end with -SNAPSHOT (e.g. 0.2.0-SNAPSHOT)" + exit 1 + fi + echo "Setting api-control-plane to next dev version: ${NEXT_DEV_VERSION}" + make -C portals/api-control-plane version-set "VERSION=${NEXT_DEV_VERSION}" + + - name: Commit version bump + run: | + if [ -n "$(git status --porcelain --untracked-files=no)" ]; then + git commit -am "Bump api-control-plane to next dev version" + else + echo "No changes to commit" + fi + + - name: Create PR for version bump + id: create-pr + uses: peter-evans/create-pull-request@v6 + with: + commit-message: "Bump api-control-plane to next dev version" + title: "chore: Bump api-control-plane to next dev version" + body: | + Automated version bump after release `apip-api-control-plane/v${{ inputs.version }}` + + This PR bumps the api-control-plane version to the next development version `${{ inputs.next_dev_version }}`. + branch: api-control-plane-version-bump-${{ github.run_id }} + base: main + delete-branch: true + labels: | + automated + version-bump diff --git a/Makefile b/Makefile index 8e544bc80..92536a3eb 100644 --- a/Makefile +++ b/Makefile @@ -25,6 +25,7 @@ EVENT_GATEWAY_VERSION := $(shell cat event-gateway/VERSION) PLATFORM_API_VERSION := $(shell cat platform-api/VERSION) CLI_VERSION := $(shell cat cli/VERSION) API_PORTAL_VERSION := $(shell cat portals/api-portal/VERSION) +API_CONTROL_PLANE_VERSION := $(shell cat portals/api-control-plane/VERSION) # Docker registry configuration DOCKER_REGISTRY ?= ghcr.io/wso2/api-platform @@ -57,6 +58,8 @@ help: ## Show this help message @echo ' make test-cli - Run CLI tests' @echo ' make test-api-portal - Run API Portal integration tests' @echo ' make test-ai-workspace - Run AI Workspace E2E tests' + @echo ' make build-and-push-api-control-plane-multiarch - Build and push API Control Plane image for multiple architectures' + @echo ' make test-api-control-plane - Run API Control Plane tests' @echo '' @echo 'Push Targets:' @echo ' make push-gateway - Push gateway images to registry' @@ -75,6 +78,7 @@ version: ## Display current versions @echo "Platform API Version: $(PLATFORM_API_VERSION)" @echo "CLI Version: $(CLI_VERSION)" @echo "API Portal Version: $(API_PORTAL_VERSION)" + @echo "API Control Plane Version: $(API_CONTROL_PLANE_VERSION)" # Build Targets @@ -114,6 +118,12 @@ build-and-push-api-portal-multiarch: ## Build and push API Portal Docker image f $(MAKE) -C portals/api-portal build-and-push-multiarch @echo "Successfully built and pushed multi-arch API Portal" +.PHONY: build-and-push-api-control-plane-multiarch +build-and-push-api-control-plane-multiarch: ## Build and push API Control Plane Docker image for multiple architectures (amd64, arm64) + @echo "Building and pushing multi-arch API Control Plane ($(API_CONTROL_PLANE_VERSION))..." + $(MAKE) -C portals/api-control-plane build-and-push-multiarch VERSION=$(API_CONTROL_PLANE_VERSION) + @echo "Successfully built and pushed multi-arch API Control Plane" + # Package Targets .PHONY: package-event-gateway package-event-gateway: ## Package event gateway as a self-contained zip (wso2apip-event-gateway-.zip) @@ -162,6 +172,11 @@ test-ai-workspace: ## Run AI Workspace E2E tests @echo "Running AI Workspace E2E tests..." $(MAKE) -C portals/ai-workspace e2e-ci +.PHONY: test-api-control-plane +test-api-control-plane: ## Run API Control Plane tests + @echo "Running API Control Plane tests..." + $(MAKE) -C portals/api-control-plane test + .PHONY: build-cli build-cli: ## Build CLI binaries for all platforms @echo "Building CLI ($(CLI_VERSION))..." diff --git a/portals/api-control-plane/Makefile b/portals/api-control-plane/Makefile index e15b4bf57..7932f79da 100644 --- a/portals/api-control-plane/Makefile +++ b/portals/api-control-plane/Makefile @@ -21,7 +21,10 @@ SHELL := /bin/bash VERSION ?= $(shell cat VERSION 2>/dev/null || echo "0.0.0-SNAPSHOT") -.PHONY: help run build bff-build bff-run bff-test bff-tidy +DOCKER_REGISTRY ?= ghcr.io/wso2/api-platform +IMAGE_NAME := $(DOCKER_REGISTRY)/api-control-plane + +.PHONY: help run build bff-build bff-run bff-test bff-tidy test build-and-push-multiarch version-set version-bump-patch version-bump-minor version-bump-major version-bump-next-dev version-get-release # BFF (Backend-for-Frontend) — Go server that serves the SPA, proxies all # browser->backend traffic, and owns authentication. Local dev runs it over @@ -75,5 +78,47 @@ bff-test: ## Run BFF unit tests bff-tidy: ## Tidy the BFF module cd $(BFF_DIR) && GOWORK=off go mod tidy +test: ## Run frontend and BFF tests + npm test + $(MAKE) bff-test + build: ## Build the production Docker image - docker build -t api-control-plane:$(VERSION) . + docker build -t $(IMAGE_NAME):$(VERSION) -t api-control-plane:$(VERSION) . + +build-and-push-multiarch: ## Build and push multi-architecture Docker image (linux/amd64, linux/arm64) + @echo "Building and pushing multi-arch api-control-plane Docker image: $(IMAGE_NAME):$(VERSION)" + docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --build-arg VERSION=$(VERSION) \ + -t $(IMAGE_NAME):$(VERSION) \ + --push \ + . + +version-set: ## Set api-control-plane version + @if [ -z "$(VERSION)" ]; then \ + echo "Error: VERSION required"; \ + echo "Usage: make version-set VERSION=1.0.0"; \ + exit 1; \ + fi + @echo "$(VERSION)" > VERSION + @echo "✓ Set api-control-plane version to $(VERSION)" + +version-bump-patch: ## Bump patch version + @$(MAKE) version-set VERSION=$$(cd ../.. && bash scripts/next-version.sh patch portals/api-control-plane) + +version-bump-minor: ## Bump minor version + @$(MAKE) version-set VERSION=$$(cd ../.. && bash scripts/next-version.sh minor portals/api-control-plane) + +version-bump-major: ## Bump major version + @$(MAKE) version-set VERSION=$$(cd ../.. && bash scripts/next-version.sh major portals/api-control-plane) + +version-bump-next-dev: ## Bump to next minor dev version with SNAPSHOT suffix + @$(MAKE) version-set VERSION=$$(cd ../.. && bash scripts/next-version.sh next-dev portals/api-control-plane) + +version-get-release: ## Get release version (strips SNAPSHOT suffix) + @VERSION=$$(cat VERSION 2>/dev/null | tr -d '[:space:]' | sed 's/-SNAPSHOT//'); \ + if [ -z "$$VERSION" ]; then \ + echo "Error: VERSION is empty or contains only whitespace. Check portals/api-control-plane/VERSION file." >&2; \ + exit 1; \ + fi; \ + echo "$$VERSION" diff --git a/portals/api-control-plane/src/App.tsx b/portals/api-control-plane/src/App.tsx index 9a88eeda3..cde14ab45 100644 --- a/portals/api-control-plane/src/App.tsx +++ b/portals/api-control-plane/src/App.tsx @@ -27,6 +27,10 @@ import { runtimeConfig } from './config/runtime'; import { AuthProvider } from './features/auth/AuthProvider'; import { ProductActivation } from './features/billing/ProductActivation'; import { AppRoutes } from './routes/AppRoutes'; +import { + ExtensionsProvider, + type ApiControlPlaneExtension, +} from './extensions'; const isProduction = import.meta.env.PROD; @@ -39,7 +43,11 @@ const queryClient = new QueryClient({ }, }); -export default function App() { +export type AppProps = { + extensions?: readonly ApiControlPlaneExtension[]; +}; + +export default function App({ extensions = [] }: AppProps) { return ( @@ -49,7 +57,9 @@ export default function App() { - + + + diff --git a/portals/api-control-plane/src/cloud/index.ts b/portals/api-control-plane/src/cloud/index.ts new file mode 100644 index 000000000..fee98f3ef --- /dev/null +++ b/portals/api-control-plane/src/cloud/index.ts @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * Injection seam for cloud-only extensions. `main.tsx` imports + * `cloudExtensions` from here unconditionally, so this file must always + * exist and export a valid (possibly empty) array — this is what lets a + * downstream build overlay just this one file/directory with real cloud + * features, without ever touching App.tsx/main.tsx/extensions.tsx. + */ + +import type { ApiControlPlaneExtension } from '../extensions'; + +export const cloudExtensions: ApiControlPlaneExtension[] = []; diff --git a/portals/api-control-plane/src/extensions.tsx b/portals/api-control-plane/src/extensions.tsx new file mode 100644 index 000000000..0ed969124 --- /dev/null +++ b/portals/api-control-plane/src/extensions.tsx @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createContext, useContext, type ReactNode } from 'react'; + +import type { ConsoleScope } from './scope/ConsoleScopeProvider'; +import type { NavigationLevel } from './navigation/navigationTypes'; + +/** + * A host-injected feature: a route plus its sidebar entry. `routePath` is + * relative to the same route group the built-in nav items live in (e.g. + * `"billing"`, not `"/organizations/:orgHandle/billing"`), and `level` + * decides which sidebar section it's grouped under — mirrors + * `NavigationDefinition` so it can be merged straight into the existing + * nav pipeline in `navigation/useNavigationItems.ts`. + */ +export type ApiControlPlaneExtension = { + id: string; + routePath: string; + element: ReactNode; + label: string; + icon?: ReactNode; + level: NavigationLevel; + /** Sidebar section heading. Defaults to the level's own section (e.g. "Organization"). */ + group?: string; + order: number; + isVisible?: (scope: ConsoleScope) => boolean; +}; + +const ExtensionsContext = createContext( + [] +); + +export function ExtensionsProvider({ + extensions, + children, +}: { + extensions: readonly ApiControlPlaneExtension[]; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +export function useExtensions(): readonly ApiControlPlaneExtension[] { + return useContext(ExtensionsContext); +} + +/** + * Prefixes an extension's `routePath` with the URL shape for its `level` + * (organization/project/api), so both `AppRoutes` (route patterns, `orgHandle` + * etc. as `:param` placeholders) and the nav pipeline (concrete scope values) + * build the same URL shape from one place. + */ +export function buildScopedExtensionPath( + level: NavigationLevel, + routeSuffix: string, + params: { orgHandle: string; projectHandler?: string; apiHandler?: string } +): string { + if (level === 'organization') { + return `/organizations/${params.orgHandle}/${routeSuffix}`; + } + if (level === 'project') { + return `/organizations/${params.orgHandle}/projects/${params.projectHandler}/${routeSuffix}`; + } + return `/organizations/${params.orgHandle}/projects/${params.projectHandler}/apis/${params.apiHandler}/${routeSuffix}`; +} diff --git a/portals/api-control-plane/src/index.ts b/portals/api-control-plane/src/index.ts index 5e5abfd10..f9ccdeac5 100644 --- a/portals/api-control-plane/src/index.ts +++ b/portals/api-control-plane/src/index.ts @@ -23,4 +23,7 @@ // hot-reloading edits across both apps in a single dev server, with no // separate "build api-control-plane, then run the host" step. export { default as App } from './App'; +export type { AppProps } from './App'; export { loadRuntimeConfigScripts } from './config/loadRuntimeConfigScripts'; +export type { ApiControlPlaneExtension } from './extensions'; +export { buildScopedExtensionPath } from './extensions'; diff --git a/portals/api-control-plane/src/main.tsx b/portals/api-control-plane/src/main.tsx index 756e760cb..d5874799f 100644 --- a/portals/api-control-plane/src/main.tsx +++ b/portals/api-control-plane/src/main.tsx @@ -29,10 +29,11 @@ loadRuntimeConfigScripts() }) .finally(async () => { const { default: App } = await import('./App'); + const { cloudExtensions } = await import('./cloud'); root.render( - + ); }); diff --git a/portals/api-control-plane/src/navigation/useNavigationItems.ts b/portals/api-control-plane/src/navigation/useNavigationItems.ts index f9fb37a97..e395df316 100644 --- a/portals/api-control-plane/src/navigation/useNavigationItems.ts +++ b/portals/api-control-plane/src/navigation/useNavigationItems.ts @@ -21,6 +21,7 @@ import { useLocation } from 'react-router-dom'; import { runtimeConfig } from '../config/runtime'; import { useConsoleScope } from '../scope/ConsoleScopeProvider'; +import { buildScopedExtensionPath, useExtensions } from '../extensions'; import { navigationRegistry } from './navigationRegistry'; import { NAVIGATION_GROUP_BY_LEVEL, @@ -45,37 +46,72 @@ const isFeatureEnabled = (definition: NavigationDefinition) => export const useNavigationItems = (): NavigationItem[] => { const scope = useConsoleScope(); const location = useLocation(); + const extensions = useExtensions(); - return useMemo( - () => - navigationRegistry - .filter((definition) => isLevelAvailable(definition, scope)) - .filter(isFeatureEnabled) - .filter((definition) => definition.isVisible?.(scope) ?? true) - .map((definition) => { - const to = definition.to(scope); - if (!to) return undefined; - return { - group: definition.group ?? NAVIGATION_GROUP_BY_LEVEL[definition.level], - icon: definition.icon, - id: definition.id, - isActive: definition.match - ? definition.match(location.pathname) - : location.pathname === to, - label: definition.label, - to, - }; - }) - .filter(Boolean) - .sort((left, right) => { - const leftOrder = - navigationRegistry.find((item) => item.id === left?.id)?.order ?? 0; - const rightOrder = - navigationRegistry.find((item) => item.id === right?.id)?.order ?? 0; - return leftOrder - rightOrder; - }) as NavigationItem[], - [location.pathname, scope] - ); + return useMemo(() => { + // Host-injected extensions are converted to the same NavigationDefinition + // shape the built-in registry uses, so they run through one filter/sort + // pipeline instead of a parallel "Cloud category" implementation. + const extensionDefinitions: NavigationDefinition[] = extensions.map( + (extension) => { + const routeSuffix = extension.routePath.replace(/\/\*$/, ''); + return { + group: extension.group, + icon: extension.icon, + id: extension.id, + isVisible: extension.isVisible, + label: extension.label, + level: extension.level, + match: (pathname) => pathname.includes(`/${routeSuffix}`), + order: extension.order, + to: (navScope) => { + const { orgHandle, projectHandler, apiHandler } = navScope.params; + if (!orgHandle) return undefined; + if (extension.level === 'project' && !projectHandler) return undefined; + if ( + extension.level === 'api' && + (!projectHandler || !apiHandler) + ) { + return undefined; + } + return buildScopedExtensionPath(extension.level, routeSuffix, { + apiHandler, + orgHandle, + projectHandler, + }); + }, + }; + } + ); + const combinedRegistry = [...navigationRegistry, ...extensionDefinitions]; + + return combinedRegistry + .filter((definition) => isLevelAvailable(definition, scope)) + .filter(isFeatureEnabled) + .filter((definition) => definition.isVisible?.(scope) ?? true) + .map((definition) => { + const to = definition.to(scope); + if (!to) return undefined; + return { + group: definition.group ?? NAVIGATION_GROUP_BY_LEVEL[definition.level], + icon: definition.icon, + id: definition.id, + isActive: definition.match + ? definition.match(location.pathname) + : location.pathname === to, + label: definition.label, + to, + }; + }) + .filter(Boolean) + .sort((left, right) => { + const leftOrder = + combinedRegistry.find((item) => item.id === left?.id)?.order ?? 0; + const rightOrder = + combinedRegistry.find((item) => item.id === right?.id)?.order ?? 0; + return leftOrder - rightOrder; + }) as NavigationItem[]; + }, [location.pathname, scope, extensions]); }; /** diff --git a/portals/api-control-plane/src/routes/AppRoutes.tsx b/portals/api-control-plane/src/routes/AppRoutes.tsx index d2bef3afb..cffdcd5ba 100644 --- a/portals/api-control-plane/src/routes/AppRoutes.tsx +++ b/portals/api-control-plane/src/routes/AppRoutes.tsx @@ -30,6 +30,10 @@ import { } from '../features/system/SystemPages'; import { ConsoleScopeProvider } from '../scope/ConsoleScopeProvider'; import AppLayout from '../layouts/AppLayout'; +import { + buildScopedExtensionPath, + type ApiControlPlaneExtension, +} from '../extensions'; import { ProtectedRoute } from './ProtectedRoute'; import { routes } from './paths'; @@ -100,7 +104,23 @@ const SettingsPage = lazy(() => })) ); -export function AppRoutes() { +export type AppRoutesProps = { + extensions?: readonly ApiControlPlaneExtension[]; +}; + +export function AppRoutes({ extensions = [] }: AppRoutesProps) { + const extensionRoutes = extensions.map((extension) => ( + + )); + return ( } /> @@ -133,6 +153,7 @@ export function AppRoutes() { } /> } /> } /> + {extensionRoutes} } /> From 983d11d9855456bdffbcc42a2dee8aa2fec37499 Mon Sep 17 00:00:00 2001 From: Lasantha Samarakoon Date: Thu, 13 Aug 2026 08:55:52 +0530 Subject: [PATCH 4/6] Update .gitignore file --- portals/api-control-plane/.gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/portals/api-control-plane/.gitignore b/portals/api-control-plane/.gitignore index a586006cb..5bb327ac2 100644 --- a/portals/api-control-plane/.gitignore +++ b/portals/api-control-plane/.gitignore @@ -1,4 +1,5 @@ /build/ /coverage/ /node_modules/ +/target/ .env*.local From 3f39324675c7c54cf02ba19209e27beb22ce29dd Mon Sep 17 00:00:00 2001 From: Lasantha Samarakoon Date: Thu, 13 Aug 2026 10:05:22 +0530 Subject: [PATCH 5/6] Revert "api-control-plane/bff: add extension contract for host binaries" --- portals/api-control-plane/bff/app/app.go | 89 ------- portals/api-control-plane/bff/app/app_test.go | 77 ------ .../bff/internal/server/middleware.go | 21 -- .../bff/internal/server/options.go | 52 ---- .../bff/internal/server/options_test.go | 237 ------------------ .../bff/internal/server/server.go | 66 +---- .../bff/internal/session/context.go | 37 --- 7 files changed, 12 insertions(+), 567 deletions(-) delete mode 100644 portals/api-control-plane/bff/app/app.go delete mode 100644 portals/api-control-plane/bff/app/app_test.go delete mode 100644 portals/api-control-plane/bff/internal/server/options.go delete mode 100644 portals/api-control-plane/bff/internal/server/options_test.go delete mode 100644 portals/api-control-plane/bff/internal/session/context.go diff --git a/portals/api-control-plane/bff/app/app.go b/portals/api-control-plane/bff/app/app.go deleted file mode 100644 index 7a1c5e71e..000000000 --- a/portals/api-control-plane/bff/app/app.go +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the - * License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -// Package app is api-control-plane-bff's public surface for a host binary -// that wants to embed and extend it — the Go equivalent of api-control- -// plane's own src/index.ts on the frontend side (see -// docs/remote-app-wrapper-app-architecture.md there). Every other package in -// this module is under internal/ and can never be imported from outside this -// module's own tree, by Go's own import-visibility rule — this package is -// the only one a host module (e.g. cloud-control-plane's own BFF) may -// import. Keep this file's surface minimal: it should only ever grow to -// support a concrete extension need, never speculatively. -package app - -import ( - "context" - "net/http" - - "api-control-plane-bff/internal/config" - "api-control-plane-bff/internal/server" - "api-control-plane-bff/internal/session" -) - -// Config is api-control-plane-bff's full configuration shape (koanf-loaded -// TOML + APIP_ACP_ env overlay) — identical to what the standalone binary -// loads. A host typically points LoadConfig at its own config.toml with -// cloud-specific [auth.oidc] / [control_plane.upstreams] sections. -type Config = config.Config - -// LoadConfig loads and validates a Config exactly as the standalone main.go -// does (same koanf sources, same env prefix, same defaults, same -// [server.http]/[server.https] validation). -func LoadConfig(paths ...string) (*Config, error) { return config.Load(paths...) } - -// SessionUser is the resolved caller identity available via -// SessionFromContext — the same shape GET /api/session returns to the -// browser (name, email, role, scopes, and org when present). -type SessionUser = session.User - -// SessionFromContext returns the caller's resolved session for this request, -// if it carried a valid, unexpired session cookie. Works identically for a -// default route and for an Options.ExtraRoutes handler — both run behind the -// same session-resolving middleware (see internal/server/middleware.go's -// sessionContext). ok is false for an unauthenticated request; a handler -// that requires auth must check ok itself; this function makes no -// authorization decision on its own. -func SessionFromContext(ctx context.Context) (SessionUser, bool) { - return session.FromContext(ctx) -} - -// Options extends the BFF's default route set. See -// internal/server/options.go for the full field-by-field contract -// (ExtraRoutes, DisabledRoutes, RouteOverrides, WrapRoute). The zero value -// reproduces standalone behavior exactly. -type Options = server.Options - -// Server is the subset of the BFF's lifecycle a host needs: the fully-wired -// http.Handler, and Close to release background resources on shutdown. -// Declared as an interface here — rather than re-exporting *server.Server -// directly — so this package's public signatures never spell out an -// internal type name; *server.Server already satisfies it. -type Server interface { - Handler() http.Handler - Close() error -} - -// New builds the BFF exactly as the standalone binary does, with opts -// layered on top: opts.ExtraRoutes are registered alongside the default -// route set, opts.DisabledRoutes are skipped, opts.RouteOverrides/WrapRoute -// replace or wrap a default route's handler. Every extra/overriding handler -// runs inside the same middleware chain (CSRF, security headers, session -// resolution) as every default route — there is no second auth path for a -// host to implement. -func New(ctx context.Context, cfg *Config, opts Options) (Server, error) { - return server.New(ctx, cfg, opts) -} diff --git a/portals/api-control-plane/bff/app/app_test.go b/portals/api-control-plane/bff/app/app_test.go deleted file mode 100644 index d0a30ad24..000000000 --- a/portals/api-control-plane/bff/app/app_test.go +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the - * License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -// package app_test (external, black-box) deliberately imports ONLY -// api-control-plane-bff/app — the same constraint a real host module (e.g. -// cloud-control-plane's own BFF) is under. If this file ever needed to -// import an internal/ package to build a working server, that would mean -// the app package's public surface is insufficient on its own. -package app_test - -import ( - "context" - "net/http" - "net/http/httptest" - "testing" - "time" - - "api-control-plane-bff/app" -) - -func TestApp_New_WithExtraRoute(t *testing.T) { - cfg := &app.Config{} - cfg.Server.HTTP.Enabled = true - cfg.ControlPlane.URL = "https://unused.example.com" - cfg.ControlPlane.ProxyPrefix = "/proxy" - cfg.Session.Store = "memory" - cfg.Session.AbsoluteTTL = 8 * time.Hour - cfg.Session.Cookie.Name = "_test_session" - cfg.Auth.Mode = "basic" - - srv, err := app.New(context.Background(), cfg, app.Options{ - ExtraRoutes: map[string]http.Handler{ - "GET /api/environments": http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if _, ok := app.SessionFromContext(r.Context()); ok { - t.Error("expected no session for an unauthenticated request") - } - w.WriteHeader(http.StatusOK) - }), - }, - }) - if err != nil { - t.Fatalf("app.New: %v", err) - } - defer srv.Close() - - ts := httptest.NewServer(srv.Handler()) - defer ts.Close() - - res, err := http.Get(ts.URL + "/api/environments") - if err != nil { - t.Fatalf("request: %v", err) - } - if res.StatusCode != http.StatusOK { - t.Errorf("status = %d, want 200", res.StatusCode) - } - - res, err = http.Get(ts.URL + "/healthz") - if err != nil { - t.Fatalf("request: %v", err) - } - if res.StatusCode != http.StatusOK { - t.Errorf("healthz status = %d, want 200 (default routes must still be present)", res.StatusCode) - } -} diff --git a/portals/api-control-plane/bff/internal/server/middleware.go b/portals/api-control-plane/bff/internal/server/middleware.go index b9cb60840..705123163 100644 --- a/portals/api-control-plane/bff/internal/server/middleware.go +++ b/portals/api-control-plane/bff/internal/server/middleware.go @@ -24,7 +24,6 @@ import ( "time" "api-control-plane-bff/internal/config" - "api-control-plane-bff/internal/session" ) // chain applies middlewares in order (outermost first). @@ -102,26 +101,6 @@ func (s *Server) requireCSRF(next http.Handler) http.Handler { }) } -// sessionContext resolves the caller's session (if any) once per request and -// stashes it on the request context via session.WithContext, using the exact -// same cookie lookup + decode path handleSession itself uses. Runs for every -// route on this mux — including a host's Options.ExtraRoutes handlers — so -// any of them can read identity via session.FromContext the same way a -// default handler would, with no per-feature auth wiring. A request with no -// (or an invalid) session cookie simply proceeds with nothing stashed; -// FromContext's ok=false is how a handler distinguishes that case, and -// whether that's an error is up to the handler — this middleware never -// rejects a request itself (routes below decide their own auth requirement). -func (s *Server) sessionContext(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if token, ok := s.tokenFromCookie(r); ok && !tokenExpired(token) { - u := s.userFromToken(r.Context(), token) - r = r.WithContext(session.WithContext(r.Context(), u)) - } - next.ServeHTTP(w, r) - }) -} - // logRequests emits a structured access log line per request. func logRequests(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/portals/api-control-plane/bff/internal/server/options.go b/portals/api-control-plane/bff/internal/server/options.go deleted file mode 100644 index 2b6fee1ab..000000000 --- a/portals/api-control-plane/bff/internal/server/options.go +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the - * License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -package server - -import "net/http" - -// Options extends the BFF's default route set for a host binary that embeds -// this module (see the exported bff/app package) — e.g. cloud-control-plane's -// own BFF, which registers cloud-only routes and hides/overrides a handful of -// standalone ones without forking this module. The zero value reproduces -// today's exact standalone behavior: every field is additive/optional, and a -// caller that never sets one gets the unmodified default for it. New(...) -// accepts Options variadically specifically so every existing call site -// (this module's own main.go, its tests) keeps compiling unchanged. -type Options struct { - // ExtraRoutes are registered on the same mux as every default route, so - // they run through the same middleware chain (CSRF, security headers, - // session resolution) automatically. Keyed like http.ServeMux patterns, - // e.g. "POST /api/environments". A pattern that collides with a default - // route's pattern is a caller bug (net/http.ServeMux.Handle panics on a - // duplicate registration) — construct these with care, since it is not - // validated here. - ExtraRoutes map[string]http.Handler - - // DisabledRoutes lists default route patterns (matching the exact string - // passed to register() in routes()) to skip registering entirely. A real - // 404 for that pattern, not merely a hidden UI element. - DisabledRoutes []string - - // RouteOverrides replaces a default route's handler outright, keyed by - // the same pattern the default registration uses. - RouteOverrides map[string]http.Handler - - // WrapRoute wraps a default route's handler instead of replacing it — for - // augmenting behavior (e.g. injecting host-resolved data into a proxied - // request) while still delegating to the original handler. - WrapRoute map[string]func(http.Handler) http.Handler -} diff --git a/portals/api-control-plane/bff/internal/server/options_test.go b/portals/api-control-plane/bff/internal/server/options_test.go deleted file mode 100644 index c47ffbb0c..000000000 --- a/portals/api-control-plane/bff/internal/server/options_test.go +++ /dev/null @@ -1,237 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the - * License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -package server - -import ( - "context" - "encoding/json" - "net/http" - "net/http/cookiejar" - "net/http/httptest" - "strings" - "testing" - - "api-control-plane-bff/internal/config" - "api-control-plane-bff/internal/session" -) - -// A caller that never sets Options must observe byte-for-byte the same -// behavior as before this contract existed — proven by re-running an -// existing end-to-end scenario through New(ctx, cfg) with no opts at all. -func TestOptions_ZeroValue_IsStandaloneBehavior(t *testing.T) { - tok := makeJWT(map[string]any{"username": "admin", "scope": "ap:project:read"}) - platform := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - json.NewEncoder(w).Encode(map[string]any{"token": tok}) - })) - defer platform.Close() - - cfg := newTestConfig(platform.URL) - srv, err := New(context.Background(), cfg) // no Options argument at all - if err != nil { - t.Fatalf("New: %v", err) - } - defer srv.Close() - bff := httptest.NewServer(srv.Handler()) - defer bff.Close() - - res, err := http.Get(bff.URL + "/healthz") - if err != nil { - t.Fatalf("healthz request: %v", err) - } - assertStatus(t, res, http.StatusOK) -} - -func TestOptions_ExtraRoutes_Reachable(t *testing.T) { - cfg := newTestConfig("https://unused.example.com") - opts := Options{ - ExtraRoutes: map[string]http.Handler{ - "GET /api/environments": http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte("environments")) - }), - }, - } - srv, err := New(context.Background(), cfg, opts) - if err != nil { - t.Fatalf("New: %v", err) - } - defer srv.Close() - bff := httptest.NewServer(srv.Handler()) - defer bff.Close() - - res, err := http.Get(bff.URL + "/api/environments") - if err != nil { - t.Fatalf("request: %v", err) - } - assertStatus(t, res, http.StatusOK) -} - -func TestOptions_DisabledRoutes_404s(t *testing.T) { - cfg := newTestConfig("https://unused.example.com") - opts := Options{DisabledRoutes: []string{"GET /healthz"}} - srv, err := New(context.Background(), cfg, opts) - if err != nil { - t.Fatalf("New: %v", err) - } - defer srv.Close() - bff := httptest.NewServer(srv.Handler()) - defer bff.Close() - - res, err := http.Get(bff.URL + "/healthz") - if err != nil { - t.Fatalf("request: %v", err) - } - assertStatus(t, res, http.StatusNotFound) -} - -func TestOptions_RouteOverrides_ReplacesDefaultHandler(t *testing.T) { - cfg := newTestConfig("https://unused.example.com") - opts := Options{ - RouteOverrides: map[string]http.Handler{ - "GET /healthz": http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusTeapot) - }), - }, - } - srv, err := New(context.Background(), cfg, opts) - if err != nil { - t.Fatalf("New: %v", err) - } - defer srv.Close() - bff := httptest.NewServer(srv.Handler()) - defer bff.Close() - - res, err := http.Get(bff.URL + "/healthz") - if err != nil { - t.Fatalf("request: %v", err) - } - assertStatus(t, res, http.StatusTeapot) -} - -func TestOptions_WrapRoute_DelegatesToOriginal(t *testing.T) { - cfg := newTestConfig("https://unused.example.com") - var wrapperRan bool - opts := Options{ - WrapRoute: map[string]func(http.Handler) http.Handler{ - "GET /healthz": func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - wrapperRan = true - w.Header().Set("X-Wrapped", "true") - next.ServeHTTP(w, r) // delegates to the original handleHealth - }) - }, - }, - } - srv, err := New(context.Background(), cfg, opts) - if err != nil { - t.Fatalf("New: %v", err) - } - defer srv.Close() - bff := httptest.NewServer(srv.Handler()) - defer bff.Close() - - res, err := http.Get(bff.URL + "/healthz") - if err != nil { - t.Fatalf("request: %v", err) - } - assertStatus(t, res, http.StatusOK) // original handleHealth still ran - if !wrapperRan { - t.Error("expected the wrapper to run") - } - if res.Header.Get("X-Wrapped") != "true" { - t.Error("expected the wrapper's header to be present") - } - var body map[string]string - json.NewDecoder(res.Body).Decode(&body) - if body["status"] != "ok" { - t.Errorf("expected original handleHealth body to pass through, got %v", body) - } -} - -// An ExtraRoutes handler reads identity via session.FromContext exactly like -// a default handler would — proving the shared middleware chain (not a -// second auth path) is what makes this "seamless." -func TestOptions_ExtraRoutes_SeesSessionViaContext(t *testing.T) { - tok := makeJWT(map[string]any{ - "username": "admin", "scope": "ap:project:read", - "organization": "org-123", "org_name": "Acme", "org_handle": "acme", - }) - platform := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - json.NewEncoder(w).Encode(map[string]any{"token": tok}) - })) - defer platform.Close() - - cfg := newTestConfig(platform.URL) - - var gotOrgID string - var gotOK bool - opts := Options{ - ExtraRoutes: map[string]http.Handler{ - "GET /api/environments": http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - u, ok := session.FromContext(r.Context()) - gotOK = ok - if ok && u.Org != nil { - gotOrgID = u.Org.ID - } - w.WriteHeader(http.StatusOK) - }), - }, - } - srv, err := New(context.Background(), cfg, opts) - if err != nil { - t.Fatalf("New: %v", err) - } - defer srv.Close() - bff := httptest.NewServer(srv.Handler()) - defer bff.Close() - - jar, _ := cookiejar.New(nil) - client := &http.Client{Jar: jar} - - // Unauthenticated call: no session in context, handler must see ok=false. - res, err := client.Get(bff.URL + "/api/environments") - if err != nil { - t.Fatalf("request: %v", err) - } - assertStatus(t, res, http.StatusOK) - if gotOK { - t.Error("expected ok=false for an unauthenticated request") - } - - // Log in, then call again: handler must now see the resolved org. - loginReq, _ := http.NewRequest(http.MethodPost, bff.URL+"/api/login", - strings.NewReader(`{"username":"admin","password":"secret"}`)) - loginReq.Header.Set("Content-Type", "application/json") - loginReq.Header.Set(config.CSRFHeaderName, "api-control-plane") - res, err = client.Do(loginReq) - if err != nil { - t.Fatalf("login: %v", err) - } - assertStatus(t, res, http.StatusOK) - - res, err = client.Get(bff.URL + "/api/environments") - if err != nil { - t.Fatalf("request: %v", err) - } - assertStatus(t, res, http.StatusOK) - if !gotOK { - t.Fatal("expected ok=true for an authenticated request") - } - if gotOrgID != "org-123" { - t.Errorf("org ID = %q, want org-123", gotOrgID) - } -} diff --git a/portals/api-control-plane/bff/internal/server/server.go b/portals/api-control-plane/bff/internal/server/server.go index 193dffc4c..03e4ee38d 100644 --- a/portals/api-control-plane/bff/internal/server/server.go +++ b/portals/api-control-plane/bff/internal/server/server.go @@ -70,16 +70,7 @@ type Server struct { // session store, the file-based authenticator, and (when enabled) the OIDC // authenticator — discovering the IDP endpoints up front when discovery is // configured. -// -// opts is variadic so every existing caller (this module's own main.go, its -// tests) keeps compiling unchanged; at most the first value is used. A host -// binary that embeds this module (see the exported bff/app package) passes -// one Options to add, hide, or override routes — see options.go. -func New(ctx context.Context, cfg *config.Config, opts ...Options) (*Server, error) { - var o Options - if len(opts) > 0 { - o = opts[0] - } +func New(ctx context.Context, cfg *config.Config) (*Server, error) { primaryTransport, err := proxy.NewTransport(proxy.TLSClientOptions{ CAFile: cfg.ControlPlane.CAFile, SkipVerify: cfg.ControlPlane.TLSSkipVerify, @@ -168,7 +159,7 @@ func New(ctx context.Context, cfg *config.Config, opts ...Options) (*Server, err s.oidc = o } - s.handler = s.routes(o) + s.handler = s.routes() return s, nil } @@ -207,61 +198,29 @@ func withWriteDeadline(next http.HandlerFunc) http.HandlerFunc { } // routes builds the mux and wraps it with the global middleware chain. -// -// Every default route is registered through register() rather than calling -// mux.Handle/HandleFunc directly, so opts.DisabledRoutes/RouteOverrides/ -// WrapRoute apply uniformly without touching each handler's own logic. -// opts.ExtraRoutes bypasses register() deliberately — those patterns are the -// addition, not subject to being disabled/overridden/wrapped themselves. -func (s *Server) routes(opts Options) http.Handler { +func (s *Server) routes() http.Handler { mux := http.NewServeMux() - disabled := make(map[string]bool, len(opts.DisabledRoutes)) - for _, p := range opts.DisabledRoutes { - disabled[p] = true - } - - register := func(pattern string, h http.Handler) { - if disabled[pattern] { - return - } - if override, ok := opts.RouteOverrides[pattern]; ok { - h = override - } - if wrap, ok := opts.WrapRoute[pattern]; ok { - h = wrap(h) - } - mux.Handle(pattern, h) - } - // Health (no auth, no CSRF). - register("GET /healthz", withWriteDeadline(handleHealth)) + mux.HandleFunc("GET /healthz", withWriteDeadline(handleHealth)) // Runtime config consumed by the SPA before app init. - register("GET /api-platform.env.config.js", withWriteDeadline(s.handleRuntimeConfig)) - register("GET /api-platform.common.config.js", withWriteDeadline(s.handleCommonConfig)) + mux.HandleFunc("GET /api-platform.env.config.js", withWriteDeadline(s.handleRuntimeConfig)) + mux.HandleFunc("GET /api-platform.common.config.js", withWriteDeadline(s.handleCommonConfig)) // Auth endpoints. - register("POST /api/login", withWriteDeadline(s.handleLogin)) - register("POST /api/logout", withWriteDeadline(s.handleLogout)) - register("GET /api/session", withWriteDeadline(s.handleSession)) - register("GET /api/auth/login", withWriteDeadline(s.handleOIDCLogin)) - register("GET /api/auth/callback", withWriteDeadline(s.handleOIDCCallback)) + mux.HandleFunc("POST /api/login", withWriteDeadline(s.handleLogin)) + mux.HandleFunc("POST /api/logout", withWriteDeadline(s.handleLogout)) + mux.HandleFunc("GET /api/session", withWriteDeadline(s.handleSession)) + mux.HandleFunc("GET /api/auth/login", withWriteDeadline(s.handleOIDCLogin)) + mux.HandleFunc("GET /api/auth/callback", withWriteDeadline(s.handleOIDCCallback)) // Same-origin reverse proxy(ies): the primary control plane, plus any // named upstream. Each Rewrite hook already strips its own prefix, so the // subtree is registered directly. Deliberately NOT wrapped in // withWriteDeadline — see its doc comment. for _, p := range s.proxies { - register(p.prefix+"/", s.proxyHandler(p.rp)) - } - - // Host-supplied additions. Registered directly (not through register()) - // since Disabled/RouteOverrides/WrapRoute target default routes, not - // these — they run through the same middleware chain as every route - // above regardless. - for pattern, h := range opts.ExtraRoutes { - mux.Handle(pattern, h) + mux.HandleFunc(p.prefix+"/", s.proxyHandler(p.rp)) } // SPA static files + client-side routing fallback. Must be registered @@ -274,7 +233,6 @@ func (s *Server) routes(opts Options) http.Handler { logRequests, s.securityHeaders, s.requireCSRF, - s.sessionContext, ) } diff --git a/portals/api-control-plane/bff/internal/session/context.go b/portals/api-control-plane/bff/internal/session/context.go deleted file mode 100644 index d389a386d..000000000 --- a/portals/api-control-plane/bff/internal/session/context.go +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the - * License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -package session - -import "context" - -// contextKey is unexported so no other package can construct a colliding key. -type contextKey struct{} - -// WithContext returns a copy of ctx carrying the resolved session User. -func WithContext(ctx context.Context, u User) context.Context { - return context.WithValue(ctx, contextKey{}, u) -} - -// FromContext returns the session User stashed by the BFF's session -// middleware (server.sessionContext), if the request was authenticated. Any -// handler registered on the server's mux — a default route or a host-supplied -// one via server.Options.ExtraRoutes — can call this instead of re-deriving -// identity from the cookie itself. -func FromContext(ctx context.Context) (User, bool) { - u, ok := ctx.Value(contextKey{}).(User) - return u, ok -} From e691dfc1f957ecbdd3358ed007c7d8ad77adbcf1 Mon Sep 17 00:00:00 2001 From: Lasantha Samarakoon Date: Thu, 13 Aug 2026 15:54:42 +0530 Subject: [PATCH 6/6] Fix review suggestions --- .github/workflows/apip-api-control-plane-release.yml | 9 ++++++++- portals/api-control-plane/src/main.tsx | 7 ++++++- .../src/navigation/useNavigationItems.ts | 11 ++++++++++- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/.github/workflows/apip-api-control-plane-release.yml b/.github/workflows/apip-api-control-plane-release.yml index b2cacd83d..775824484 100644 --- a/.github/workflows/apip-api-control-plane-release.yml +++ b/.github/workflows/apip-api-control-plane-release.yml @@ -26,6 +26,10 @@ jobs: env: VERSION: ${{ inputs.version }} run: | + if [ -z "$(printf '%s' "${VERSION}" | tr -d '[:space:]')" ] || [[ "${VERSION}" == *-SNAPSHOT ]]; then + echo "Error: version must be a non-snapshot release version" + exit 1 + fi make -C portals/api-control-plane version-set "VERSION=${VERSION}" - name: Set up Node.js @@ -42,9 +46,12 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 with: - install: true driver: docker-container + - name: Install frontend dependencies + working-directory: portals/api-control-plane + run: npm ci + - name: Run tests run: make test-api-control-plane diff --git a/portals/api-control-plane/src/main.tsx b/portals/api-control-plane/src/main.tsx index d5874799f..fdc1d8067 100644 --- a/portals/api-control-plane/src/main.tsx +++ b/portals/api-control-plane/src/main.tsx @@ -29,7 +29,12 @@ loadRuntimeConfigScripts() }) .finally(async () => { const { default: App } = await import('./App'); - const { cloudExtensions } = await import('./cloud'); + const cloudExtensions = await import('./cloud') + .then((module) => module.cloudExtensions) + .catch((error) => { + console.warn('Cloud extensions could not be loaded.', error); + return []; + }); root.render( diff --git a/portals/api-control-plane/src/navigation/useNavigationItems.ts b/portals/api-control-plane/src/navigation/useNavigationItems.ts index e395df316..31bf338ba 100644 --- a/portals/api-control-plane/src/navigation/useNavigationItems.ts +++ b/portals/api-control-plane/src/navigation/useNavigationItems.ts @@ -54,7 +54,9 @@ export const useNavigationItems = (): NavigationItem[] => { // pipeline instead of a parallel "Cloud category" implementation. const extensionDefinitions: NavigationDefinition[] = extensions.map( (extension) => { + const isDescendantRoute = extension.routePath.endsWith('/*'); const routeSuffix = extension.routePath.replace(/\/\*$/, ''); + const routeSegment = `/${routeSuffix}`; return { group: extension.group, icon: extension.icon, @@ -62,7 +64,14 @@ export const useNavigationItems = (): NavigationItem[] => { isVisible: extension.isVisible, label: extension.label, level: extension.level, - match: (pathname) => pathname.includes(`/${routeSuffix}`), + match: (pathname) => { + const index = pathname.indexOf(routeSegment); + if (index === -1) return false; + const charAfter = pathname[index + routeSegment.length]; + // Match only a complete path segment: nothing after it, or (for + // a `/*` route) a further `/` continuing into a descendant path. + return charAfter === undefined || (isDescendantRoute && charAfter === '/'); + }, order: extension.order, to: (navScope) => { const { orgHandle, projectHandler, apiHandler } = navScope.params;