fix(cloudcp): proxy portal commercial actions same-origin

This commit is contained in:
Richard Courtman
2026-03-26 22:45:35 +00:00
parent 44f3e0b6e0
commit 6c24232995
22 changed files with 245 additions and 67 deletions
@@ -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
@@ -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
+26 -26
View File
@@ -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,
}
}
+100
View File
@@ -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
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"source_hash": "6c3fac3d004ef32e5bee17d4f8f80a569a64e0ee8582c417d79c7c537cce8d6d",
"source_hash": "f6a8fe4433646b19222e045466d685cd413111f5af747e80af1ff31d4cf5d177",
"build_inputs": [
"package.json",
"tsconfig.json",
+2 -2
View File
@@ -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",
@@ -28,7 +28,7 @@ async function flushAsync() {
const bootstrapDefaults: Omit<PortalBootstrapData, 'authenticated' | 'email' | 'accounts'> = {
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',
@@ -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',
+1 -1
View File
@@ -142,7 +142,7 @@ export function createPortalAPI(context: PortalAPIContext): PortalAPI {
}, 'Failed to sign out.');
},
postCommercialJSON: function<T>(path: string, body: Record<string, unknown>) {
return request<T>(bootstrap().commercial_api_base_url + path, {
return request<T>(bootstrap().commercial_api_base_path + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
@@ -27,7 +27,7 @@ async function flushAsync() {
const bootstrapDefaults: Omit<PortalBootstrapData, 'authenticated' | 'email' | 'accounts'> = {
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' }),
@@ -17,7 +17,7 @@ async function flushAsync() {
const bootstrapDefaults: Omit<PortalBootstrapData, 'authenticated' | 'email' | 'accounts'> = {
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',
@@ -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() {
@@ -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',
@@ -18,7 +18,7 @@ function createBootstrap(overrides: Partial<PortalBootstrapData> = {}): 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',
@@ -15,7 +15,7 @@ function createBootstrap(overrides: Partial<PortalBootstrapData> = {}): 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',
@@ -6,7 +6,7 @@ import type { PortalBootstrapData } from './types';
const bootstrapDefaults: Omit<PortalBootstrapData, 'authenticated' | 'email' | 'accounts'> = {
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',
@@ -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;
+62 -3
View File
@@ -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())
+6 -7
View File
@@ -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")
+12 -10
View File
@@ -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"
)
+3
View File
@@ -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{
+7
View File
@@ -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) {