diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index da5ed8a7b..69595b942 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -381,6 +381,10 @@ action or state (`Manage access`, `Hosted billing attached`, `Email support`, generic alert labels. Support copy is part of the same typed contract: escalation surfaces must render short literal path/account/action wording instead of longer procedural prose. +That same typed portal page contract also owns favicon cache-busting: the +rendered `` must point at the shared `/favicon.svg` asset +through a versioned href so new portal icon revisions bypass browser cache on +deploy instead of waiting for asset expiry. That same typed overview contract must also preserve a sharp, high-density enterprise visual aesthetic (e.g. Cloudflare/GCP density standards) across all portal scenarios, removing gradients and heavy box-shadows to ensure a calm, rigorous visual language with standard 256px sidebars, Inter-grade typography, clean text-transform rules, and cleanly unboxed typography without excessive pills or stacked metrics. plus a package-local `tsc --noEmit` gate, so future account-shell work should extend the typed source boundary instead of reviving opaque global runtime diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 2e7323c2a..04164d8b1 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -270,7 +270,10 @@ instance, but local portal design work must not depend on redeploying That same preview/runtime boundary also owns shared browser chrome such as the portal favicon: the local preview must serve the same `/favicon.svg` asset as the real control-plane route so icon changes can be reviewed locally before -deployment instead of appearing only after a live push. +deployment instead of appearing only after a live push. The portal page itself +must also reference that shared favicon through a versioned href so updated +icon revisions bypass browser cache on deploy instead of waiting for asset +expiry. That same frontend delivery boundary must keep the account portal visual language sharp and high-density, avoiding gradients, heavy shadows, and decorative SaaS styling in favor of a clean, restrained, Cloudflare/GCP-grade baseline with flat inline workspace action rows and text-driven unboxed metadata instead of pills and absolutely inline row actions. That same portal delivery boundary also owns the checked-in embedded bundle in `internal/cloudcp/portal/dist/`. Visual or interaction changes are not diff --git a/internal/cloudcp/portal/frontend/dev.mjs b/internal/cloudcp/portal/frontend/dev.mjs index f82777614..c1033bb3e 100644 --- a/internal/cloudcp/portal/frontend/dev.mjs +++ b/internal/cloudcp/portal/frontend/dev.mjs @@ -1,6 +1,7 @@ import fs from 'node:fs'; import http from 'node:http'; import path from 'node:path'; +import { createHash } from 'node:crypto'; import { context } from 'esbuild'; import { createPortalBuildOptions, frontendRoot } from './build_config.mjs'; @@ -10,6 +11,7 @@ const previewHost = process.env.PULSE_PORTAL_PREVIEW_HOST || '127.0.0.1'; const previewPort = Number(process.env.PULSE_PORTAL_PREVIEW_PORT || '8765'); const previewScenarios = ['managed', 'readonly', 'selfhosted', 'empty']; const previewFaviconSVG = fs.readFileSync(path.join(frontendRoot, '..', '..', 'favicon.svg'), 'utf8'); +const previewFaviconHref = '/favicon.svg?v=' + createHash('sha256').update(previewFaviconSVG).digest('hex').slice(0, 16); function iso(value) { return new Date(value).toISOString(); @@ -253,7 +255,7 @@ function buildPreviewHTML(assets, bootstrap, previewToast) { '' + '' + 'Pulse Account Preview' + - '' + + '' + '' + '' + '' + diff --git a/internal/cloudcp/portal/handlers_test.go b/internal/cloudcp/portal/handlers_test.go index 7d6a1dfc4..328bd7f8d 100644 --- a/internal/cloudcp/portal/handlers_test.go +++ b/internal/cloudcp/portal/handlers_test.go @@ -46,7 +46,7 @@ func doRequest(t *testing.T, h http.Handler, req *http.Request) *httptest.Respon func renderPortalHTML(t *testing.T, bootstrap BootstrapData) string { t.Helper() rec := httptest.NewRecorder() - renderPortalPage(rec, "test-nonce", bootstrap) + renderPortalPage(rec, "test-nonce", "/favicon.svg?v=test-favicon", bootstrap) if rec.Code != http.StatusOK { t.Fatalf("renderPortalPage returned %d", rec.Code) } @@ -782,7 +782,7 @@ func TestPortalPageTemplate_AccountServicesRendered(t *testing.T) { mustContain := []string{ "Pulse Account", - ``, + ``, `id="portal-user-info"`, `id="portal-app-root"`, `id="pulse-account-bootstrap"`, @@ -1133,7 +1133,7 @@ func TestPortalPageTemplate_UsesPulseAccountBrandingWhenSignedOut(t *testing.T) mustContain := []string{ "Pulse Account", - ``, + ``, "Pulse Account", `id="portal-app-root"`, "Enter the commercial email address for your Pulse account.", diff --git a/internal/cloudcp/portal/page.go b/internal/cloudcp/portal/page.go index 4f206355f..23f86f4ad 100644 --- a/internal/cloudcp/portal/page.go +++ b/internal/cloudcp/portal/page.go @@ -46,6 +46,7 @@ type portalPageAccount struct { // portalPageData is passed to the portal HTML template. type portalPageData struct { Nonce string + FaviconHref string Styles template.CSS ShellScript template.JS BootstrapJSON template.JS @@ -67,7 +68,7 @@ var errPortalAuthRequired = errors.New("portal auth required") // Route: GET /portal // - No session or invalid session -> shows a magic-link login form // - Valid session -> shows workspace list with management actions -func HandlePortalPage(sessionSvc *cpauth.Service, reg *registry.TenantRegistry, commercialLookup CommercialIdentityLookup) http.HandlerFunc { +func HandlePortalPage(sessionSvc *cpauth.Service, reg *registry.TenantRegistry, commercialLookup CommercialIdentityLookup, faviconHref string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -85,9 +86,9 @@ func HandlePortalPage(sessionSvc *cpauth.Service, reg *registry.TenantRegistry, http.Error(w, "internal error", http.StatusInternalServerError) return } - renderPortalPage(w, nonce, BuildBootstrapData(true, claims.Email, accounts, resolveSelfHostedCommercial(r.Context(), commercialLookup, claims.Email, accounts))) + renderPortalPage(w, nonce, faviconHref, BuildBootstrapData(true, claims.Email, accounts, resolveSelfHostedCommercial(r.Context(), commercialLookup, claims.Email, accounts))) case errors.Is(err, errPortalAuthRequired): - renderPortalPage(w, nonce, BuildAnonymousBootstrapData()) + renderPortalPage(w, nonce, faviconHref, BuildAnonymousBootstrapData()) default: log.Error().Err(err).Msg("cloudcp.portal.page: validate session") http.Error(w, "internal error", http.StatusInternalServerError) @@ -246,7 +247,7 @@ func workspaceHealthStatus(healthy bool, lastHealthCheck *time.Time) string { return "unhealthy" } -func renderPortalPage(w http.ResponseWriter, nonce string, bootstrapData BootstrapData) { +func renderPortalPage(w http.ResponseWriter, nonce string, faviconHref string, bootstrapData BootstrapData) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) bootstrapJSON, err := MarshalBootstrapJSON(bootstrapData) @@ -256,6 +257,7 @@ func renderPortalPage(w http.ResponseWriter, nonce string, bootstrapData Bootstr } if err := portalPageTmpl.Execute(w, portalPageData{ Nonce: nonce, + FaviconHref: faviconHref, Styles: portalStyles, ShellScript: portalShellScript, BootstrapJSON: bootstrapJSON, diff --git a/internal/cloudcp/portal/templates/portal.html b/internal/cloudcp/portal/templates/portal.html index 5b76b6f0f..d712f52af 100644 --- a/internal/cloudcp/portal/templates/portal.html +++ b/internal/cloudcp/portal/templates/portal.html @@ -4,7 +4,7 @@ Pulse Account - + diff --git a/internal/cloudcp/routes.go b/internal/cloudcp/routes.go index 447f5de36..4dceae9ef 100644 --- a/internal/cloudcp/routes.go +++ b/internal/cloudcp/routes.go @@ -257,5 +257,5 @@ func RegisterRoutes(mux *http.ServeMux, deps *Deps) { // MSP/Cloud portal HTML page — self-authenticating (shows login form if no session) portalPageLimiter := NewCPRateLimiter(60, time.Minute) - mux.Handle(portal.PortalPagePath, portalPageLimiter.Middleware(http.HandlerFunc(portal.HandlePortalPage(deps.MagicLinks, deps.Registry, portalCommercialLookup)))) + mux.Handle(portal.PortalPagePath, portalPageLimiter.Middleware(http.HandlerFunc(portal.HandlePortalPage(deps.MagicLinks, deps.Registry, portalCommercialLookup, controlPlaneFaviconHref())))) } diff --git a/internal/cloudcp/routes_test.go b/internal/cloudcp/routes_test.go index bda54daff..54ed03a89 100644 --- a/internal/cloudcp/routes_test.go +++ b/internal/cloudcp/routes_test.go @@ -141,6 +141,9 @@ func TestRegisterRoutes_FaviconRouteParity(t *testing.T) { if got := icoRec.Header().Get("Location"); got != "/favicon.svg" { t.Fatalf("GET /favicon.ico location=%q, want %q", got, "/favicon.svg") } + if got := controlPlaneFaviconHref(); !strings.HasPrefix(got, "/favicon.svg?v=") { + t.Fatalf("controlPlaneFaviconHref=%q, want versioned favicon href", got) + } } func TestRegisterRoutes_TrialSignupRoutes(t *testing.T) { diff --git a/internal/cloudcp/static_assets.go b/internal/cloudcp/static_assets.go index 8105e7044..5d985d029 100644 --- a/internal/cloudcp/static_assets.go +++ b/internal/cloudcp/static_assets.go @@ -1,13 +1,24 @@ package cloudcp import ( + "crypto/sha256" _ "embed" + "encoding/hex" "net/http" ) //go:embed favicon.svg var controlPlaneFaviconSVG []byte +var controlPlaneFaviconVersion = func() string { + sum := sha256.Sum256(controlPlaneFaviconSVG) + return hex.EncodeToString(sum[:8]) +}() + +func controlPlaneFaviconHref() string { + return "/favicon.svg?v=" + controlPlaneFaviconVersion +} + func handleControlPlaneFaviconSVG(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)