Skip to content

Plugin UI Extensions

Plugins can extend the Admin Panel and Storefront with custom UI — without modifying the core SPAs. Two mechanisms are available:

  • Schema-based forms for simple settings (API keys, toggles, selects)
  • Web Components for complex UIs (payment widgets, dashboards)

How it works

  1. Plugin implements sdk.UIPlugin in Go and declares extensions
  2. Stoa validates, collects, and serves extensions via a manifest API
  3. The frontend fetches the manifest and renders <PluginSlot> components at predefined locations

Implementing UIPlugin

Add UIExtensions() to your plugin alongside the existing sdk.Plugin interface:

go
package myplugin

import "github.com/stoa-hq/stoa/pkg/sdk"

func (p *Plugin) UIExtensions() []sdk.UIExtension {
    return []sdk.UIExtension{
        {
            ID:   "myplugin_settings",
            Slot: "admin:payment:settings",
            Type: "schema",
            Schema: &sdk.UISchema{
                Fields: []sdk.UISchemaField{
                    {
                        Key:   "api_key",
                        Type:  "password",
                        Label: map[string]string{"en": "API Key", "de": "API-Schlüssel"},
                    },
                    {
                        Key:  "mode",
                        Type: "select",
                        Label: map[string]string{"en": "Mode", "de": "Modus"},
                        Options: []sdk.UISelectOption{
                            {Value: "test", Label: map[string]string{"en": "Test"}},
                            {Value: "live", Label: map[string]string{"en": "Live"}},
                        },
                    },
                },
                SubmitURL: "/api/v1/admin/plugins/myplugin/settings",
                LoadURL:   "/api/v1/admin/plugins/myplugin/settings",
            },
        },
    }
}

Extension Types

Schema

Schema extensions render forms from field descriptors. Supported field types:

TypeRenders
textText input
passwordPassword input
numberNumber input
textareaMulti-line text
toggleCheckbox
selectDropdown with options

Labels, placeholders, and help text support i18n via map[string]string (locale → text).

If load_url is set, the form loads current values on mount. If submit_url is set, a save button is shown and the form POSTs values on submit.

Web Component

For complex UIs, plugins can ship a Web Component loaded from embedded assets:

go
{
    ID:   "myplugin_checkout",
    Slot: "storefront:checkout:payment",
    Type: "component",
    Component: &sdk.UIComponent{
        TagName:         "stoa-myplugin-checkout",
        ScriptURL:       "/plugins/myplugin/assets/checkout.js",
        Integrity:       "sha256-...",
        ExternalScripts: []string{"https://js.example.com/v3/"},
        StyleURL:        "/plugins/myplugin/assets/checkout.css",
    },
}

The web component receives two properties:

  • context — slot-specific data (e.g. payment method ID, order total)
  • apiClient — scoped HTTP client limited to /api/v1/store/* and /plugins/*

Dispatch plugin-event CustomEvents to communicate back to the host page.

Real-world example

The Stripe plugin uses a Web Component to render Stripe Payment Elements in the checkout. See its frontend/dist/checkout.js for a complete implementation.

Serving Assets

Plugin assets are served from Go-embedded files. In Init(), mount the file server on the provided AssetRouter:

go
//go:embed frontend/dist
var assetsFS embed.FS

func (p *Plugin) Init(app *sdk.AppContext) error {
    sub, _ := fs.Sub(assetsFS, "frontend/dist")
    app.AssetRouter.Handle("/*", http.StripPrefix(
        "/plugins/"+p.Name()+"/assets",
        http.FileServerFS(sub),
    ))
    return nil
}

Assets are served at /plugins/{name}/assets/*.

Available Slots

SlotLocationSPA
storefront:checkout:paymentAfter payment method selectionStorefront
storefront:checkout:after_orderAfter order confirmationStorefront
admin:payment:settingsPayment method detail pageAdmin
admin:sidebarSidebar navigationAdmin
admin:dashboard:widgetDashboard widgetsAdmin

Manifest API

The backend serves filtered extensions:

  • GET /api/v1/store/plugin-manifest — only storefront:* slots
  • GET /api/v1/admin/plugin-manifest — only admin:* slots (requires auth)

Response:

json
{
  "data": {
    "extensions": [
      {
        "id": "myplugin_settings",
        "slot": "admin:payment:settings",
        "type": "schema",
        "schema": { "fields": [...], "submit_url": "..." }
      }
    ]
  }
}

Validation Rules

Extensions are validated at startup. Invalid extensions are skipped with a warning:

  • Slot must start with storefront: or admin:
  • Schema field types must be from the allowed list
  • Web Component tag names must use stoa-{pluginName}- prefix
  • URLs must be relative paths starting with / — see URL validation below

URL validation

All URL fields (ScriptURL, StyleURL, SubmitURL, LoadURL) are validated using a whitelist approach: only relative paths starting with / are accepted.

URLResult
/plugins/myplugin/assets/checkout.jsAllowed
/api/v1/admin/plugins/myplugin/settingsAllowed
javascript:alert(1)Rejected — not a relative path
data:text/html,...Rejected — not a relative path
vbscript:...Rejected — not a relative path
//attacker.com/evil.jsRejected — protocol-relative URL
https://external.com/script.jsRejected — absolute URL
../../../etc/passwdRejected — path traversal

The implementation checks two conditions:

go
func validateURL(u string) error {
    if u == "" {
        return nil
    }
    if strings.Contains(u, "..") {
        return fmt.Errorf("path traversal not allowed: %q", u)
    }
    if !strings.HasPrefix(u, "/") || strings.HasPrefix(u, "//") {
        return fmt.Errorf("only relative paths starting with / are allowed: %q", u)
    }
    return nil
}
  1. Path traversal (..) is blocked explicitly.
  2. The URL must start with / but must not start with //. This single whitelist rule automatically rejects all dangerous schemes (javascript:, data:, vbscript:, ftp:, file:, blob:, etc.) and protocol-relative URLs.

Plugin developers

Do not attempt to reference external scripts directly via ScriptURL or StyleURL. Use the ExternalScripts field on UIComponent for third-party scripts — those are loaded at runtime by the host page and their domains are added to the CSP script-src directive. ScriptURL and StyleURL must point to assets served from your plugin's own embedded file server at /plugins/{name}/assets/.

Security

  • Light DOM with scoped CSS — Web Components render in the Light DOM for maximum compatibility (e.g. Stripe Payment Element requires direct DOM access). CSS isolation is achieved via scoped class prefixes (.stoa-{pluginName}-{component})
  • SRI — Scripts are verified via integrity hash
  • Scoped API Client — plugins can only call /api/v1/store/*, /api/v1/admin/*, and /plugins/*
  • Dynamic CSP — external scripts from ExternalScripts are added to script-src, frame-src, and connect-src in the Content-Security-Policy header
  • Go-embedded assets — no user-uploaded scripts, assets are compiled into the binary
  • Whitelist URL validation — all URL fields are validated at startup using a whitelist approach; only relative paths starting with / are accepted, blocking all dangerous schemes and protocol-relative URLs (see URL validation)

Frontend Integration

Use <PluginSlot> in Svelte pages to render plugin extensions at a given slot:

svelte
<script>
  import PluginSlot from '$lib/components/PluginSlot.svelte';
</script>

<PluginSlot
  slot="admin:payment:settings"
  context={{ paymentMethodId: id, provider: form.provider }}
/>

If no plugins provide extensions for a slot, nothing is rendered.

Released under the APACHE 2.0 License.