From 8e42338cf25e9dadc17178c734a8de8f836a4efb Mon Sep 17 00:00:00 2001 From: HananINouman Date: Wed, 29 Jul 2026 16:13:26 +0300 Subject: [PATCH 1/2] feat(storefront): support secure operator previews Render session-only branding drafts through the real public storefront so operators can preview safely without duplicating buyer UI. Co-authored-by: Cursor --- web/public-storefront/src/app/page.tsx | 53 +---- .../src/components/Header.tsx | 23 +- .../src/components/StorefrontPreview.tsx | 207 ++++++++++++++++++ 3 files changed, 225 insertions(+), 58 deletions(-) create mode 100644 web/public-storefront/src/components/StorefrontPreview.tsx diff --git a/web/public-storefront/src/app/page.tsx b/web/public-storefront/src/app/page.tsx index a1f5a654..4c2c78b3 100644 --- a/web/public-storefront/src/app/page.tsx +++ b/web/public-storefront/src/app/page.tsx @@ -1,56 +1,9 @@ -import { DEFAULT_HERO_TITLE, fetchCatalogDocument } from "@/lib/catalog"; -import { Header } from "@/components/Header"; -import { RichText } from "@/components/RichText"; -import { ServicesList } from "@/components/ServicesList"; -import { PaymentFlow } from "@/components/PaymentFlow"; +import { StorefrontPreview } from "@/components/StorefrontPreview"; +import { fetchCatalogDocument } from "@/lib/catalog"; export const dynamic = "force-dynamic"; export const revalidate = 0; export default async function Home() { const catalog = await fetchCatalogDocument(); - - return ( - <> -
-
-
-

- {DEFAULT_HERO_TITLE} -

-

- {catalog.tagline} -

- {catalog.description ? ( - - ) : null} -
- - - - -
- - ); + return ; } diff --git a/web/public-storefront/src/components/Header.tsx b/web/public-storefront/src/components/Header.tsx index d28cab16..04459fdf 100644 --- a/web/public-storefront/src/components/Header.tsx +++ b/web/public-storefront/src/components/Header.tsx @@ -13,14 +13,21 @@ export function Header({ storefront }: { storefront: StorefrontProfile }) { data-obol="brand" > {isDefaultLogo && dark ? ( - {storefront.displayName} + <> + Obol Stack + {storefront.displayName !== "Obol Stack" ? ( +
+ {storefront.displayName} +
+ ) : null} + ) : isDefaultLogo ? ( // The default wordmark is light-on-dark and invisible on the // light theme — use the dark square mark plus the name instead. diff --git a/web/public-storefront/src/components/StorefrontPreview.tsx b/web/public-storefront/src/components/StorefrontPreview.tsx new file mode 100644 index 00000000..ea8f385e --- /dev/null +++ b/web/public-storefront/src/components/StorefrontPreview.tsx @@ -0,0 +1,207 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Header } from "@/components/Header"; +import { PaymentFlow } from "@/components/PaymentFlow"; +import { RichText } from "@/components/RichText"; +import { ServicesList } from "@/components/ServicesList"; +import type { ServiceCatalogDocument } from "@/lib/catalog"; +import { isDarkTheme, themeStyle } from "@/lib/theme"; + +const DEFAULT_HERO_TITLE = "Agent services"; +const DEFAULT_LOGO_PATH = "/obol-stack-logo.png"; +const PREVIEW_MESSAGE = "obol.storefront.preview"; +const PREVIEW_READY_MESSAGE = "obol.storefront.preview.ready"; +const HEX_RE = /^#[0-9a-fA-F]{3,8}$/; +const MAX_DATA_URL_LENGTH = 360_000; +const ALLOWED_OPERATOR_HOSTS = new Set(["obol.stack", "localhost", "127.0.0.1"]); + +interface PreviewBranding { + displayName: string; + tagline: string; + theme: "light" | "dark" | "obol"; + themeVars: Record; + logoUrl: string | null; +} + +function operatorOrigin(): string | null { + if (!document.referrer) return null; + try { + const url = new URL(document.referrer); + return ALLOWED_OPERATOR_HOSTS.has(url.hostname) ? url.origin : null; + } catch { + return null; + } +} + +function optionalImage(value: unknown): string | null | undefined { + if (value === null) return null; + if (typeof value !== "string" || value.length > MAX_DATA_URL_LENGTH) { + return undefined; + } + if ( + /^data:image\/[^;,]+;base64,[a-zA-Z0-9+/=\s]+$/.test(value) || + value.startsWith("https://") || + value.startsWith("http://") || + value.startsWith("/") + ) { + return value; + } + return undefined; +} + +function parsePreviewMessage(data: unknown): PreviewBranding | null { + if (!data || typeof data !== "object") return null; + const message = data as Record; + if (message.type !== PREVIEW_MESSAGE || message.version !== 1) return null; + if (!message.branding || typeof message.branding !== "object") return null; + + const branding = message.branding as Record; + const theme = branding.theme; + if (theme !== "light" && theme !== "dark" && theme !== "obol") return null; + if ( + typeof branding.displayName !== "string" || + branding.displayName.length > 120 || + typeof branding.tagline !== "string" || + branding.tagline.length > 500 || + !branding.themeVars || + typeof branding.themeVars !== "object" + ) { + return null; + } + + const themeVars: Record = {}; + for (const [key, value] of Object.entries( + branding.themeVars as Record, + )) { + if (typeof value === "string" && HEX_RE.test(value)) { + themeVars[key] = value; + } + } + const logoUrl = optionalImage(branding.logoUrl); + if (logoUrl === undefined) { + return null; + } + + return { + displayName: branding.displayName, + tagline: branding.tagline, + theme, + themeVars, + logoUrl, + }; +} + +export function StorefrontPreview({ + initial, +}: { + initial: ServiceCatalogDocument; +}) { + const [storefront, setStorefront] = useState(initial); + + useEffect(() => { + const origin = operatorOrigin(); + if (!origin || window.parent === window) return; + + const handleMessage = (event: MessageEvent) => { + if (event.source !== window.parent || event.origin !== origin) return; + const branding = parsePreviewMessage(event.data); + if (!branding) return; + setStorefront((published) => ({ + ...published, + displayName: branding.displayName, + tagline: branding.tagline, + theme: branding.theme, + themeVars: branding.themeVars, + logoUrl: branding.logoUrl ?? DEFAULT_LOGO_PATH, + })); + }; + + window.addEventListener("message", handleMessage); + const publishedTheme = + initial.theme === "dark" || initial.theme === "obol" + ? initial.theme + : "light"; + window.parent.postMessage( + { + type: PREVIEW_READY_MESSAGE, + version: 1, + published: { + displayName: initial.displayName, + tagline: initial.tagline, + theme: publishedTheme, + accent: initial.themeVars?.green ?? "#0b9b71", + logoUrl: initial.logoUrl, + }, + }, + origin, + ); + return () => window.removeEventListener("message", handleMessage); + }, [initial]); + + useEffect(() => { + const root = document.documentElement; + const previewStyle = themeStyle(storefront.themeVars); + const previous = new Map(); + for (const [name, value] of Object.entries(previewStyle)) { + previous.set(name, root.style.getPropertyValue(name)); + root.style.setProperty(name, value); + } + const previousScheme = root.style.colorScheme; + root.style.colorScheme = isDarkTheme(storefront.theme) ? "dark" : "light"; + return () => { + for (const [name, value] of previous) { + if (value) root.style.setProperty(name, value); + else root.style.removeProperty(name); + } + root.style.colorScheme = previousScheme; + }; + }, [storefront.theme, storefront.themeVars]); + + return ( + <> +
+
+
+

+ {DEFAULT_HERO_TITLE} +

+

+ {storefront.tagline} +

+ {storefront.description ? ( + + ) : null} +
+ + + + +
+ + ); +} From aa9ebf7f8c3a31c7c3402a5a7ee9060bf69b3857 Mon Sep 17 00:00:00 2001 From: HananINouman Date: Wed, 29 Jul 2026 22:59:37 +0300 Subject: [PATCH 2/2] feat(storefront): publish local-only preview and SSA branding applies Keep operator live previews on storefront-preview.obol.stack (never the public tunnel), retain that renderer when the public catch-all is torn down, and use server-side apply so large inline logos survive kubectl's 256 KiB last-applied annotation limit. --- cmd/obol/sell_info.go | 17 ++- cmd/obol/sell_info_test.go | 37 +++++ internal/stack/stack.go | 9 +- internal/storefront/record.go | 12 +- internal/tunnel/tunnel.go | 136 +++++++++++++++--- internal/tunnel/tunnel_test.go | 109 ++++++++++++-- web/public-storefront/src/app/globals.css | 14 ++ .../src/components/StorefrontPreview.tsx | 34 ++++- 8 files changed, 332 insertions(+), 36 deletions(-) diff --git a/cmd/obol/sell_info.go b/cmd/obol/sell_info.go index cca58c66..ddffe220 100644 --- a/cmd/obol/sell_info.go +++ b/cmd/obol/sell_info.go @@ -841,7 +841,22 @@ func applySellerProfile(cfg *config.Config, profile schemas.StorefrontProfile) e if err != nil { return err } - if err := kubectlApply(cfg, manifest); err != nil { + raw, err := json.Marshal(manifest) + if err != nil { + return fmt.Errorf("marshal storefront profile: %w", err) + } + bin, kubeconfig := kubectl.Paths(cfg) + // Inline data:image values can make kubectl's client-side + // last-applied-configuration annotation exceed its 256 KiB limit even + // though the ConfigMap itself remains below Kubernetes' 1 MiB limit. + // The host-side profile record is authoritative, so use server-side apply + // for both initial writes and updates. + if err := kubectl.ApplyServerSideForceConflicts( + bin, + kubeconfig, + raw, + "obol-storefront-profile", + ); err != nil { return fmt.Errorf("apply storefront profile: %w", err) } return nil diff --git a/cmd/obol/sell_info_test.go b/cmd/obol/sell_info_test.go index d3c0ebbc..2a73f689 100644 --- a/cmd/obol/sell_info_test.go +++ b/cmd/obol/sell_info_test.go @@ -1,12 +1,49 @@ package main import ( + "fmt" + "os" + "path/filepath" "strings" "testing" + "github.com/ObolNetwork/obol-stack/internal/config" "github.com/ObolNetwork/obol-stack/internal/schemas" ) +func TestApplySellerProfileUsesServerSideApplyForInlineImages(t *testing.T) { + cfg := &config.Config{ConfigDir: t.TempDir(), BinDir: t.TempDir()} + argsPath := filepath.Join(t.TempDir(), "kubectl-args") + script := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > %q\ncat >/dev/null\n", argsPath) + if err := os.WriteFile(filepath.Join(cfg.BinDir, "kubectl"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + profile := schemas.StorefrontProfile{ + LogoURL: "data:image/png;base64," + strings.Repeat("a", 270_000), + } + if err := applySellerProfile(cfg, profile); err != nil { + t.Fatalf("applySellerProfile: %v", err) + } + + args, err := os.ReadFile(argsPath) + if err != nil { + t.Fatal(err) + } + got := string(args) + for _, want := range []string{ + "apply\n", + "--server-side\n", + "--force-conflicts\n", + "--field-manager=obol-storefront-profile\n", + "-f\n-\n", + } { + if !strings.Contains(got, want) { + t.Fatalf("kubectl args missing %q:\n%s", want, got) + } + } +} + func TestClearProfileFields(t *testing.T) { base := schemas.StorefrontProfile{ DisplayName: "Acme", diff --git a/internal/stack/stack.go b/internal/stack/stack.go index 6c1b5464..d1bd0b3d 100644 --- a/internal/stack/stack.go +++ b/internal/stack/stack.go @@ -249,7 +249,8 @@ func Up(cfg *config.Config, u *ui.UI, wildcardDNS bool) error { // Ensure the base host before syncing defaults. Include existing agent // hostnames so stack up never shrinks the managed /etc/hosts block to only // obol.stack when default setup is skipped. - if err := dns.EnsureHostsEntries(agentruntime.CollectHostnames(cfg)); err != nil { + hostnames := append(agentruntime.CollectHostnames(cfg), tunnel.StorefrontPreviewHostname) + if err := dns.EnsureHostsEntries(hostnames); err != nil { u.Warnf("Could not update /etc/hosts for obol.stack: %v", err) } @@ -588,6 +589,12 @@ func syncDefaults(cfg *config.Config, u *ui.UI, kubeconfigPath string, dataDir s u.Dim(" You can manually apply later with: obol agent init") } + // Local-only storefront preview must exist even when the public tunnel is + // dormant — the operator branding editor iframes storefront-preview.obol.stack. + if err := tunnel.EnsureLocalStorefrontPreview(cfg); err != nil { + u.Warnf("Could not publish local storefront preview: %v", err) + } + // Start the Cloudflare tunnel only if a persistent DNS tunnel is provisioned. // Quick tunnels are dormant by default and activate on first `obol sell`. u.Blank() diff --git a/internal/storefront/record.go b/internal/storefront/record.go index 26d4d7ce..896e3803 100644 --- a/internal/storefront/record.go +++ b/internal/storefront/record.go @@ -49,7 +49,17 @@ func ReconcileRecorded(cfg *config.Config, u *ui.UI) { } bin, kubeconfig := kubectl.Paths(cfg) - if err := kubectl.Apply(bin, kubeconfig, payload); err != nil { + // Profiles may contain inline data:image URIs. Client-side apply mirrors + // the complete manifest into last-applied-configuration, and that annotation + // has a 256 KiB hard limit even though the ConfigMap itself allows 1 MiB. + // The host record is authoritative, so server-side ownership is appropriate + // and keeps large valid profiles replayable after stack recreation. + if err := kubectl.ApplyServerSideForceConflicts( + bin, + kubeconfig, + payload, + "obol-storefront-profile", + ); err != nil { u.Warnf("Could not reconcile recorded storefront profile: %v", err) return } diff --git a/internal/tunnel/tunnel.go b/internal/tunnel/tunnel.go index d20f6553..4cec0392 100644 --- a/internal/tunnel/tunnel.go +++ b/internal/tunnel/tunnel.go @@ -18,6 +18,7 @@ import ( "github.com/ObolNetwork/obol-stack/internal/agentruntime" "github.com/ObolNetwork/obol-stack/internal/config" + stackdefaults "github.com/ObolNetwork/obol-stack/internal/defaults" "github.com/ObolNetwork/obol-stack/internal/images" "github.com/ObolNetwork/obol-stack/internal/ui" ) @@ -1229,8 +1230,16 @@ func deleteLocalCloudflareTunnel(u *ui.UI, tunnelName, tunnelID string) error { return nil } -// storefrontNamespace is where the storefront landing page resources live. -const storefrontNamespace = "traefik" +// Storefront resources live beside the Gateway. The preview hostname is +// deliberately local-only: unlike tunnel hostnames it is never written into +// cloudflared ingress configuration, so the operator dashboard can frame the +// real renderer without bringing the public tunnel into its trust boundary. +const ( + storefrontNamespace = "traefik" + // StorefrontPreviewHostname is the local-only operator preview origin. + // It is never written into cloudflared ingress configuration. + StorefrontPreviewHostname = "storefront-preview.obol.stack" +) // storefrontHostnames returns the hostnames the public storefront should be // published on: the full tracked set for a persistent tunnel, else the host @@ -1276,6 +1285,63 @@ func offerBoundHostnames(kubectlPath, kubeconfigPath string) (map[string]bool, e return bound, nil } +func buildStorefrontPreviewHTTPRoute() map[string]any { + return map[string]any{ + "apiVersion": "gateway.networking.k8s.io/v1", + "kind": "HTTPRoute", + "metadata": map[string]any{ + "name": "storefront-preview", + "namespace": storefrontNamespace, + }, + "spec": map[string]any{ + "hostnames": []string{StorefrontPreviewHostname}, + "parentRefs": []map[string]any{ + { + "name": "traefik-gateway", + "namespace": "traefik", + "sectionName": "web", + }, + }, + "rules": []map[string]any{ + { + "matches": []map[string]any{ + {"path": map[string]string{"type": "PathPrefix", "value": "/"}}, + }, + "filters": []map[string]any{ + { + "type": "ResponseHeaderModifier", + "responseHeaderModifier": map[string]any{ + "set": []map[string]string{ + { + "name": "Content-Security-Policy", + "value": "frame-ancestors 'self' http://obol.stack:* https://obol.stack:*", + }, + { + "name": "Permissions-Policy", + "value": "camera=(), microphone=(), geolocation=(), payment=()", + }, + {"name": "X-Content-Type-Options", "value": "nosniff"}, + }, + }, + }, + }, + "backendRefs": []map[string]any{ + {"name": "tunnel-storefront", "port": 3000}, + }, + }, + }, + }, + } +} + +func storefrontImage(cfg *config.Config) string { + const repo = "ghcr.io/obolnetwork/obol-stack-public-storefront" + if strings.EqualFold(strings.TrimSpace(os.Getenv("OBOL_DEVELOPMENT")), "true") { + return images.ResolveDev(repo, stackdefaults.ReadDevImageTag(cfg)) + } + return images.Resolve(repo) +} + // CreateStorefront creates (or updates) the public storefront landing page and // publishes it at the root path of EVERY supplied hostname. Each argument may be // a bare hostname or a full URL (scheme/path stripped); empty or duplicate @@ -1315,22 +1381,41 @@ func CreateStorefront(cfg *config.Config, hostnames ...string) error { } hosts = kept if len(hosts) == 0 { - // Every tracked hostname is offer-bound: the storefront has - // nothing left to serve at any hostname. Tear it down instead - // of leaving the previously-applied HTTPRoute (with the now- - // stale wider host list) on the cluster — Gateway API breaks - // the resulting PathPrefix-/ tie by route age, so that stale - // route would otherwise keep shadowing the offer's own - // dedicated-origin route. - return DeleteStorefront(cfg) + // Every tracked public hostname is offer-bound: remove only the + // public catch-all HTTPRoute so it cannot shadow dedicated-origin + // offer routes. Keep the renderer + local preview hostname so the + // operator dashboard can still frame a non-public copy. + cmd := exec.Command(kubectlPath, + "--kubeconfig", kubeconfigPath, + "delete", "httproute/tunnel-storefront", + "-n", storefrontNamespace, + "--ignore-not-found", + ) + _ = cmd.Run() + return ensureStorefrontRenderer(cfg, nil) } } + return ensureStorefrontRenderer(cfg, hosts) +} + +// EnsureLocalStorefrontPreview publishes the local-only storefront renderer +// (Deployment + Service + storefront-preview.obol.stack HTTPRoute) without +// requiring a public tunnel hostname. Safe to call on every stack up so the +// operator branding editor can iframe a non-public copy before tunnel setup. +func EnsureLocalStorefrontPreview(cfg *config.Config) error { + return ensureStorefrontRenderer(cfg, nil) +} + +// ensureStorefrontRenderer applies the Next.js storefront Deployment + Service +// and the local-only preview HTTPRoute. When publicHosts is non-empty it also +// publishes the tunnel catch-all HTTPRoute for those hostnames. +func ensureStorefrontRenderer(cfg *config.Config, publicHosts []string) error { + kubectlPath := filepath.Join(cfg.BinDir, "kubectl") + kubeconfigPath := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml") labels := map[string]string{"app": "tunnel-storefront"} - // Build the resources for the public storefront. resources := []map[string]any{ - // Deployment: Next.js public storefront image. { "apiVersion": "apps/v1", "kind": "Deployment", @@ -1351,7 +1436,7 @@ func CreateStorefront(cfg *config.Config, hostnames ...string) error { "containers": []map[string]any{ { "name": "storefront", - "image": images.Resolve("ghcr.io/obolnetwork/obol-stack-public-storefront"), + "image": storefrontImage(cfg), "imagePullPolicy": "IfNotPresent", "ports": []map[string]any{ {"containerPort": 3000, "name": "http"}, @@ -1397,7 +1482,6 @@ func CreateStorefront(cfg *config.Config, hostnames ...string) error { }, }, }, - // Service { "apiVersion": "v1", "kind": "Service", @@ -1412,8 +1496,14 @@ func CreateStorefront(cfg *config.Config, hostnames ...string) error { }, }, }, - // HTTPRoute: tunnel hostname → storefront (more specific than frontend catch-all). - { + // Local-only preview route. Hostname scoping is the security boundary: + // cloudflared never advertises *.obol.stack, while the CSP limits who + // may frame this renderer even on the local machine. + buildStorefrontPreviewHTTPRoute(), + } + + if len(publicHosts) > 0 { + resources = append(resources, map[string]any{ "apiVersion": "gateway.networking.k8s.io/v1", "kind": "HTTPRoute", "metadata": map[string]any{ @@ -1421,7 +1511,7 @@ func CreateStorefront(cfg *config.Config, hostnames ...string) error { "namespace": storefrontNamespace, }, "spec": map[string]any{ - "hostnames": hosts, + "hostnames": publicHosts, "parentRefs": []map[string]any{ { "name": "traefik-gateway", @@ -1443,10 +1533,9 @@ func CreateStorefront(cfg *config.Config, hostnames ...string) error { }, }, }, - }, + }) } - // Apply each resource via kubectl apply. for _, res := range resources { data, err := json.Marshal(res) if err != nil { @@ -1468,7 +1557,10 @@ func CreateStorefront(cfg *config.Config, hostnames ...string) error { return nil } -// DeleteStorefront removes the storefront landing page resources. +// DeleteStorefront removes the public storefront catch-all HTTPRoute while +// keeping the local-only preview renderer (Deployment/Service + +// storefront-preview.obol.stack). The operator branding editor must keep +// working when the tunnel is dormant or the last offer is deleted. func DeleteStorefront(cfg *config.Config) error { kubectlPath := filepath.Join(cfg.BinDir, "kubectl") @@ -1479,8 +1571,6 @@ func DeleteStorefront(cfg *config.Config) error { for _, resource := range []string{ "httproute/tunnel-storefront", - "service/tunnel-storefront", - "deployment/tunnel-storefront", "configmap/tunnel-storefront", } { cmd := exec.Command(kubectlPath, @@ -1492,7 +1582,7 @@ func DeleteStorefront(cfg *config.Config) error { _ = cmd.Run() // best-effort cleanup } - return nil + return EnsureLocalStorefrontPreview(cfg) } func parseQuickTunnelURL(logs string) (string, bool) { diff --git a/internal/tunnel/tunnel_test.go b/internal/tunnel/tunnel_test.go index 2a116dbc..ea5015a5 100644 --- a/internal/tunnel/tunnel_test.go +++ b/internal/tunnel/tunnel_test.go @@ -1,6 +1,7 @@ package tunnel import ( + "encoding/json" "fmt" "os" "path/filepath" @@ -11,6 +12,49 @@ import ( "github.com/ObolNetwork/obol-stack/internal/config" ) +func TestBuildStorefrontPreviewHTTPRouteSecurity(t *testing.T) { + route := buildStorefrontPreviewHTTPRoute() + data, err := json.Marshal(route) + if err != nil { + t.Fatal(err) + } + rendered := string(data) + + for _, want := range []string{ + `"hostnames":["storefront-preview.obol.stack"]`, + `"name":"Content-Security-Policy"`, + `frame-ancestors 'self' http://obol.stack:* https://obol.stack:*`, + `"name":"Permissions-Policy"`, + `camera=(), microphone=(), geolocation=(), payment=()`, + `"name":"X-Content-Type-Options","value":"nosniff"`, + `"name":"tunnel-storefront","port":3000`, + } { + if !strings.Contains(rendered, want) { + t.Fatalf("preview route missing %q:\n%s", want, rendered) + } + } + if strings.Contains(rendered, `"hostnames":[]`) { + t.Fatalf("preview route must never become hostless:\n%s", rendered) + } +} + +func TestStorefrontImageUsesPersistedDevTag(t *testing.T) { + t.Setenv("OBOL_DEVELOPMENT", "true") + cfg := &config.Config{ConfigDir: t.TempDir()} + if err := os.WriteFile( + filepath.Join(cfg.ConfigDir, ".dev-image-tag"), + []byte("dev-abc1234"), + 0o600, + ); err != nil { + t.Fatal(err) + } + + const want = "ghcr.io/obolnetwork/obol-stack-public-storefront:dev-abc1234" + if got := storefrontImage(cfg); got != want { + t.Fatalf("storefrontImage() = %q, want %q", got, want) + } +} + func TestWaitReadyTimeout(t *testing.T) { t.Setenv("FLOW_TUNNEL_TIMEOUT", "") if got := waitReadyTimeout(); got != defaultWaitReadyTimeout { @@ -162,6 +206,7 @@ func TestBuildLocalManagedConfigYAMLDeduplicates(t *testing.T) { // every invocation (argv) to logPath and, for a `get serviceoffers.obol.org` // query, prints boundHostname as the sole offer-bound hostname -- just enough // to drive offerBoundHostnames()/DeleteStorefront() without a real cluster. +// Apply stdin is appended so callers can assert which resources were published. func writeFakeKubectl(t *testing.T, cfg *config.Config, logPath, boundHostname string) { t.Helper() if err := os.MkdirAll(cfg.BinDir, 0o755); err != nil { @@ -171,9 +216,14 @@ func writeFakeKubectl(t *testing.T, cfg *config.Config, logPath, boundHostname s echo "$@" >> %q case "$*" in *"get serviceoffers.obol.org"*) echo %q ;; + *apply*) + echo "---APPLY---" >> %q + cat >> %q + echo >> %q + ;; esac exit 0 -`, logPath, boundHostname) +`, logPath, boundHostname, logPath, logPath, logPath) if err := os.WriteFile(filepath.Join(cfg.BinDir, "kubectl"), []byte(script), 0o755); err != nil { t.Fatalf("write fake kubectl: %v", err) } @@ -181,11 +231,10 @@ exit 0 // TestCreateStorefront_TearsDownWhenAllHostsOfferBound guards the Canary402 // fix: when every hostname CreateStorefront was asked to serve turns out to -// be offer-bound, it must tear down the previously-applied tunnel-storefront -// HTTPRoute (via DeleteStorefront) instead of a no-op `return nil` that would -// leave a stale, wider-hostname route on the cluster shadowing the offer's -// own dedicated-origin route (equal PathPrefix-/ specificity, older route -// wins the Gateway API tie). +// be offer-bound, it must tear down the previously-applied public +// tunnel-storefront HTTPRoute instead of leaving a stale wider-hostname +// route shadowing the offer's dedicated-origin route. The local preview +// renderer (Deployment/Service + storefront-preview route) is retained. func TestCreateStorefront_TearsDownWhenAllHostsOfferBound(t *testing.T) { cfg := newHostnameTestConfig(t) writeFakeKubeconfig(t, cfg) @@ -206,8 +255,52 @@ func TestCreateStorefront_TearsDownWhenAllHostsOfferBound(t *testing.T) { if !strings.Contains(log, "delete httproute/tunnel-storefront") { t.Fatalf("CreateStorefront must tear down the stale tunnel-storefront HTTPRoute when every requested hostname is offer-bound; kubectl invocations:\n%s", log) } - if strings.Contains(log, "apply") { - t.Fatalf("CreateStorefront must not re-apply the storefront HTTPRoute when every requested hostname is offer-bound; kubectl invocations:\n%s", log) + if !strings.Contains(log, "apply") { + t.Fatalf("CreateStorefront must retain the local storefront renderer when every requested hostname is offer-bound; kubectl invocations:\n%s", log) + } + if !strings.Contains(log, `"hostnames":["storefront-preview.obol.stack"]`) { + t.Fatalf("CreateStorefront must keep the local-only preview HTTPRoute; kubectl invocations:\n%s", log) + } + if strings.Contains(log, `"hostnames":["example.com"]`) { + t.Fatalf("CreateStorefront must not re-publish the public catch-all for offer-bound hostnames; kubectl invocations:\n%s", log) + } + if strings.Contains(log, "delete httproute/storefront-preview") || + strings.Contains(log, "delete deployment/tunnel-storefront") || + strings.Contains(log, "delete service/tunnel-storefront") { + t.Fatalf("CreateStorefront must not delete the local preview renderer when only public hosts are offer-bound; kubectl invocations:\n%s", log) + } +} + +// TestDeleteStorefront_KeepsLocalPreview ensures tearing down the public +// catch-all (tunnel delete / last quick-tunnel offer) still leaves the +// operator branding editor a local renderer to iframe. +func TestDeleteStorefront_KeepsLocalPreview(t *testing.T) { + cfg := newHostnameTestConfig(t) + writeFakeKubeconfig(t, cfg) + + logPath := filepath.Join(cfg.ConfigDir, "kubectl.log") + writeFakeKubectl(t, cfg, logPath, "") + + if err := DeleteStorefront(cfg); err != nil { + t.Fatalf("DeleteStorefront: %v", err) + } + + logBytes, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("read kubectl log: %v", err) + } + log := string(logBytes) + + if !strings.Contains(log, "delete httproute/tunnel-storefront") { + t.Fatalf("DeleteStorefront must remove the public catch-all; kubectl invocations:\n%s", log) + } + if strings.Contains(log, "delete httproute/storefront-preview") || + strings.Contains(log, "delete deployment/tunnel-storefront") || + strings.Contains(log, "delete service/tunnel-storefront") { + t.Fatalf("DeleteStorefront must not tear down the local preview renderer; kubectl invocations:\n%s", log) + } + if !strings.Contains(log, `"hostnames":["storefront-preview.obol.stack"]`) { + t.Fatalf("DeleteStorefront must re-ensure the local preview route; kubectl invocations:\n%s", log) } } diff --git a/web/public-storefront/src/app/globals.css b/web/public-storefront/src/app/globals.css index 5024e5f4..0a9027e9 100644 --- a/web/public-storefront/src/app/globals.css +++ b/web/public-storefront/src/app/globals.css @@ -39,6 +39,20 @@ body { color: var(--color-text-light); } +/* Operator iframe preview: disable pay/wallet controls but keep page scroll. */ +html[data-obol-preview] a, +html[data-obol-preview] button, +html[data-obol-preview] input, +html[data-obol-preview] textarea, +html[data-obol-preview] select, +html[data-obol-preview] summary, +html[data-obol-preview] [role="button"] { + pointer-events: none; +} +html[data-obol-preview] { + cursor: default; +} + /* Scrollbars follow the theme */ * { scrollbar-color: var(--color-bg04) var(--color-bg01); diff --git a/web/public-storefront/src/components/StorefrontPreview.tsx b/web/public-storefront/src/components/StorefrontPreview.tsx index ea8f385e..aaf428a1 100644 --- a/web/public-storefront/src/components/StorefrontPreview.tsx +++ b/web/public-storefront/src/components/StorefrontPreview.tsx @@ -15,6 +15,7 @@ const PREVIEW_READY_MESSAGE = "obol.storefront.preview.ready"; const HEX_RE = /^#[0-9a-fA-F]{3,8}$/; const MAX_DATA_URL_LENGTH = 360_000; const ALLOWED_OPERATOR_HOSTS = new Set(["obol.stack", "localhost", "127.0.0.1"]); +const PREVIEW_HOST = "storefront-preview.obol.stack"; interface PreviewBranding { displayName: string; @@ -34,6 +35,12 @@ function operatorOrigin(): string | null { } } +function isOperatorPreviewFrame(): boolean { + if (typeof window === "undefined") return false; + if (window.parent !== window) return true; + return window.location.hostname === PREVIEW_HOST; +} + function optionalImage(value: unknown): string | null | undefined { if (value === null) return null; if (typeof value !== "string" || value.length > MAX_DATA_URL_LENGTH) { @@ -98,10 +105,20 @@ export function StorefrontPreview({ initial: ServiceCatalogDocument; }) { const [storefront, setStorefront] = useState(initial); + const [previewMode, setPreviewMode] = useState(false); useEffect(() => { + const preview = isOperatorPreviewFrame(); + setPreviewMode(preview); + const root = document.documentElement; + if (preview) root.dataset.obolPreview = "1"; + else delete root.dataset.obolPreview; const origin = operatorOrigin(); - if (!origin || window.parent === window) return; + if (!origin || window.parent === window) { + return () => { + delete root.dataset.obolPreview; + }; + } const handleMessage = (event: MessageEvent) => { if (event.source !== window.parent || event.origin !== origin) return; @@ -136,7 +153,10 @@ export function StorefrontPreview({ }, origin, ); - return () => window.removeEventListener("message", handleMessage); + return () => { + window.removeEventListener("message", handleMessage); + delete root.dataset.obolPreview; + }; }, [initial]); useEffect(() => { @@ -162,6 +182,14 @@ export function StorefrontPreview({ <>
+ {previewMode ? ( +

+ Operator preview — payment and wallet actions are disabled here. +

+ ) : null}

/skill.md /.well-known/agent-registration.json