The Plugin Model
This page explains why Mydia plugins are shaped the way they are: why a Wasm component instead of an embedded scripting language, how the capability sandbox works, what its current limits actually are, who gets trusted and why, and what the host-version floor buys an operator running a self-hosted instance. For the mechanical contract (the event schema, the capability table, the manifest fields), see the events, capabilities, host functions and guest exports references and the manifest schema. For hands-on steps, see the tutorial and the how-to guides.
Why a component, not a scripting runtime
A lot of plugin systems reach for an embedded scripting language: ship a Lua or JS interpreter in the host process, hand a plugin author a global object, and let them call whatever the interpreter exposes. That's fast to build and familiar to write against, but the safety boundary is only as good as what the embedder remembers to leave out of the global namespace, and every plugin shares the interpreter's runtime and heap with the host process.
Mydia plugins are WebAssembly components instead. A component's imports and exports are typed and declared up front in a WIT (WebAssembly Interface Types) file, not discovered by calling into a global object at runtime. That buys three things a scripting embed doesn't give for free:
- No ambient anything. A freshly instantiated component has no network,
filesystem, or OS access by default. The only functions it can call are the
ones the host explicitly linked in, one per declared capability. There's no
equivalent of "someone forgot to strip
os.executefrom the sandbox," since nothing is there to strip. - A typed, versioned boundary. The WIT contract, not a hand-rolled JSON protocol or a set of blessed global functions, is the single source of truth both the Elixir host and the Rust guest SDK build against. wasmtime's component linker type-checks the boundary at instantiation and refuses a guest whose imports don't match, so drift between host and guest is a build error, not a runtime surprise months later.
- Fresh isolation per call. Each invocation runs against a fresh component instance and store, so guests never share mutable linear memory with each other or across calls. A scripting-language plugin system that shares one interpreter heap across plugins doesn't get this for free either.
The SDK (mydia-plugin-sdk) happens to be Rust today, but nothing
about the contract is Rust-specific: it's a WIT interface, which is the point
of the component model. The #[mydia_plugin_sdk::plugin] macro just adapts a
plain typed handler function onto the component's exported interface, so plugin
authors never hand-write the generated binding boilerplate.
Exports, imports, and the contract version
A plugin is a Wasm component built for wasm32-wasip2 against the
canonical WIT contract mydia:plugin@1.7.0, living at
native/mydia_plugin_sdk/wit/plugin.wit.
It exports handlers the host calls (events, schedules, the setup wizard, health checks, pages, shelves) and imports the host's capabilities (HTTP, data reads, the key-value store, watch-state writes, and so on). The SDK macro generates the exports, and one a plugin does not implement returns an error. Every import is enforced server-side on every call; there is no path around it. Which function arrived in which contract version is listed in Contract versions.
The package version in the WIT file is the ABI version, and the contract
is meant to evolve additively (new functions, new record fields, new variant
cases) rather than by breaking existing signatures. That's what lets a plugin
built against 1.0 keep running unmodified against a 1.7 host: the host
detects the guest's contract version from its bytes at instantiation and
serves the matching interface, rather than forcing every plugin to track the
host's latest release.
What the host owns and what a plugin owns
Every plugin, bundled or third-party, follows one rule: the host owns the nouns other features need to see, and the plugin owns the behaviour specific to the remote system.
Two questions place any piece of a design:
- Would the admin UI, a user's profile page, another feature, or a different plugin ever need to read or show this? If so, it is a host noun. The plugin reads and writes it through contract functions, and the host stores it.
- Does it change when the remote service changes its API? If so, it belongs to the plugin.
Host nouns are watch state, favorites, which Mydia user is linked to which remote account, per-user connections and their tokens, operator settings and secrets, plugin instances, sync-run history, health status, schedules, egress policy, and shelves. For a shelf, the list, its staleness and its dismissals are the host's; choosing the titles is the plugin's. The host also renders every screen. A plugin describes setup steps declaratively and ships no UI code of its own.
Plugin behaviour is the protocol: auth handshakes, server discovery and endpoint probing, pagination, parsing remote IDs, crawling the remote library to build mappings, and the reconcile loop that decides what to pull and push. The plugin's working state (checkpoints, cursors, mapping caches) lives in its own store and is opaque to the host.
The same thinking shapes the write functions. Marking something watched is idempotent, so a plugin can replay a sync after a crash and land in the same place. Favoriting is additive only: no host function removes a favorite, so a deletion on a remote list can never strip curation the user did in Mydia.
Three consequences follow:
- A plugin never keeps the only copy of a host noun. Storing account links or sync history in KV because the contract lacks a function for it is a violation. Add the noun to the host instead.
- New host nouns are generic. A noun is named for the concept ("account link", "sync run"), never for the service ("Plex profile"), and needs a plausible second consumer before it enters the contract.
- Shared engines are optional helpers. A Rust crate that packages reconciliation or polling logic is welcome, but the contract must never require it. A plugin written in another language, or one that needs a different loop, still gets every host noun.
A plugin can run as several instances, one per configured server or
account, when its manifest sets multi_instance. Each instance has its own
settings, store, account links, schedule and sync history, and the plugin
learns which instance it is serving from instance_id in its injected config.
The rule sits between two common designs. Typed-slot systems (Terraform providers, Kodi PVR add-ons, Grafana data sources) keep the engine in the host and leave the plugin a thin adapter. That duplicates nothing, but the engine can never leave core and plugins cannot do what the slot did not anticipate. Fully self-contained systems (Jellyfin's Trakt plugin, Obsidian, WordPress) hand the plugin everything, including UI and storage. That gives authors full freedom, but the host cannot explain what a plugin did and every plugin invents its own UX. Home Assistant's integrations are the closest model to Mydia's: self-contained code that plugs into host-owned entities and host-rendered config flows.
The capability-based sandbox
Every class of thing a plugin might want to do (make an HTTP request, read a media record, hold a key/value store, run on a schedule) is a named capability, and capabilities are deny-by-default. A plugin's manifest declares what it wants; for a third-party plugin the operator sees that declaration and approves it before the plugin runs at all. A plugin can never widen its own grant at runtime; there's no equivalent of asking for permission mid-execution the way a mobile app might.
A third-party grant only widens when an operator approves it. A revised
manifest that asks for more, whether a new capability class, a new net:http
host or a new subscribed event, keeps running on its old grant, and calls
against anything newly declared come back Denied until re-approval. The
reason is that the operator approved a specific set, and a plugin update is not
a second chance to enlarge it unnoticed. See
Grants and approval for the
rules and Install and manage plugins for what
the operator sees.
Image-bundled system plugins are the deliberate exception. They are delivered as part of the trusted Mydia host release, so discovery grants their complete shipped capability set and enables a new system plugin. Later host upgrades replace that grant with the bundled manifest's current effective set, including settings-derived HTTP hosts. Upgrades preserve whether the administrator has since enabled or disabled the plugin. Because the release is the source of truth, Remove and Revoke are not final for a bundled plugin: the next reconciliation restores the shipped capabilities and re-seeds a removed row approved and enabled. Disable is the control that persists.
What both cases share is the guarantee worth trusting: what a plugin may do is
fixed in the granted_capabilities the host stores, and the host re-checks that
grant on every call rather than trusting the manifest.
There is a third path, for the plugin you are still writing. A sideloaded
plugin is installed from a .wasm file and manifest on the operator's own disk,
with mix mydia.plugin install in development or mydia-cli plugin install in
production. Its bytes are trusted the way the override directory is: the file
came from the operator, so there is no catalog hash to verify against. Its
capabilities are not trusted. It installs inactive and waits for approval like
any index plugin, unless the operator passes --approve, which grants the
declared set on the spot. Skipping approval is an explicit act by the person who
built the plugin, not a default. Reinstalling replaces the bytes and clears the
grant, as a fresh install would, and a bundled slug is refused because its bytes
come from the image.
This is also why net:http is an exact-hostname allowlist with no wildcards:
a wildcard subdomain grant is effectively an open exfiltration channel, since
the plugin author (or someone who compromises their supply chain later)
controls what any subdomain of that wildcard resolves to. The full capability
table is in Capabilities and its host functions
are in Host functions;
what matters here is the shape of the guarantee: the host, not the plugin,
decides what "having a capability" actually allows on every single call, not
just the first one.
The same reasoning explains the settings that name a host. An operator who runs
their own ntfy server or webhook cannot be listed in a manifest the author wrote
in advance, so a url setting can be marked host-granting: the host, never the
guest, derives the allowlist entry from the value the operator typed. The plugin
computes nothing, and Mydia stays agnostic about the target. A connection's
device flow follows the same rule: its code, poll and verification URLs must sit
on declared net:http hosts, so the link the host renders in trusted UI cannot
become a phishing surface.
Two capabilities reach across users, and they are consent-scoped for that
reason. users:connections and data:read playback_progress let a plugin see
linked accounts and watch history and mark items watched on a user's behalf, so
the approval screen says exactly that. A user becomes visible to a plugin only
after they connect it on the Integrations page. Outside a page call,
data-list playback_progress returns rows only for connected users, and the
write functions are refused for a user with no active connection.
Two honest limitations
The sandbox is real, but it isn't complete, and plugin authors will find the edges eventually, so it's worth stating them plainly rather than letting someone discover them the hard way:
- The memory cap only applies at instantiation. Mydia caps a component
store's linear memory, but the underlying Wasm runtime only enforces that
cap when a component is instantiated: an instance whose minimum declared
memory exceeds the cap is refused outright. It does not currently cap
memory.growcalls once the instance is running. A guest that keeps allocating at runtime is not stopped by the memory cap alone. - There is no fuel or CPU metering for component-model guests. A runaway loop in a handler isn't preemptively interrupted the way it would be under a runtime with fuel or epoch-based interruption. What still bounds it is a per-call wall-clock timeout that force-kills the invocation and reclaims the Elixir process on the other side of the call; the residual is that the underlying OS thread of a wedged guest isn't reclaimed until it yields on its own.
The exact numbers are on the limits page. Both are accepted trade-offs of the current Wasm component runtime, not design choices Mydia is defending as sufficient on their own. They're one reason Mydia's plugin model leans on a curated, trusted set of plugins rather than treating the sandbox as the sole safety boundary against an arbitrary, unvetted one: the capability system and the instantiation-time memory cap are real, meaningful guards, but they are not a substitute for knowing what a plugin you install actually does.
Pages and writes on a user's behalf
Most plugins react to events with no user in the room. A plugin that declares
surfaces:page is different: it serves its own page inside Mydia, and what it
does there it does for the person looking at it. The model that keeps this safe
has a few parts.
Interaction scope. Page host functions (search, media-add, the
collection-* writes, mark-watched-state, add-favorite) work only during an
on-http call, and only as the user of that call. The host takes the user, role
and page session from a signed token and the database, never from the request
body or the guest, and refuses the same functions from an event or schedule
handler. Reads in a page call are scoped to that user too, so a page never sees
more than the person using it.
The frame token. The page authenticates with a signed token that is a one-hour bearer. A password change invalidates it at once, but logging out does not revoke it.
Grants. A write surface the plugin declares in surfaces:write is still not
enough on its own. The first time a page writes to a surface for a user, the
write is parked and the user is asked. They can answer once (this write
only), for this session (until the page is closed or reloaded) or always
(until revoked). Later writes to that surface by that plugin for that user then
go through without a prompt.
Role ceilings. An administrator caps the longest grant each role may hold
per plugin, in the plugin's settings. The defaults are always for admins and
users, session for guests and none for read-only users. A user is only
offered choices at or below their role's ceiling, and lowering a ceiling stops
counting stored grants above it at once. Role also decides what a write means:
a guest's media-add files a request, a user's adds to the library.
A confirmation the plugin cannot fake. The page runs in a sandboxed iframe
and can only ask, by posting {mydia: "confirm", ids} to its parent. The host
then shows its own modal, built from the pending-write rows it stored (what will
change, resolved by the host), not from anything the plugin sent. The allow or
deny decision comes from a click in the host's UI, and the plugin only hears
back confirmed, denied or expired. A pending write expires after an hour.
Journal and undo. Every write that happens is recorded in a journal, and
the write and its entry commit together, so nothing changes unrecorded. Most
entries carry the inverse needed to revert them; one the host cannot reverse is
shown as irreversible. Users see their entries on /plugins/<slug>/activity and
can undo one entry or a whole batch. Undo refuses when the item has changed
since, and cannot be applied twice.
Why the host never sees page content. The host is a courier for page
traffic. Request and response bodies pass through to and from the guest as
opaque text; the host does not parse, store or log them, and no host code knows
what a page is for. The only data the host keeps is what it resolved itself: the
pending-write rows and the journal, which hold host-resolved arguments (ids,
titles it looked up) and never the page's own content. Whatever the page keeps
stays in the plugin's own per-user state:kv keys.
To build one, see Serve a page.
What the host-version floor is for
A plugin's manifest can declare min_host_version, the lowest Mydia release
it needs. Because Mydia is self-hosted, there's no coordinated deploy order
between "the host" and "the plugins running on it": an operator upgrades their
instance whenever they choose, and a plugin author has no way to know which
host version any given installation is running. min_host_version lets a
plugin that genuinely needs a capability, event, or host function added in a
specific release say so explicitly. Mydia refuses to activate a plugin whose
floor exceeds the running host with a clear "requires mydia >= X" message,
before instantiation, rather than instantiating it anyway and failing in a way
that looks like a mysterious runtime bug. A guest that imports a function newer
than the host serves cannot link, so the floor lets an older host decline it
cleanly instead. Combined with the additive-evolution rule above, this is what
makes it safe for a 1.0 plugin and a 1.1 plugin to both run correctly
against the same host, and for that host to be upgraded without breaking either. The functions by version are in
Contract versions.
The rules are few, and each follows from that purpose:
- The floor is a Mydia release version, not a contract version. Contract numbers describe the interface; operators and authors reason in releases.
- Pre-release tags are ignored. A
0.16.0-beta.1host meets a0.16.0floor, because a beta carries the feature the floor names. - Development builds (
-dev) meet any floor, so working from source never blocks a plugin under test. - Bundled plugins are exempt, since they ship with the host that runs them.
- Absent means no floor. Most plugins never need one.
A floor that cannot be parsed on either side does not block activation. The field itself must be a semantic version or the manifest is rejected; see the manifest reference.
Why the SDK is pinned to a tag
The SDK is not on crates.io, so a plugin depends on it as a git dependency. A
tag is the right pin because it names the host release a plugin targets. A
branch would follow unreleased host changes, and a rebuild months later could
produce a component built against a different contract than the one you tested.
Pinning makes upgrading a deliberate act: move the tag when you move to a newer
host, and raise min_host_version if you start using something it added.