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): add storefront branding profile #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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions