Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 6 additions & 11 deletions apps/customer-portal/backend-v2/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,13 @@ AI_CHAT_AGENT_BASE_URL=
AI_CHAT_AGENT_SCOPES=
AI_CHAT_AGENT_WS_BASE_URL=
AI_CHAT_AGENT_WS_SCOPES=
# Comma-separated browser Origins allowed to open GET /ws (defense in depth
# against cross-site WebSocket hijacking). Leave unset to allow any origin —
# local development only.
WS_ALLOWED_ORIGINS=

# Product-consumption services — not entity-service. The Ballerina backend
# configures these as two independently-configurable base URLs
# (product_consumption_subscription vs product_consumption_tracking), so set
# both here too. PRODUCT_CONSUMPTION_TRACKING_BASE_URL falls back to
# PRODUCT_CONSUMPTION_BASE_URL when left unset — only needed if the two
# services are hosted separately.
PRODUCT_CONSUMPTION_BASE_URL=
# Product-consumption services — not entity-service. These are two
# independently-configurable base URLs (subscription/license vs usage
# tracking), so set both here too. PRODUCT_CONSUMPTION_TRACKING_BASE_URL
# falls back to PRODUCT_CONSUMPTION_SUBSCRIPTION_URL when left unset — only
# needed if the two services are hosted separately.
PRODUCT_CONSUMPTION_SUBSCRIPTION_URL=
PRODUCT_CONSUMPTION_TRACKING_BASE_URL=
PRODUCT_CONSUMPTION_SCOPES=

Expand Down
302 changes: 160 additions & 142 deletions apps/customer-portal/backend-v2/CLAUDE.md

Large diffs are not rendered by default.

17 changes: 8 additions & 9 deletions apps/customer-portal/backend-v2/README.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
# Customer Portal Backend (v2)

Go rewrite of the Ballerina backend at `apps/customer-portal/backend`. It is a backend-for-frontend
Rewrite of the existing backend at `apps/customer-portal/backend`. It is a backend-for-frontend
(BFF) for the customer portal: it authenticates callers, forwards requests to
[`entity-service`](../../../entity-service) (this repo's `cs-tools/entity-service`, not the
`digiops-cs/entity-service` the Ballerina backend targets), and shapes the responses for the frontend.
`digiops-cs/entity-service` the existing backend targets), and shapes the responses for the frontend.

This is a work in progress — only the 101 routes listed below are implemented so far, across
entity-service, the WSO2 Updates service, SCIM, the AI chat agent, the product-consumption
service, the registry (robot-account) service, and the project-contact onboarding service (six
more separate services — see [CLAUDE.md](./CLAUDE.md#the-ai-chat-agent),
[CLAUDE.md](./CLAUDE.md#the-product-consumption-service),
[CLAUDE.md](./CLAUDE.md#the-registry-service), and
[CLAUDE.md](./CLAUDE.md#the-project-contact-onboarding-service)). Everything else the Ballerina
[CLAUDE.md](./CLAUDE.md#the-project-contact-onboarding-service)). Everything else the existing
backend exposes still needs a Go handler; add them following the pattern described in
[CLAUDE.md](./CLAUDE.md#adding-a-new-endpoint).

Expand Down Expand Up @@ -132,18 +132,17 @@ A separate Python service (not entity-service) — see [CLAUDE.md](./CLAUDE.md#t
| `AI_CHAT_AGENT_SCOPES` | Comma-separated OAuth2 scopes (optional) |
| `AI_CHAT_AGENT_WS_BASE_URL` | Base URL of the AI chat agent's WebSocket endpoint |
| `AI_CHAT_AGENT_WS_SCOPES` | Comma-separated OAuth2 scopes (optional) |
| `WS_ALLOWED_ORIGINS` | Comma-separated browser Origins allowed to open `GET /ws` (optional — defense in depth against cross-site WebSocket hijacking; unset allows any origin, local development only) |

### Product-consumption service

Not entity-service — see [CLAUDE.md](./CLAUDE.md#the-product-consumption-service). The Ballerina
backend configures the subscription/license API and the usage-tracking API as two independently
configurable base URLs, so set both here too.
Not entity-service — see [CLAUDE.md](./CLAUDE.md#the-product-consumption-service). The
subscription/license API and the usage-tracking API are two independently configurable base URLs,
so set both here too.

| Variable | Description |
|---|---|
| `PRODUCT_CONSUMPTION_BASE_URL` | Base URL of the subscription/license API |
| `PRODUCT_CONSUMPTION_TRACKING_BASE_URL` | Base URL of the usage-tracking API (optional — falls back to `PRODUCT_CONSUMPTION_BASE_URL` when unset) |
| `PRODUCT_CONSUMPTION_SUBSCRIPTION_URL` | Base URL of the subscription/license API |
| `PRODUCT_CONSUMPTION_TRACKING_BASE_URL` | Base URL of the usage-tracking API (optional — falls back to `PRODUCT_CONSUMPTION_SUBSCRIPTION_URL` when unset) |
| `PRODUCT_CONSUMPTION_SCOPES` | Comma-separated OAuth2 scopes (optional) |

### Registry service
Expand Down
34 changes: 17 additions & 17 deletions apps/customer-portal/backend-v2/asyncapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,10 @@ info:
separate Python service — see CLAUDE.md's "The AI chat agent" section).
This server (internal/handler/websocket.go) forwards every message from
the upstream agent to the browser verbatim, so the message shapes below
mirror the upstream agent's own contract (see
apps/customer-portal/backend/asyncapi.yaml for the Ballerina backend's
equivalent, which this is adapted from) — with one deliberate difference:
conversationId is REQUIRED on UserMessage here. Unlike the Ballerina
backend, this server cannot start a brand-new AI chat conversation
(entity-service has no createConversation yet — see CLAUDE.md), so a
mirror the upstream agent's own contract — with one deliberate
restriction: conversationId is REQUIRED on UserMessage here. This
connection only resumes an existing conversation (start a new one via
POST /projects/{id}/conversations first — see CLAUDE.md), so a
Comment thread
coderabbitai[bot] marked this conversation as resolved.
UserMessage with no conversationId gets an ErrorMessage back instead of
creating one.

Expand All @@ -51,10 +49,10 @@ channels:
format: uuid
description: >
The project ID. Named sessionId for wire compatibility with
the Ballerina backend this replaces, even though it doesn't
carry a session/conversation value itself — the upstream
agent's own per-conversation session key is derived
server-side as "{projectId}:{conversationId}".
existing clients, even though it doesn't carry a
session/conversation value itself — the upstream agent's own
per-conversation session key is derived server-side as
"{projectId}:{conversationId}".
required:
- sessionId

Expand Down Expand Up @@ -119,10 +117,12 @@ components:
type: string
format: uuid
description: >
Required — the ID of an existing conversation (obtained via
POST /projects/{projectId}/conversations/{conversationId}/messages
or another entity-service-backed flow). Omitting it returns an
ErrorMessage; this server cannot mint a new conversation ID.
Required — the ID of an existing conversation, obtained from
the response of POST /projects/{id}/conversations (which
starts a new conversation) or GET /conversations/{id}. This
WebSocket connection only resumes a conversation; it never
mints a new conversation ID itself. Omitting conversationId
returns an ErrorMessage.
envProducts:
type: object
nullable: true
Expand Down Expand Up @@ -334,9 +334,9 @@ components:
type: boolean
nullable: true
description: >
When true, the Ballerina backend marks the conversation
resolved in entity-service; this server does not (no
updateConversation yet — see CLAUDE.md).
When true, this server marks the conversation resolved in
entity-service (best-effort — a failure here is logged,
not surfaced to the client).
kbReferences:
type: array
nullable: true
Expand Down
57 changes: 29 additions & 28 deletions apps/customer-portal/backend-v2/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,7 @@ func main() {

// The AI chat agent is a separate Python service (not entity-service),
// but authenticates as the same shared OAuth2 client-credentials app as
// entity/updates/scim above (see the Ballerina backend's Config.toml,
// where every module — including ai_chat_agent's WebSocket variant —
// reuses the same clientId/tokenUrl); only its base URLs differ.
// entity/updates/scim above; only its base URLs differ.
aiChatAgentCfg := aichatagent.Config{
BaseURL: mustEnv("AI_CHAT_AGENT_BASE_URL"),
TokenURL: oauth2TokenURL,
Expand All @@ -105,19 +103,17 @@ func main() {

// The product-consumption service(s) are separate services (not
// entity-service) that provision deployment licenses and import usage
// data; both authenticate as the same shared OAuth2 app. The Ballerina
// backend configures these as two independently-configurable base URLs
// (productConsumptionBaseUrl vs productConsumptionTrackingBaseUrl) —
// PRODUCT_CONSUMPTION_TRACKING_BASE_URL defaults to
// PRODUCT_CONSUMPTION_BASE_URL when unset, matching that backend's
// current config where both happen to point at the same host.
// data; both authenticate as the same shared OAuth2 app. These are two
// independently-configurable base URLs — PRODUCT_CONSUMPTION_TRACKING_BASE_URL
// defaults to PRODUCT_CONSUMPTION_SUBSCRIPTION_URL when unset, for
// deployments where both happen to point at the same host.
productConsumptionCfg := productconsumption.Config{
BaseURL: mustEnv("PRODUCT_CONSUMPTION_BASE_URL"),
TrackingBaseURL: os.Getenv("PRODUCT_CONSUMPTION_TRACKING_BASE_URL"),
TokenURL: oauth2TokenURL,
ClientID: oauth2ClientID,
ClientSecret: oauth2ClientSecret,
Scopes: splitComma(os.Getenv("PRODUCT_CONSUMPTION_SCOPES")),
SubscriptionBaseURL: mustEnv("PRODUCT_CONSUMPTION_SUBSCRIPTION_URL"),
TrackingBaseURL: os.Getenv("PRODUCT_CONSUMPTION_TRACKING_BASE_URL"),
TokenURL: oauth2TokenURL,
ClientID: oauth2ClientID,
ClientSecret: oauth2ClientSecret,
Scopes: splitComma(os.Getenv("PRODUCT_CONSUMPTION_SCOPES")),
}
productConsumptionClient := productconsumption.NewClient(productConsumptionCfg)

Expand Down Expand Up @@ -154,8 +150,7 @@ func main() {
}

// adminRole is the role string (from entity.GetUserMeResponse.Roles) that
// grants admin privileges for registry-token and contact management —
// mirrors the Ballerina reference's configurable authorizedRoles.adminRole.
// grants admin privileges for registry-token and contact management.
adminRole := mustEnv("AUTH_ADMIN_ROLE")

userHandler := handler.NewUserHandler(entityClient, scimClient)
Expand All @@ -175,7 +170,7 @@ func main() {
catalogHandler := handler.NewCatalogHandler(entityClient)
timeCardHandler := handler.NewTimeCardHandler(entityClient)
aiChatHandler := handler.NewAIChatHandler(aiChatAgentClient, entityClient)
webSocketHandler := handler.NewWebSocketHandler(aiChatAgentWsClient, entityClient, splitComma(os.Getenv("WS_ALLOWED_ORIGINS")))
webSocketHandler := handler.NewWebSocketHandler(aiChatAgentWsClient, entityClient, nil)
Comment thread
Rashmika998 marked this conversation as resolved.
productConsumptionHandler := handler.NewProductConsumptionHandler(productConsumptionClient, entityClient)
Comment thread
Rashmika998 marked this conversation as resolved.
globalHandler := handler.NewGlobalHandler(entityClient)
instanceHandler := handler.NewInstanceHandler(entityClient)
Expand Down Expand Up @@ -261,10 +256,11 @@ func main() {
// registered as literal patterns: net/http.ServeMux (Go 1.22+) rejects
// them as ambiguous (neither is more specific than the other — e.g. both
// match "/deployments/products/products/instances/metrics/search") and
// panics at startup. Both path shapes are genuine, distinct Ballerina
// reference routes, so they're merged under one wildcard pattern and
// dispatched by dispatchDeploymentsProductsMetricsSearch below instead of
// renaming either one.
// panics at startup. Both path shapes are genuine, distinct routes this
// backend must expose exactly as-is, so they're merged under one
// wildcard pattern and dispatched by
// dispatchDeploymentsProductsMetricsSearch below instead of renaming
// either one.
mux.HandleFunc("POST /deployments/{seg1}/{seg2}/{seg3}/metrics/search",
dispatchDeploymentsProductsMetricsSearch(instanceHandler, deployedProductHandler))
mux.HandleFunc("POST /deployments/{deploymentId}/products/{productId}/metrics/usage-counts/search", deployedProductHandler.SearchDeployedProductUsageCounts)
Expand Down Expand Up @@ -350,10 +346,15 @@ func main() {
slog.Info("Customer Portal Backend (v2) started", "addr", addr)

srv := &http.Server{
Handler: middleware.SecurityHeaders(
middleware.CorrelationID(
middleware.Auth(authCfg)(
middleware.Logger(mux),
// CORS must be outermost: a preflight OPTIONS request carries no JWT,
// so if Auth ran first it would reject every preflight with 401
// before the browser ever saw a CORS header.
Handler: middleware.CORS(nil)(
Comment thread
Rashmika998 marked this conversation as resolved.
middleware.SecurityHeaders(
middleware.CorrelationID(
middleware.Auth(authCfg)(
middleware.Logger(mux),
),
),
),
),
Expand Down Expand Up @@ -385,8 +386,8 @@ func main() {
slog.Info("Customer Portal Backend (v2) stopped")
}

// dispatchDeploymentsProductsMetricsSearch resolves the two distinct
// Ballerina reference routes merged under the "POST
// dispatchDeploymentsProductsMetricsSearch resolves the two distinct routes
// merged under the "POST
// /deployments/{seg1}/{seg2}/{seg3}/metrics/search" pattern registered in
// main() (see the comment at that registration for why they can't be
// registered as separate literal patterns). Exactly one of the two shapes
Expand Down
10 changes: 2 additions & 8 deletions apps/customer-portal/backend-v2/internal/aichatagent/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,7 @@
// Package aichatagent is the HTTP client for the upstream AI chat agent — a
// separate Python service (not entity-service) that powers the customer
// portal's AI chat feature: case classification, chat responses, and KB
// article recommendations. See apps/customer-portal/backend's
// modules/ai_chat_agent for the Ballerina backend's equivalent client.
// article recommendations.
package aichatagent

import (
Expand Down Expand Up @@ -142,12 +141,7 @@ func (c *Client) do(ctx context.Context, method, path string, body []byte) ([]by
}

if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
const maxErrBody = 256
excerpt := respBody
if len(excerpt) > maxErrBody {
excerpt = excerpt[:maxErrBody]
}
return nil, &apierror.Error{StatusCode: resp.StatusCode, Body: string(excerpt)}
return nil, apierror.NewUpstreamError(resp.StatusCode, respBody)
}

return respBody, nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,7 @@
package aichatagent

// These types mirror the upstream Python AI chat agent's wire format 1:1
// (see apps/customer-portal/backend's modules/ai_chat_agent/types.bal, the
// Ballerina backend this is rewriting) so json.Unmarshal can decode its
// responses directly.
// so json.Unmarshal can decode its responses directly.

// CaseClassificationPayload is the input for POST /case_classification.
type CaseClassificationPayload struct {
Expand Down
5 changes: 2 additions & 3 deletions apps/customer-portal/backend-v2/internal/aichatagent/ws.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,8 @@ const maxMessageBytes = 64 << 10 // 64 KiB
const idleTimeout = 5 * time.Minute

// WSConfig holds the configuration for dialing the upstream AI chat agent's
// WebSocket endpoint. Kept separate from Config since the Ballerina backend
// this is rewriting uses a distinct OAuth2 client-credentials configuration
// for its WebSocket connection.
// WebSocket endpoint. Kept separate from Config because the WebSocket
// connection uses its own distinct OAuth2 client-credentials configuration.
type WSConfig struct {
BaseURL string
TokenURL string
Expand Down
30 changes: 29 additions & 1 deletion apps/customer-portal/backend-v2/internal/apierror/apierror.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@
// HTTP failures back through the client layer to the handler layer.
package apierror

import "fmt"
import (
"encoding/json"
"fmt"
)

// Error wraps a non-2xx response from an upstream service call.
type Error struct {
Expand All @@ -29,3 +32,28 @@ type Error struct {
func (e *Error) Error() string {
return fmt.Sprintf("upstream returned %d: %s", e.StatusCode, e.Body)
}

// upstreamErrorBody is the {"message": "..."} shape every upstream service
// this backend calls uses for its own error responses (entity-service's is a
// superset, {"code":...,"message":"..."}, which unmarshals the same way).
type upstreamErrorBody struct {
Message string `json:"message"`
}

// NewUpstreamError builds an *Error from a non-2xx upstream HTTP response.
// Body is set to the upstream's own "message" field when the response is the
// expected {"message": "..."} shape, and left empty otherwise. Every upstream
// client in this backend must construct its errors through this function
// rather than falling back to a raw response excerpt: callers already treat
// an empty Body as "no specific message available" (both mapUpstreamError's
// 400 case and writeUpstreamMessage fall back to a fixed message), so a raw
// excerpt is never necessary — and logging or returning one to the frontend
// risks leaking unbounded, non-message upstream content (e.g. a gateway HTML
// error page).
func NewUpstreamError(statusCode int, rawBody []byte) *Error {
var body upstreamErrorBody
if err := json.Unmarshal(rawBody, &body); err == nil && body.Message != "" {
return &Error{StatusCode: statusCode, Body: body.Message}
}
return &Error{StatusCode: statusCode}
}
60 changes: 60 additions & 0 deletions apps/customer-portal/backend-v2/internal/apierror/apierror_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// 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 apierror

import (
"net/http"
"testing"
)

func TestNewUpstreamError_ExtractsMessageField(t *testing.T) {
raw := []byte(`{"code":400,"message":"caseTypes must be valid UUIDs"}`)

err := NewUpstreamError(http.StatusBadRequest, raw)

if err.StatusCode != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", err.StatusCode)
}
if err.Body != "caseTypes must be valid UUIDs" {
t.Fatalf("expected extracted message, got %q", err.Body)
}
}

// TestNewUpstreamError_LeavesBodyEmptyWhenNotJSON guards against ever
// logging or returning to the frontend a raw, unbounded upstream response
// body (e.g. a gateway error page) — Body must stay empty so callers'
// existing "empty Body means no specific message" fallback kicks in,
// instead of surfacing arbitrary upstream content.
func TestNewUpstreamError_LeavesBodyEmptyWhenNotJSON(t *testing.T) {
raw := []byte("<html>502 Bad Gateway</html>")

err := NewUpstreamError(http.StatusBadGateway, raw)

if err.Body != "" {
t.Fatalf("expected empty Body for a non-JSON response, got %q", err.Body)
}
}

func TestNewUpstreamError_LeavesBodyEmptyWhenMessageFieldMissing(t *testing.T) {
raw := []byte(`{"code":500}`)

err := NewUpstreamError(http.StatusInternalServerError, raw)

if err.Body != "" {
t.Fatalf("expected empty Body when message field is absent, got %q", err.Body)
}
}
Loading