- {DEFAULT_HERO_TITLE} -
-- {catalog.tagline} -
- {catalog.description ? ( -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/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 (
- <>
-
- {catalog.tagline}
-
- {DEFAULT_HERO_TITLE}
-
-
+ Operator preview — payment and wallet actions are disabled here. +
+ ) : null} ++ {storefront.tagline} +
+ {storefront.description ? ( +