Skip to content

Architecture proposal: customizable public storefront via A2UI-style surface contract #669

Description

@bussyjd

Context

PR #663 (feat(sell): add storefront branding profile and /api/storefront.json) introduces the first slice of storefront customization:

  • obol sell storefront set/show/reset
  • an operator-owned storefront profile with displayName, tagline, and logoUrl
  • controller publication of /api/storefront.json
  • public storefront consumption of the profile for header, metadata, manifest, favicon, and OG image

That is a useful phase 0, but we should agree on the next architecture before adding more one-off JSON fields to the Next app. The likely next requests are theme colors, hero layout, section ordering, custom copy blocks, richer service grouping, preview/publish, and maybe authenticated local editing. Those can become hard to reason about if each field is threaded directly through bespoke frontend conditionals.

A2UI looks like a good conceptual fit for the next layer because it separates:

  • UI structure: component tree / surface definition
  • data model: profile, services, identity, payment metadata, theme tokens
  • actions: user interactions that can be validated before mutating state
  • transport: REST, JSONL, SSE, WebSocket, etc.

References:

Current PR review notes to address before or alongside this direction

1. Relative custom logos break /opengraph-image

The CLI currently allows site-relative logo URLs, for example /custom.png, via ValidateLogoURL:

// ValidateLogoURL accepts absolute http(s) URLs or site-relative paths.
func ValidateLogoURL(raw string) error {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
if strings.HasPrefix(raw, "/") {
return nil
}
if strings.HasPrefix(raw, "https://") || strings.HasPrefix(raw, "http://") {
return nil
}
return fmt.Errorf("logo URL must be https://..., http://..., or a path starting with /")

The OG route then passes storefront.logoUrl directly to ImageResponse:

<img
src={storefront.logoUrl}
alt={storefront.displayName}
width={72}
height={72}
style={{ width: 72, height: 72, borderRadius: 16, objectFit: "cover" }}

I reproduced a failure with logoUrl: "/custom.png": /opengraph-image returns an error because ImageResponse requires an absolute image URL or data URL in that context.

Possible fixes:

  • Resolve relative logo paths against the public site URL before rendering OG images.
  • Or reject relative custom logos until we add explicit asset hosting.
  • Or distinguish logoUrl from stored asset references and let the controller publish fully resolved asset URLs only.

2. gofmt needed

internal/serviceoffercontroller/controller.go compiles, but gofmt -d reports alignment changes around the new storefrontProfileInformer fields and initializer.

Validation already run on PR head e3510bd

go test ./cmd/obol ./internal/storefront ./internal/serviceoffercontroller -count=1

Passed.

cd web/public-storefront
npm ci
npm run build

Passed. npm ci reported existing dependency audit findings: 1 moderate, 1 high.

Problem statement

We need storefront customization without turning the public storefront into a loose, operator-supplied HTML/React execution surface.

The storefront is public on the tunnel hostname. It should remain constrained, predictable, safe to cache, and easy for buyers/agents to consume. At the same time, sellers should be able to differentiate their storefront beyond just display name, tagline, and logo.

If we keep extending /api/storefront.json as a flat profile object consumed ad hoc by Next components, we risk:

  • tight coupling between config shape and frontend implementation details
  • no clear way to preview or validate changes before publishing
  • no stable contract for future non-Next renderers or agent-facing clients
  • ambiguous ownership between ServiceOffer listing metadata, AgentIdentity metadata, and seller-wide storefront metadata
  • unsafe future pressure toward custom HTML/CSS/script
  • hard-to-test rendering branches as fields multiply

Goals

  • Let operators customize the public storefront in a structured, validated way.
  • Keep payment/routing/security behavior separate from presentation.
  • Preserve existing public/private route boundaries:
    • public: /, /services/*, /skill.md, /api/services.json, /api/storefront.json, discovery docs
    • local-only: internal frontend, eRPC, dashboards, admin surfaces
  • Keep the public storefront renderer component-catalog based, not arbitrary code based.
  • Allow the controller to resolve a public, cacheable snapshot from multiple sources: storefront config, ServiceOffers, AgentIdentity, tunnel URL, defaults.
  • Support a future local editor/preview flow without needing to redesign the data contract.
  • Make the contract versioned from the start.

Non-goals

  • Do not allow arbitrary HTML, JavaScript, or remote React components from operator config.
  • Do not expose the local frontend/admin UI on the public tunnel hostname.
  • Do not move service pricing, settlement, or x402 verifier behavior into storefront config.
  • Do not make the public storefront depend on live write access to Kubernetes.
  • Do not require streaming for the first implementation. Static JSON is enough for phase 1.

Proposed architecture

operator CLI / future local editor
        |
        v
StorefrontConfig desired state
        |
        v
serviceoffer-controller
        |
        +--> StorefrontSnapshot
        |       - resolved profile
        |       - resolved theme tokens
        |       - resolved layout config
        |       - service catalog projection
        |       - identity / trust / payment metadata
        |       - validated public asset URLs
        |
        +--> /api/storefront.json
        +--> /api/storefront.surface.json or /api/storefront.surface.jsonl
        +--> /api/services.json
        |
        v
Next public storefront renderer
        |
        v
safe Obol storefront component catalog

1. StorefrontConfig: operator-owned desired state

This is what the CLI or future local editor mutates. It should remain sparse and intent-oriented, not fully resolved.

Possible shape:

{
  "schemaVersion": "storefront.obol.org/v1alpha1",
  "profile": {
    "displayName": "Acme Agents",
    "tagline": "Paid inference and API services",
    "logo": {
      "source": "url",
      "url": "https://cdn.example.com/logo.png"
    }
  },
  "theme": {
    "mode": "dark",
    "accent": "#2FE4AB",
    "background": "#091011"
  },
  "layout": {
    "template": "marketplace",
    "hero": {
      "title": "Agent services",
      "subtitlePath": "/profile/tagline"
    },
    "sections": [
      { "id": "featured", "title": "Featured", "filter": { "featured": true } },
      { "id": "demo", "title": "Demos", "filter": { "category": "demo" } },
      { "id": "all", "title": "All services", "filter": {} }
    ]
  },
  "content": {
    "footerLinks": [
      { "label": "Docs", "href": "/skill.md" },
      { "label": "Registration", "href": "/.well-known/agent-registration.json" }
    ]
  }
}

Initial storage options:

2. StorefrontSnapshot: controller-resolved public state

The controller should resolve all defaults and external references into a public snapshot.

Inputs:

  • StorefrontConfig desired state
  • Ready/draining ServiceOffers
  • AgentIdentity and ERC-8004 registration state
  • tunnel/base URL from obol-stack-config
  • static defaults from the codebase
  • validated assets

Outputs:

  • /api/storefront.json: compact profile/snapshot for simple clients and metadata
  • /api/storefront.surface.json: component/data contract for the renderer
  • /api/services.json: current service catalog, as today

Important behavior:

  • Add schemaVersion now.
  • Distinguish sparse desired state from resolved published state.
  • Publish fully resolved asset URLs for public render paths.
  • Keep empty values meaningful where needed, so operators can clear a single field without resetting everything.
  • Add status/validation errors before publishing if we move to CRD.

3. Obol Storefront Catalog

Use an Obol-owned catalog of safe, known components instead of arbitrary UI. This can be A2UI-compatible without exposing arbitrary component execution.

Example catalog components:

  • StorefrontShell
  • Header
  • Hero
  • ServiceSection
  • ServiceCard
  • PaymentBadges
  • TrustBanner
  • IdentitySummary
  • MarkdownBlock with a safe markdown subset
  • FooterLinks
  • EmptyState

Component properties should bind to data model paths, not duplicate all data inline.

Example data paths:

  • /profile/displayName
  • /profile/tagline
  • /profile/logoUrl
  • /theme/accent
  • /services
  • /servicesBySection/demo
  • /identity/agentId
  • /trust/supportedTrust

4. A2UI-style surface endpoint

A2UI v0.9-style messages would let us define the storefront as a surface:

{"version":"v0.9","createSurface":{"surfaceId":"storefront","catalogId":"https://obol.org/catalogs/storefront/v1.json"}}
{"version":"v0.9","updateDataModel":{"surfaceId":"storefront","path":"/profile","value":{"displayName":"Acme Agents","tagline":"Paid inference and API services","logoUrl":"https://cdn.example.com/logo.png"}}}
{"version":"v0.9","updateDataModel":{"surfaceId":"storefront","path":"/services","value":[/* service catalog projection */]}}
{"version":"v0.9","updateComponents":{"surfaceId":"storefront","components":[{"id":"root","component":"StorefrontShell","children":["header","hero","sections","footer"]},{"id":"header","component":"Header","profile":{"path":"/profile"}},{"id":"hero","component":"Hero","title":"Agent services","subtitle":{"path":"/profile/tagline"}},{"id":"sections","component":"ServiceSections","services":{"path":"/services"}},{"id":"footer","component":"FooterLinks","links":{"path":"/content/footerLinks"}}]}}

For phase 1, this can be a single static JSON array or JSONL document served over HTTP. SSE/WebSocket can wait until we have live editor preview or progressive updates.

Why this helps:

  • layout is data, not hard-coded branches spread across Next pages
  • the renderer remains constrained to known Obol components
  • future editor previews can render the same surface before publish
  • the catalog becomes the compatibility boundary
  • service data can update independently from the component tree

5. Public Next renderer

The existing Next app remains the public storefront runtime. It should become a renderer for the Obol storefront catalog.

Responsibilities:

  • fetch /api/storefront.surface.json or /api/storefront.surface.jsonl
  • validate message schema and catalog IDs
  • render only known components
  • continue to support current /api/services.json polling during migration
  • keep metadata/manifest/OG generation backed by resolved StorefrontSnapshot
  • fail closed to a default storefront if the surface is malformed

6. Actions and future editor flow

Public storefront actions should be limited to safe interactions:

  • open service endpoint
  • copy endpoint
  • open docs
  • filter/sort client-side

A future local authenticated editor could support write actions:

  • update profile
  • update theme
  • reorder sections
  • preview draft
  • publish draft
  • reset to defaults

Those editor actions should target a local-only endpoint or CLI-backed workflow, not the public tunnel route.

Suggested implementation phases

Phase 0: tighten PR #663

  • Fix relative logo handling in OG image rendering.
  • Run gofmt.
  • Add schemaVersion to /api/storefront.json if low-risk.
  • Add a test case for relative logo URL behavior.
  • Add a test that storefront publication resolves public/default logo URLs consistently.

Phase 1: versioned snapshot

  • Introduce internal/storefront/schema.go with typed desired vs published structs.
  • Keep ConfigMap storage, but make the payload versioned.
  • Add StorefrontConfig and StorefrontSnapshot builders.
  • Publish a richer /api/storefront.json while preserving the existing flat fields for compatibility.
  • Define clear merge semantics for partial updates and field clearing.

Phase 2: Obol catalog and static surface

  • Define web/public-storefront/src/catalog/obol-storefront-v1.ts.
  • Add /api/storefront.surface.json or /api/storefront.surface.jsonl from the controller.
  • Build a small renderer that maps catalog components to existing React components.
  • Use the surface for page layout while still using /api/services.json for service refresh.
  • Add malformed-surface fallback tests.

Phase 3: preview and local editor

  • Add obol sell storefront preview to render a draft locally or publish a temporary draft endpoint.
  • Optionally add a local-only editor in the internal frontend.
  • Add action handling for preview/publish/reset.
  • Consider CRD migration if we need status conditions or draft/published state.

Phase 4: optional live updates

  • If useful, add SSE or WebSocket transport for live preview.
  • Keep public storefront static/request-response unless there is a concrete need for streaming.

Security and validation constraints

  • No arbitrary HTML, script, remote React, or iframe components from operator config.
  • Remote images must be validated and rendered safely. Prefer resolved public URLs or managed uploaded assets.
  • Keep public routes intentionally limited. Do not expose local frontend/admin routes through the tunnel hostname.
  • Theme tokens should be validated against contrast/accessibility constraints where practical.
  • Markdown content, if allowed, should use a small safe subset and sanitized links.
  • Surface renderer should reject unknown catalog IDs, unknown components, and invalid data bindings.
  • Metadata and OG routes should degrade to defaults rather than returning 500s.

Open questions for validation

Product / UX

  • What is the minimum useful customization set beyond name/tagline/logo?
  • Do we want templates such as minimal, marketplace, agent-profile, docs-first?
  • Should sellers be able to add custom markdown sections, or only structured fields?
  • Should service grouping/order live in ServiceOffer.spec.listing, storefront config, or both?

Frontend

  • Should the Next app render from A2UI-style messages directly, or should we compile the snapshot into a simpler internal view model first?
  • Should /api/storefront.surface.json be a JSON array or JSONL from day one?
  • How much of the existing components can be reused unchanged behind a renderer adapter?
  • What is the fallback behavior for a malformed surface?

Controller / Kubernetes

  • Is ConfigMap storage enough for v1, or do we want a StorefrontConfig CRD early?
  • If CRD, do we need status.conditions for validation and publish state?
  • Should the controller publish draft and active snapshots separately?
  • Should storefront profile be tied to AgentIdentity eventually?

Security

  • What asset sources should be allowed for logos and images?
  • Should custom external images be proxied/cached, copied into a managed asset store, or referenced directly?
  • How do we prevent public metadata endpoints from becoming an accidental admin/config leak?
  • Which routes need explicit hostname restrictions as this grows?

Compatibility

  • How long should the flat /api/storefront.json fields remain stable?
  • Do we expect external clients to consume storefront metadata, or only browsers?
  • Should the catalog ID be a local version string, public URL, or both?

Proposed acceptance criteria for the architecture spike

  • Team agrees on desired vs published state split.
  • Team agrees whether v1 storage is ConfigMap or CRD.
  • Team agrees on the initial Obol Storefront Catalog component set.
  • Team agrees whether the surface endpoint is JSON array or JSONL.
  • Team agrees how relative/custom assets are represented and resolved.
  • A small prototype can render the existing PR feat(sell): allow for the control of your storefront branding  #663 storefront from a generated surface without changing public route posture.

Proposed prototype deliverable

A follow-up PR could include:

  • internal/storefront typed schema with schemaVersion
  • controller-generated /api/storefront.surface.json
  • web/public-storefront renderer for 5 to 7 catalog components
  • tests for malformed surface fallback
  • tests for logo URL resolution in metadata and OG image paths
  • no public write actions
  • no CRD unless the team explicitly chooses that path

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions