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
- Plugin implements
sdk.UIPluginin Go and declares extensions - Stoa validates, collects, and serves extensions via a manifest API
- 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:
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:
| Type | Renders |
|---|---|
text | Text input |
password | Password input |
number | Number input |
textarea | Multi-line text |
toggle | Checkbox |
select | Dropdown 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:
{
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: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
| Slot | Location | SPA |
|---|---|---|
storefront:checkout:payment | After payment method selection | Storefront |
storefront:checkout:after_order | After order confirmation | Storefront |
admin:payment:settings | Payment method detail page | Admin |
admin:sidebar | Sidebar navigation | Admin |
admin:dashboard:widget | Dashboard widgets | Admin |
Manifest API
The backend serves filtered extensions:
GET /api/v1/store/plugin-manifest— onlystorefront:*slotsGET /api/v1/admin/plugin-manifest— onlyadmin:*slots (requires auth)
Response:
{
"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:oradmin: - 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.
| URL | Result |
|---|---|
/plugins/myplugin/assets/checkout.js | Allowed |
/api/v1/admin/plugins/myplugin/settings | Allowed |
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.js | Rejected — protocol-relative URL |
https://external.com/script.js | Rejected — absolute URL |
../../../etc/passwd | Rejected — path traversal |
The implementation checks two conditions:
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
}- Path traversal (
..) is blocked explicitly. - 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
integrityhash - Scoped API Client — plugins can only call
/api/v1/store/*,/api/v1/admin/*, and/plugins/* - Dynamic CSP — external scripts from
ExternalScriptsare added toscript-src,frame-src, andconnect-srcin 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:
<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.