From 6c2423299599109da19721c670c135a331b88e6f Mon Sep 17 00:00:00 2001 From: Richard Courtman Date: Thu, 26 Mar 2026 22:45:35 +0000 Subject: [PATCH] fix(cloudcp): proxy portal commercial actions same-origin --- .../v6/internal/PULSE_ACCOUNT_PORTAL_SPEC.md | 4 +- .../v6/internal/subsystems/api-contracts.md | 6 ++ internal/cloudcp/portal/bootstrap.go | 52 ++++----- internal/cloudcp/portal/commercial_proxy.go | 100 ++++++++++++++++++ .../cloudcp/portal/dist/build_manifest.json | 2 +- internal/cloudcp/portal/dist/portal_app.js | 4 +- .../frontend/src/account_runtime.test.ts | 2 +- .../cloudcp/portal/frontend/src/api.test.ts | 2 +- internal/cloudcp/portal/frontend/src/api.ts | 2 +- .../cloudcp/portal/frontend/src/app.test.ts | 10 +- .../frontend/src/auth_controller.test.ts | 2 +- .../portal/frontend/src/runtime.test.ts | 6 +- .../cloudcp/portal/frontend/src/runtime.ts | 2 +- .../portal/frontend/src/services_view.test.ts | 2 +- .../portal/frontend/src/shell_view.test.ts | 2 +- .../cloudcp/portal/frontend/src/store.test.ts | 2 +- internal/cloudcp/portal/frontend/src/types.ts | 2 +- internal/cloudcp/portal/handlers_test.go | 65 +++++++++++- internal/cloudcp/portal/page.go | 13 ++- internal/cloudcp/portal/paths.go | 22 ++-- internal/cloudcp/routes.go | 3 + internal/cloudcp/routes_auth_test.go | 7 ++ 22 files changed, 245 insertions(+), 67 deletions(-) create mode 100644 internal/cloudcp/portal/commercial_proxy.go diff --git a/docs/release-control/v6/internal/PULSE_ACCOUNT_PORTAL_SPEC.md b/docs/release-control/v6/internal/PULSE_ACCOUNT_PORTAL_SPEC.md index 51431ad21e..2baccf0350 100644 --- a/docs/release-control/v6/internal/PULSE_ACCOUNT_PORTAL_SPEC.md +++ b/docs/release-control/v6/internal/PULSE_ACCOUNT_PORTAL_SPEC.md @@ -251,7 +251,9 @@ canonical frontend state seam for: 1. account identity context 2. hosted account and workspace summaries -3. public/commercial edge URLs needed by the account shell +3. public-site URLs plus same-origin portal route paths for commercial actions, + so the browser shell can stay behind the control-plane CSP instead of + calling shared license APIs cross-origin 4. signed-out versus signed-in shell state, so login, session expiry, and authenticated account runtime all inherit one owned page contract instead of separate server-rendered templates diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 5c9db88038..6d08b32198 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -214,6 +214,12 @@ account identity context, signed-out versus signed-in shell state, workspace summaries, and renderer-owned public, commercial, and control-plane route configuration, including the canonical bootstrap route path, magic-link request path, signup path, and stable workspace summary fields such as `created_at`. +Commercial self-service actions in that shell must stay same-origin as well: +the frontend may only call the portal-owned `/api/portal/commercial/*` routes, +and `internal/cloudcp/portal/commercial_proxy.go` plus `internal/cloudcp/routes.go` +own the server-side proxy boundary to the shared license/commercial APIs so +the browser runtime does not widen control-plane CSP with direct cross-origin +commercial fetches. `/portal` is now one bootstrap-driven shell for both anonymous and authenticated users, so new account frontend work must extend that shared contract rather than inventing a second local payload shape, reviving separate diff --git a/internal/cloudcp/portal/bootstrap.go b/internal/cloudcp/portal/bootstrap.go index e947e496c9..898d8674cf 100644 --- a/internal/cloudcp/portal/bootstrap.go +++ b/internal/cloudcp/portal/bootstrap.go @@ -26,19 +26,19 @@ type BootstrapAccount struct { } type BootstrapData struct { - Authenticated bool `json:"authenticated"` - Email string `json:"email"` - PublicSiteURL string `json:"public_site_url"` - SupportEmail string `json:"support_email"` - CommercialAPIBaseURL string `json:"commercial_api_base_url"` - PortalPath string `json:"portal_path"` - BootstrapPath string `json:"bootstrap_path"` - MagicLinkRequestPath string `json:"magic_link_request_path"` - SignupPath string `json:"signup_path"` - LogoutPath string `json:"logout_path"` - AccountAPIBasePath string `json:"account_api_base_path"` - PortalAPIBasePath string `json:"portal_api_base_path"` - Accounts []BootstrapAccount `json:"accounts"` + Authenticated bool `json:"authenticated"` + Email string `json:"email"` + PublicSiteURL string `json:"public_site_url"` + SupportEmail string `json:"support_email"` + CommercialAPIBasePath string `json:"commercial_api_base_path"` + PortalPath string `json:"portal_path"` + BootstrapPath string `json:"bootstrap_path"` + MagicLinkRequestPath string `json:"magic_link_request_path"` + SignupPath string `json:"signup_path"` + LogoutPath string `json:"logout_path"` + AccountAPIBasePath string `json:"account_api_base_path"` + PortalAPIBasePath string `json:"portal_api_base_path"` + Accounts []BootstrapAccount `json:"accounts"` } func MarshalBootstrapJSON(data BootstrapData) (template.JS, error) { @@ -75,19 +75,19 @@ func BuildBootstrapData(authenticated bool, email string, accounts []portalPageA } return BootstrapData{ - Authenticated: authenticated, - Email: email, - PublicSiteURL: defaultPublicSiteURL, - SupportEmail: defaultSupportEmail, - CommercialAPIBaseURL: defaultCommercialAPIBaseURL, - PortalPath: defaultPortalPath, - BootstrapPath: PortalBootstrapPath, - MagicLinkRequestPath: PortalMagicLinkRequestPath, - SignupPath: PortalSignupPath, - LogoutPath: defaultLogoutPath, - AccountAPIBasePath: defaultAccountAPIBasePath, - PortalAPIBasePath: defaultPortalAPIBasePath, - Accounts: bootstrapAccounts, + Authenticated: authenticated, + Email: email, + PublicSiteURL: defaultPublicSiteURL, + SupportEmail: defaultSupportEmail, + CommercialAPIBasePath: PortalCommercialAPIBasePath, + PortalPath: defaultPortalPath, + BootstrapPath: PortalBootstrapPath, + MagicLinkRequestPath: PortalMagicLinkRequestPath, + SignupPath: PortalSignupPath, + LogoutPath: defaultLogoutPath, + AccountAPIBasePath: defaultAccountAPIBasePath, + PortalAPIBasePath: defaultPortalAPIBasePath, + Accounts: bootstrapAccounts, } } diff --git a/internal/cloudcp/portal/commercial_proxy.go b/internal/cloudcp/portal/commercial_proxy.go new file mode 100644 index 0000000000..c14409bda8 --- /dev/null +++ b/internal/cloudcp/portal/commercial_proxy.go @@ -0,0 +1,100 @@ +package portal + +import ( + "bytes" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const defaultCommercialProxyBaseURL = "https://license.pulserelay.pro" + +var allowedCommercialProxyEndpoints = map[string]string{ + "/v1/manage/request": http.MethodPost, + "/v1/manage": http.MethodPost, + "/v1/retrieve-license/request": http.MethodPost, + "/v1/retrieve-license": http.MethodPost, + "/v1/gdpr/request-export": http.MethodPost, + "/v1/gdpr/export": http.MethodPost, + "/v1/gdpr/request-delete": http.MethodPost, + "/v1/gdpr/confirm-delete": http.MethodPost, + "/v1/self-refund": http.MethodPost, +} + +type CommercialProxyConfig struct { + BaseURL string +} + +func HandleCommercialProxy(cfg CommercialProxyConfig) http.HandlerFunc { + baseURL := strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/") + if baseURL == "" { + baseURL = defaultCommercialProxyBaseURL + } + + client := &http.Client{Timeout: 15 * time.Second} + + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + targetPath := normalizeCommercialProxyPath(r.PathValue("commercial_path")) + expectedMethod, ok := allowedCommercialProxyEndpoints[targetPath] + if !ok || expectedMethod != r.Method { + http.Error(w, "Not found", http.StatusNotFound) + return + } + + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + upstreamURL, err := url.Parse(baseURL + targetPath) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + req, err := http.NewRequestWithContext(r.Context(), r.Method, upstreamURL.String(), bytes.NewReader(body)) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if contentType := strings.TrimSpace(r.Header.Get("Content-Type")); contentType != "" { + req.Header.Set("Content-Type", contentType) + } + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + w.Header().Set("Content-Type", "application/json") + http.Error(w, fmt.Sprintf(`{"error":"%s"}`, "commercial_proxy_unavailable"), http.StatusBadGateway) + return + } + defer resp.Body.Close() + + if contentType := strings.TrimSpace(resp.Header.Get("Content-Type")); contentType != "" { + w.Header().Set("Content-Type", contentType) + } + w.WriteHeader(resp.StatusCode) + _, _ = io.Copy(w, io.LimitReader(resp.Body, 1<<20)) + } +} + +func normalizeCommercialProxyPath(raw string) string { + raw = "/" + strings.TrimSpace(raw) + raw = strings.ReplaceAll(raw, "\\", "/") + for strings.Contains(raw, "//") { + raw = strings.ReplaceAll(raw, "//", "/") + } + if raw == "/" { + return "" + } + return raw +} diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json index 8ea01b3b52..6dd5db5939 100644 --- a/internal/cloudcp/portal/dist/build_manifest.json +++ b/internal/cloudcp/portal/dist/build_manifest.json @@ -1,5 +1,5 @@ { - "source_hash": "6c3fac3d004ef32e5bee17d4f8f80a569a64e0ee8582c417d79c7c537cce8d6d", + "source_hash": "f6a8fe4433646b19222e045466d685cd413111f5af747e80af1ff31d4cf5d177", "build_inputs": [ "package.json", "tsconfig.json", diff --git a/internal/cloudcp/portal/dist/portal_app.js b/internal/cloudcp/portal/dist/portal_app.js index 6a9c5f917d..8ecb91a207 100644 --- a/internal/cloudcp/portal/dist/portal_app.js +++ b/internal/cloudcp/portal/dist/portal_app.js @@ -293,7 +293,7 @@ }, "Failed to sign out."); }, postCommercialJSON: function(path, body) { - return request(bootstrap().commercial_api_base_url + path, { + return request(bootstrap().commercial_api_base_path + path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) @@ -1634,7 +1634,7 @@ return { public_site_url: embeddedBootstrap.public_site_url || "https://pulserelay.pro", support_email: embeddedBootstrap.support_email || "support@pulserelay.pro", - commercial_api_base_url: embeddedBootstrap.commercial_api_base_url || "", + commercial_api_base_path: embeddedBootstrap.commercial_api_base_path || "/api/portal/commercial", portal_path: embeddedBootstrap.portal_path || "/portal", bootstrap_path: embeddedBootstrap.bootstrap_path || "/api/portal/bootstrap", magic_link_request_path: embeddedBootstrap.magic_link_request_path || "/api/public/magic-link/request", diff --git a/internal/cloudcp/portal/frontend/src/account_runtime.test.ts b/internal/cloudcp/portal/frontend/src/account_runtime.test.ts index 6b31526339..a14866013a 100644 --- a/internal/cloudcp/portal/frontend/src/account_runtime.test.ts +++ b/internal/cloudcp/portal/frontend/src/account_runtime.test.ts @@ -28,7 +28,7 @@ async function flushAsync() { const bootstrapDefaults: Omit = { public_site_url: 'https://pulserelay.pro', support_email: 'support@pulserelay.pro', - commercial_api_base_url: 'https://license.pulserelay.pro', + commercial_api_base_path: '/api/portal/commercial', portal_path: '/portal', bootstrap_path: '/api/portal/bootstrap', magic_link_request_path: '/api/public/magic-link/request', diff --git a/internal/cloudcp/portal/frontend/src/api.test.ts b/internal/cloudcp/portal/frontend/src/api.test.ts index 7314a77122..6883c7bf04 100644 --- a/internal/cloudcp/portal/frontend/src/api.test.ts +++ b/internal/cloudcp/portal/frontend/src/api.test.ts @@ -8,7 +8,7 @@ const bootstrap: PortalBootstrapData = { email: 'owner@example.com', public_site_url: 'https://pulserelay.pro', support_email: 'support@pulserelay.pro', - commercial_api_base_url: 'https://license.pulserelay.pro', + commercial_api_base_path: '/api/portal/commercial', portal_path: '/portal', bootstrap_path: '/api/portal/bootstrap', magic_link_request_path: '/api/public/magic-link/request', diff --git a/internal/cloudcp/portal/frontend/src/api.ts b/internal/cloudcp/portal/frontend/src/api.ts index f83d98a921..ef98e15328 100644 --- a/internal/cloudcp/portal/frontend/src/api.ts +++ b/internal/cloudcp/portal/frontend/src/api.ts @@ -142,7 +142,7 @@ export function createPortalAPI(context: PortalAPIContext): PortalAPI { }, 'Failed to sign out.'); }, postCommercialJSON: function(path: string, body: Record) { - return request(bootstrap().commercial_api_base_url + path, { + return request(bootstrap().commercial_api_base_path + path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), diff --git a/internal/cloudcp/portal/frontend/src/app.test.ts b/internal/cloudcp/portal/frontend/src/app.test.ts index ef5a723f51..304b4447e0 100644 --- a/internal/cloudcp/portal/frontend/src/app.test.ts +++ b/internal/cloudcp/portal/frontend/src/app.test.ts @@ -27,7 +27,7 @@ async function flushAsync() { const bootstrapDefaults: Omit = { public_site_url: 'https://pulserelay.pro', support_email: 'support@pulserelay.pro', - commercial_api_base_url: 'https://license.pulserelay.pro', + commercial_api_base_path: '/api/portal/commercial', portal_path: '/portal', bootstrap_path: '/api/portal/bootstrap', magic_link_request_path: '/api/public/magic-link/request', @@ -242,10 +242,10 @@ describe('portal app', function() { accounts: accounts, }); } - if (url === 'https://license.pulserelay.pro/v1/retrieve-license/request') { + if (url === '/api/portal/commercial/v1/retrieve-license/request') { return jsonResponse({}); } - if (url === 'https://license.pulserelay.pro/v1/retrieve-license') { + if (url === '/api/portal/commercial/v1/retrieve-license') { return jsonResponse({ license: { token: 'pulse_token_123', @@ -284,14 +284,14 @@ describe('portal app', function() { await flushAsync(); expect(fetchMock.mock.calls).toContainEqual([ - 'https://license.pulserelay.pro/v1/retrieve-license/request', + '/api/portal/commercial/v1/retrieve-license/request', expect.objectContaining({ method: 'POST', body: JSON.stringify({ email: 'owner@example.com' }), }), ]); expect(fetchMock.mock.calls).toContainEqual([ - 'https://license.pulserelay.pro/v1/retrieve-license', + '/api/portal/commercial/v1/retrieve-license', expect.objectContaining({ method: 'POST', body: JSON.stringify({ email: 'owner@example.com', code: '123456' }), diff --git a/internal/cloudcp/portal/frontend/src/auth_controller.test.ts b/internal/cloudcp/portal/frontend/src/auth_controller.test.ts index 897417e3c8..8c0bf9d6fb 100644 --- a/internal/cloudcp/portal/frontend/src/auth_controller.test.ts +++ b/internal/cloudcp/portal/frontend/src/auth_controller.test.ts @@ -17,7 +17,7 @@ async function flushAsync() { const bootstrapDefaults: Omit = { public_site_url: 'https://pulserelay.pro', support_email: 'support@pulserelay.pro', - commercial_api_base_url: 'https://license.pulserelay.pro', + commercial_api_base_path: '/api/portal/commercial', portal_path: '/portal', bootstrap_path: '/api/portal/bootstrap', magic_link_request_path: '/magic-link', diff --git a/internal/cloudcp/portal/frontend/src/runtime.test.ts b/internal/cloudcp/portal/frontend/src/runtime.test.ts index 5aeceb1651..258431c1f7 100644 --- a/internal/cloudcp/portal/frontend/src/runtime.test.ts +++ b/internal/cloudcp/portal/frontend/src/runtime.test.ts @@ -7,7 +7,7 @@ describe('portal runtime', function() { var defaults = createBootstrapDefaults({ public_site_url: 'https://cloud.pulserelay.pro', support_email: 'help@pulserelay.pro', - commercial_api_base_url: 'https://license.pulserelay.pro', + commercial_api_base_path: '/api/portal/commercial', portal_path: '/account', bootstrap_path: '/api/account/bootstrap', magic_link_request_path: '/api/public/account/magic-link/request', @@ -28,12 +28,12 @@ describe('portal runtime', function() { authenticated: true, email: 'owner@example.com', accounts: [], - commercial_api_base_url: 'https://license.pulserelay.pro', + commercial_api_base_path: '/api/portal/commercial', }); expect(runtime.store.getBootstrap().authenticated).toBe(true); expect(runtime.store.getBootstrap().email).toBe('owner@example.com'); - expect(runtime.bootstrapDefaults.commercial_api_base_url).toBe('https://license.pulserelay.pro'); + expect(runtime.bootstrapDefaults.commercial_api_base_path).toBe('/api/portal/commercial'); }); it('derives canonical email and service handoff from the portal URL', function() { diff --git a/internal/cloudcp/portal/frontend/src/runtime.ts b/internal/cloudcp/portal/frontend/src/runtime.ts index 6c4f47fe31..3bb82e9672 100644 --- a/internal/cloudcp/portal/frontend/src/runtime.ts +++ b/internal/cloudcp/portal/frontend/src/runtime.ts @@ -66,7 +66,7 @@ export function createBootstrapDefaults( return { public_site_url: embeddedBootstrap.public_site_url || 'https://pulserelay.pro', support_email: embeddedBootstrap.support_email || 'support@pulserelay.pro', - commercial_api_base_url: embeddedBootstrap.commercial_api_base_url || '', + commercial_api_base_path: embeddedBootstrap.commercial_api_base_path || '/api/portal/commercial', portal_path: embeddedBootstrap.portal_path || '/portal', bootstrap_path: embeddedBootstrap.bootstrap_path || '/api/portal/bootstrap', magic_link_request_path: embeddedBootstrap.magic_link_request_path || '/api/public/magic-link/request', diff --git a/internal/cloudcp/portal/frontend/src/services_view.test.ts b/internal/cloudcp/portal/frontend/src/services_view.test.ts index 5087a20861..7d6b8892bc 100644 --- a/internal/cloudcp/portal/frontend/src/services_view.test.ts +++ b/internal/cloudcp/portal/frontend/src/services_view.test.ts @@ -18,7 +18,7 @@ function createBootstrap(overrides: Partial = {}): PortalBo email: 'owner@example.com', public_site_url: 'https://pulserelay.pro', support_email: 'support@pulserelay.pro', - commercial_api_base_url: 'https://license.pulserelay.pro', + commercial_api_base_path: '/api/portal/commercial', portal_path: '/portal', bootstrap_path: '/api/portal/bootstrap', magic_link_request_path: '/auth/magic-link', diff --git a/internal/cloudcp/portal/frontend/src/shell_view.test.ts b/internal/cloudcp/portal/frontend/src/shell_view.test.ts index 33aeb17f38..6e32fc5fd6 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.test.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.test.ts @@ -15,7 +15,7 @@ function createBootstrap(overrides: Partial = {}): PortalBo email: 'owner@example.com', public_site_url: 'https://pulserelay.pro', support_email: 'support@pulserelay.pro', - commercial_api_base_url: 'https://license.pulserelay.pro', + commercial_api_base_path: '/api/portal/commercial', portal_path: '/portal', bootstrap_path: '/api/portal/bootstrap', magic_link_request_path: '/auth/magic-link', diff --git a/internal/cloudcp/portal/frontend/src/store.test.ts b/internal/cloudcp/portal/frontend/src/store.test.ts index c8c10f2190..a755fba38a 100644 --- a/internal/cloudcp/portal/frontend/src/store.test.ts +++ b/internal/cloudcp/portal/frontend/src/store.test.ts @@ -6,7 +6,7 @@ import type { PortalBootstrapData } from './types'; const bootstrapDefaults: Omit = { public_site_url: 'https://pulserelay.pro', support_email: 'support@pulserelay.pro', - commercial_api_base_url: 'https://license.pulserelay.pro', + commercial_api_base_path: '/api/portal/commercial', portal_path: '/portal', bootstrap_path: '/api/portal/bootstrap', magic_link_request_path: '/api/public/magic-link/request', diff --git a/internal/cloudcp/portal/frontend/src/types.ts b/internal/cloudcp/portal/frontend/src/types.ts index dca82d14f9..23933b000d 100644 --- a/internal/cloudcp/portal/frontend/src/types.ts +++ b/internal/cloudcp/portal/frontend/src/types.ts @@ -22,7 +22,7 @@ export interface PortalBootstrapData { email: string; public_site_url: string; support_email: string; - commercial_api_base_url: string; + commercial_api_base_path: string; portal_path: string; bootstrap_path: string; magic_link_request_path: string; diff --git a/internal/cloudcp/portal/handlers_test.go b/internal/cloudcp/portal/handlers_test.go index 7381cc4f39..f09ea51658 100644 --- a/internal/cloudcp/portal/handlers_test.go +++ b/internal/cloudcp/portal/handlers_test.go @@ -3,6 +3,7 @@ package portal import ( "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" "sort" @@ -786,7 +787,7 @@ func TestPortalPageTemplate_AccountServicesRendered(t *testing.T) { `id="portal-app-root"`, `id="pulse-account-bootstrap"`, `"authenticated":true`, - `"commercial_api_base_url":"https://license.pulserelay.pro"`, + fmt.Sprintf(`"commercial_api_base_path":"%s"`, PortalCommercialAPIBasePath), fmt.Sprintf(`"portal_path":"%s"`, PortalPagePath), fmt.Sprintf(`"bootstrap_path":"%s"`, PortalBootstrapPath), fmt.Sprintf(`"magic_link_request_path":"%s"`, PortalMagicLinkRequestPath), @@ -863,8 +864,8 @@ func TestBuildPortalBootstrapJSON_Contract(t *testing.T) { if got := payload["email"]; got != "owner@example.com" { t.Fatalf("email = %#v, want owner@example.com", got) } - if got := payload["commercial_api_base_url"]; got != "https://license.pulserelay.pro" { - t.Fatalf("commercial_api_base_url = %#v", got) + if got := payload["commercial_api_base_path"]; got != PortalCommercialAPIBasePath { + t.Fatalf("commercial_api_base_path = %#v", got) } if got := payload["portal_path"]; got != PortalPagePath { t.Fatalf("portal_path = %#v", got) @@ -982,6 +983,64 @@ func TestHandlePortalBootstrap_Success(t *testing.T) { } } +func TestHandleCommercialProxy_ForwardsAllowedRequest(t *testing.T) { + var ( + gotMethod string + gotPath string + gotContentType string + gotBody string + ) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + gotMethod = r.Method + gotPath = r.URL.Path + gotContentType = r.Header.Get("Content-Type") + gotBody = string(body) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer upstream.Close() + + req := httptest.NewRequest(http.MethodPost, PortalCommercialAPIBasePath+"/v1/retrieve-license/request", strings.NewReader(`{"email":"owner@example.com"}`)) + req.SetPathValue("commercial_path", "v1/retrieve-license/request") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + HandleCommercialProxy(CommercialProxyConfig{BaseURL: upstream.URL}).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusOK, rec.Body.String()) + } + if gotMethod != http.MethodPost { + t.Fatalf("upstream method = %q, want %q", gotMethod, http.MethodPost) + } + if gotPath != "/v1/retrieve-license/request" { + t.Fatalf("upstream path = %q, want %q", gotPath, "/v1/retrieve-license/request") + } + if gotContentType != "application/json" { + t.Fatalf("upstream content-type = %q, want %q", gotContentType, "application/json") + } + if gotBody != `{"email":"owner@example.com"}` { + t.Fatalf("upstream body = %q", gotBody) + } + if rec.Body.String() != `{"ok":true}` { + t.Fatalf("response body = %q", rec.Body.String()) + } +} + +func TestHandleCommercialProxy_RejectsUnknownPath(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, PortalCommercialAPIBasePath+"/v1/not-allowed", strings.NewReader(`{}`)) + req.SetPathValue("commercial_path", "v1/not-allowed") + rec := httptest.NewRecorder() + + HandleCommercialProxy(CommercialProxyConfig{BaseURL: "https://license.pulserelay.pro"}).ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound) + } +} + func TestHandlePortalBootstrap_RequiresAuth(t *testing.T) { reg := newTestRegistry(t) sessionSvc, err := cpauth.NewService(t.TempDir()) diff --git a/internal/cloudcp/portal/page.go b/internal/cloudcp/portal/page.go index a9ca67fbed..b80016dda0 100644 --- a/internal/cloudcp/portal/page.go +++ b/internal/cloudcp/portal/page.go @@ -42,13 +42,12 @@ type portalPageData struct { } const ( - defaultPublicSiteURL = "https://pulserelay.pro" - defaultSupportEmail = "support@pulserelay.pro" - defaultCommercialAPIBaseURL = "https://license.pulserelay.pro" - defaultPortalPath = PortalPagePath - defaultLogoutPath = PortalLogoutPath - defaultAccountAPIBasePath = PortalAccountAPIBasePath - defaultPortalAPIBasePath = PortalAPIBasePath + defaultPublicSiteURL = "https://pulserelay.pro" + defaultSupportEmail = "support@pulserelay.pro" + defaultPortalPath = PortalPagePath + defaultLogoutPath = PortalLogoutPath + defaultAccountAPIBasePath = PortalAccountAPIBasePath + defaultPortalAPIBasePath = PortalAPIBasePath ) var errPortalAuthRequired = errors.New("portal auth required") diff --git a/internal/cloudcp/portal/paths.go b/internal/cloudcp/portal/paths.go index ef40569ea8..903aba1efa 100644 --- a/internal/cloudcp/portal/paths.go +++ b/internal/cloudcp/portal/paths.go @@ -1,14 +1,16 @@ package portal const ( - PortalPagePath = "/portal" - PortalBootstrapPath = "/api/portal/bootstrap" - PortalDashboardPath = "/api/portal/dashboard" - PortalWorkspacePath = "/api/portal/workspaces/{tenant_id}" - PortalBillingPath = "/api/portal/billing" - PortalLogoutPath = "/auth/logout" - PortalMagicLinkRequestPath = "/api/public/magic-link/request" - PortalSignupPath = "/signup" - PortalAccountAPIBasePath = "/api/accounts" - PortalAPIBasePath = "/api/portal" + PortalPagePath = "/portal" + PortalBootstrapPath = "/api/portal/bootstrap" + PortalDashboardPath = "/api/portal/dashboard" + PortalWorkspacePath = "/api/portal/workspaces/{tenant_id}" + PortalBillingPath = "/api/portal/billing" + PortalCommercialAPIBasePath = "/api/portal/commercial" + PortalCommercialAPIPath = "/api/portal/commercial/{commercial_path...}" + PortalLogoutPath = "/auth/logout" + PortalMagicLinkRequestPath = "/api/public/magic-link/request" + PortalSignupPath = "/signup" + PortalAccountAPIBasePath = "/api/accounts" + PortalAPIBasePath = "/api/portal" ) diff --git a/internal/cloudcp/routes.go b/internal/cloudcp/routes.go index 4ba0828a13..fc6a632fd8 100644 --- a/internal/cloudcp/routes.go +++ b/internal/cloudcp/routes.go @@ -230,6 +230,9 @@ func RegisterRoutes(mux *http.ServeMux, deps *Deps) { mux.Handle(portal.PortalBootstrapPath, portalAPILimiter.Middleware(sessionAuth(portal.HandlePortalBootstrap(deps.MagicLinks, deps.Registry)))) mux.Handle(portal.PortalDashboardPath, portalAPILimiter.Middleware(accountSessionAuth(accountIDFromPortalRequest, portal.HandlePortalDashboard(deps.Registry)))) mux.Handle(portal.PortalWorkspacePath, portalAPILimiter.Middleware(accountSessionAuth(accountIDFromPortalRequest, portal.HandlePortalWorkspaceDetail(deps.Registry)))) + mux.Handle(portal.PortalCommercialAPIPath, portalAPILimiter.Middleware(sessionAuth(portal.HandleCommercialProxy(portal.CommercialProxyConfig{ + BaseURL: deps.Config.LicenseServerURL, + })))) // Stripe Customer Portal redirect (session + account-membership authenticated) billingCfg := portal.BillingPortalConfig{ diff --git a/internal/cloudcp/routes_auth_test.go b/internal/cloudcp/routes_auth_test.go index e10353cc78..848055301f 100644 --- a/internal/cloudcp/routes_auth_test.go +++ b/internal/cloudcp/routes_auth_test.go @@ -338,6 +338,13 @@ func TestRegisterRoutes_PortalBootstrapRequiresSession(t *testing.T) { if revokedRec.Code != http.StatusUnauthorized { t.Fatalf("revoked status = %d, want %d (body=%q)", revokedRec.Code, http.StatusUnauthorized, revokedRec.Body.String()) } + + commercialReq := httptest.NewRequest(http.MethodPost, portal.PortalCommercialAPIBasePath+"/v1/retrieve-license/request", strings.NewReader(`{"email":"owner@example.com"}`)) + commercialRec := httptest.NewRecorder() + mux.ServeHTTP(commercialRec, commercialReq) + if commercialRec.Code != http.StatusUnauthorized { + t.Fatalf("portal commercial unauth status = %d, want %d (body=%q)", commercialRec.Code, http.StatusUnauthorized, commercialRec.Body.String()) + } } func TestRegisterRoutes_PortalPageSessionModes(t *testing.T) {