Skip to content

Plugin Development

Brandon Johnson edited this page Sep 30, 2026 · 1 revision

Writing an Obelo plugin

The canonical copy of this guide lives at docs/plugins/authoring.md in the server repository; this page is a copy of it. Operators looking to install and configure plugins want Plugins.

This is the guide for writing an Installed plugin: a WebAssembly module you build yourself, drop onto somebody's Obelo, and that the server calls as if it had been compiled in.

Everything here is taught from one working plugin — the Discord Event sink, in its own repository at obelo-plugin-discord. It is not an illustration. Obelo's own acceptance suite builds it from source and drives it end to end (internal/api/discord_plugin_test.go), and every code block below is extracted from source by a test, so the guide cannot drift away from code that runs. Three tests do it, one per source, each with its own marker syntax so none can see another's blocks:

Sections Source Test
most of this guide the Discord plugin internal/plugins/discordtest/samples_test.go
§11, the Go SDK pluginsdk/ internal/plugins/sdkguesttest/samples_test.go
pacing and the probe the plugins Obelo ships, under plugins/<id>/ internal/bundled/guidesamples_test.go

In a hurry? Build and install is four commands. The rest of this document is why each of them is what it is.

Contents

  1. What a Plugin is
  2. The Extension points, and what each is asked
  3. The manifest, by example
  4. The ABI: four exports and seven imports
  5. The host functions, and what they refuse
  6. How settings reach the guest
  7. Build and install locally
  8. Reading the counters and the last error while you develop
  9. The JSON schema, and where it lives
  10. The rules you must not break
  11. Writing a plugin in Go with the SDK

1. What a Plugin is

A Plugin is one unit of code that talks to something outside this server. That is the whole definition, and it covers two kinds of thing that are deliberately indistinguishable downstream:

  • a Built-in — compiled into the server, registered from the composition root in Go (today only the Webhook sink; TMDB, MusicBrainz and OpenSubtitles began as Built-ins and now ship as Bundled plugins, under plugins/);
  • an Installed plugin — a .wasm module and a manifest.json an Admin put on their server (as a Plugin package, one zip), loaded into a sandbox at boot or on upload.

The two reach the same registry, wear the same Descriptor, are configured through the same settings endpoints, and are called through the same interfaces. Nothing downstream of registration can tell them apart. What differs is everything below that line: an Installed plugin runs inside a wazero sandbox with no filesystem, no environment, no sockets and no stdio, and reaches the outside world only through host functions.

A Plugin never has identity of its own. It does not decide what a Title is called, it does not decide whether a match is good enough, it does not decide who may watch anything. It answers the question it was asked and states facts. Every judgment stays on the host's side of the boundary, which is why a hostile or merely buggy Plugin is a Plugin that is switched off, not a corrupted library.

Any language that compiles to wasip1/wasm works. This guide's examples are Go because the reference plugin is Go. There is no SDK to install and nothing to import: the contract is a JSON schema and a handful of function signatures. The reference plugin deliberately does not import the server's Go module, so that what you are reading is what an author in Rust or Zig would have to reproduce.

The vocabulary — Plugin, Extension point, Built-in, Installed plugin, Manifest, Event sink — is defined once in CONTEXT.md and used unchanged here.


2. The Extension points, and what each is asked

The set of seams a Plugin may fill is closed. It grows by decision, not by declaration — a manifest naming any other kind is refused at load. Today there are seven.

Extension point kind What it is asked
Metadata provider metadata-provider "What is this Title? What artwork does it have?"
Subtitle provider subtitle-provider "Which subtitles exist for this release, and give me one."
Event sink event-sink "This just finished." (Outbound HTTP only; it is never asked anything.)
Web reference provider web-reference-provider "Where can a person read about the item these ids name?" (No network at all.)
Lyric provider lyric-provider "What are this track's words?"
Marker provider marker-provider "Where are this File's Intro, Recap, Credits and Preview?"
Sign-in provider sign-in-provider "Is this who they say they are?" — by password, or by a redirect to an identity provider.

Your manifest's provides list says which you fill, and one module may fill more than one — the host looks up only the exports the declared seams need.

The exports each seam needs

Three are always required, whatever you provide:

obelo_alloc(size u32) -> ptr u32
obelo_free(ptr u32)
last_error() -> i64

Then, per seam:

Seam Export Required?
Event sink deliver(ptr, len) -> i64 yes
Subtitle provider obelo_subtitle_search(ptr, len) -> i64 yes
obelo_subtitle_download(ptr, len) -> i64 yes
Metadata provider metadata_lookup(ptr, len) -> i64 yes
metadata_search(ptr, len) -> i64 yes
metadata_artwork_candidates(ptr, len) -> i64 yes
metadata_series_seasons / metadata_season_episodes behind capability episode-list
metadata_album_tracklist / metadata_release_editions behind capability album-tracklist
metadata_external_ref behind capability external-ref
Web reference provider web_reference_links(ptr, len) -> i64 yes
Lyric provider lyric_provider_lyrics(ptr, len) -> i64 yes
Marker provider marker_provider_markers(ptr, len) -> i64 yes
Sign-in provider sign_in_password(ptr, len) -> i64 behind capability password-sign-in
sign_in_authorize_url(ptr, len) -> i64 / sign_in_exchange(ptr, len) -> i64 behind capability redirect-sign-in
sign_in_lookup(ptr, len) -> i64 behind capability sign-in-lookup
sign_in_refresh(ptr, len) -> i64 behind capability sign-in-refresh

The Event sink's call is deliver, not obelo_deliver. The three seams landed in three slices and their export names are not harmonised: the sink's is bare, the subtitle provider's is obelo_-prefixed, the metadata provider's is metadata_-prefixed. Renaming a live export would break every plugin already installed for no gain, so the names are what they are and this table is the authority. A v2 contract is where that gets tidied.

Declaring a capability is a promise. If your manifest declares external-ref and your module does not export metadata_external_ref, the host answers "unavailable" and does not disable you — it is survivable — but you have shipped a manifest that lies to the operator reading it. Declare what you export.

Not declaring a capability costs nothing. An undeclared optional call is never made, so the host does not even enter the sandbox for it.

Two things a Metadata provider author gets wrong, both learned the hard way:

  • A guest never returns artwork bytes, only URLs. The host fetches the image through its own guarded fetcher and files it.
  • A role the host does not file for that kind is refused at the store. Use poster, background or logo.

A Sign-in provider's redirect flow owns almost nothing. The host mints state, the PKCE verifier and the nonce, serves the callback, and hands you only what goes in the authorize URL and the code to exchange. If your exchange gets an OpenID Connect ID token, return it raw in idToken and declare idToken: { "issuerSetting": ..., "clientIdSetting": ... } on the provides entry, naming two of your own declared settings — a url field and a string field, neither with a default — that the operator types. The host verifies every token against them and takes the subject and groups from the token, not from your identity. Declare no idToken and the host takes your identity as given, and the Admin screen says it is not independently verified. The Bundled plugins/oidc is the worked example.

The host hands you redirectUri, and it is built from the origin the browser's request arrived on — the server has no configured external URL. Pass it through unchanged, and tell operators to register that exact URI, <the server's origin>/sign-in/callback, with their identity provider: no wildcards, so the provider itself refuses to send a code anywhere else. Say so where the operator will read it — in a setting's help — as the Bundled plugins/oidc does on its Client ID field.

Between sign-ins the host re-checks every identity a Sign-in provider vouched for — every 24 hours unless the Admin sets another interval — so the Admin's Group mapping follows your directory. Declare sign-in-lookup and export sign_in_lookup to answer a SignInLookupCall ({ "subject" }) with { "status": "active"|"gone"|"disabled", "identity"? }, the identity carrying the person's groups now; or, for a redirect provider, return the refresh token your exchange got in refreshToken, declare sign-in-refresh and export sign_in_refresh to redeem it ({ "refreshToken" } → { "status", "idToken"?, "identity"?, "refreshToken"? }, the last only when your source rotated it). The host verifies a refreshed ID token exactly as it verifies one at sign-in — signature, iss, aud, exp — and takes the groups from it. gone or disabled revokes every session the User holds; an error, or a status the host does not know, is your source being unreachable, and changes nothing but when it is asked again, so never answer gone for a timeout, nor for a refresh token your source refused as expired or revoked (invalid_grant) — that is the token dead, not the person: answer an error. A provider declaring neither is synced at sign-in only, and when an Admin presses "re-sync now". In Go, pluginsdk/signin exports both when the provider you ServeRedirect also implements pluginapi.SignInRefreshProvider or pluginapi.SignInLookupProvider; the Bundled plugins/oidc implements the refresh.

A Sign-in provider that must speak something other than HTTP — an LDAP directory for the password flow — declares "socket": true on its provides entry and gets the one exception to "no sockets": the socket import, during its sign-in calls and no others. The host adds four settings to your form after your own — socket_address (the host:port the operator types, the only address you can reach), socket_tls (tls, starttls or none, default tls), socket_allow_plaintext (the operator's opt-out, shown with a warning) and socket_trusted_ca (a PEM CA used instead of the system's) — and you read them in values like your own. A SocketRequest {op: open|write|read|starttls|close, address?, handle, data, max} answers a SocketResponse. open connects to the operator's address — name another in address and it is refused — and the host dials, encrypts and verifies the certificate itself: you write and read your protocol's plaintext and never see TLS. When open answers upgradePending, the operator chose StartTLS: write your protocol's upgrade request (LDAP's StartTLS extended operation), read the answer, then send starttls; until then the connection carries that one write and nothing more, and the write is at most 512 bytes — room for the upgrade request and none for a bind sent after it. A second write, or a longer one, is refused unless the operator allowed plaintext. A handle is good for the call that opened it: every connection is closed when the call returns or at its deadline, and a handle kept for the next call names nothing. As with a fetch, refused and error are the host's sentences, never to be branched on.

And one a Subtitle provider author gets wrong:

  • The host states maxBytes on the way in and re-checks it on the way out. Answering with more is refused whole and counted against you — see the rules.

3. The manifest, by example

manifest.json sits beside your module in the package. It is the Installed half of a Descriptor: the static facts a Built-in would have written in Go. These fields ARE the settings screen — the name, the description, the docs link and the controls an Admin sees all come from here.

This is the Discord plugin's, whole:

{
  "id": "discord",
  "name": "Discord",
  "version": "1.0.0",
  "apiVersion": 1,
  "description": "Posts a readable line to a Discord channel when a scan or an enrichment pass finishes, when somebody presses play or stop, or when a Library changes.",
  "docsUrl": "https://discord.com/developers/docs/resources/webhook",
  "module": "plugin.wasm",
  "provides": [
    {
      "kind": "event-sink",
      "requiresSecret": false
    }
  ],
  "network": {
    "hosts": ["discord.com"]
  },
  "settings": {
    "requiresSecret": false,
    "defaultUrl": "https://discord.com",
    "fields": [
      {
        "key": "webhook_url",
        "type": "secret",
        "label": "Discord webhook URL",
        "help": "Channel Settings → Integrations → Webhooks → Copy Webhook URL. Anyone holding it can post to the channel, so it is stored as a secret and never shown again.",
        "required": true
      },
      {
        "key": "template_scan_completed",
        "type": "string",
        "label": "Message: scan finished",
        "help": "Placeholders: {library} {kind} {count} {files} {scope} {at}. Leave empty to post nothing for this event.",
        "default": "📚 Scan of **{library}** finished: {count} titles, {files} files"
      },
      {
        "key": "template_enrich_completed",
        "type": "string",
        "label": "Message: enrichment finished",
        "help": "Placeholders: {library} {kind} {count} (matched) {total} {files} (done) {at}.",
        "default": "✨ Enrichment of **{library}** finished: {count} of {total} matched"
      },
      {
        "key": "template_playback_started",
        "type": "string",
        "label": "Message: playback started",
        "help": "Placeholders: {title} {kind} {who} {device} {at}. {who} is a User's display name, or a linked server — never a person on the other side of a Link.",
        "default": "▶️ **{title}** — started by {who}"
      },
      {
        "key": "template_playback_stopped",
        "type": "string",
        "label": "Message: playback stopped",
        "help": "Placeholders: {title} {kind} {who} {device} {at}.",
        "default": "⏹️ **{title}** — stopped by {who}"
      },
      {
        "key": "template_library_changed",
        "type": "string",
        "label": "Message: library changed",
        "help": "Placeholders: {library} {kind} {at}. Off by default — this one fires often.",
        "default": ""
      },
      {
        "key": "mention_role",
        "type": "bool",
        "label": "Mention a role",
        "help": "Prefix every message with a role ping. Off means no message from this plugin ever pings anybody, whatever a Title happens to be called.",
        "default": false
      },
      {
        "key": "role_id",
        "type": "string",
        "label": "Role id",
        "help": "The numeric id of the role to ping. Turn on Developer Mode in Discord, then right-click the role and Copy ID."
      }
    ]
  }
}

Field by field:

Field
id Lowercase letters, digits and dashes. It is the settings key, the route segment and the directory on disk, so it is never renamed for you — a Plugin whose id is not a slug is refused. It must not collide with a Built-in's slug.
name, description, docsUrl What the settings screen shows.
version Yours, and opaque: the host never parses it, orders by it or decides anything from it. It exists so an operator can tell which build they are running.
apiVersion The contract major you built against. It must equal the server's. A mismatch is refused at install and at boot, never at call time, with a message naming which side has to move: "this server speaks plugin API v1; the plugin needs v2 — upgrade the server".
module The file name beside the manifest. Omit it for the conventional plugin.wasm. It is a file name, never a path.
provides[] One entry per seam. kind, plus the seam's static facts: kinds, role, class, capabilities, requiresSecret.
network.hosts[] The outbound allowlist. See below.
settings requiresSecret and defaultUrl for the fixed shape; fields[] for your own. See §6.

network.hosts is the one to get right

It is the most load-bearing claim in the document and the one the host trusts least. Exact host names — no scheme, no port, no path, no wildcards:

"network": { "hosts": ["api.example.test", "cdn.example.test"] }

Matching is exact and case-insensitive on the URL's host with its port removed, so one entry covers a source on any port. A wildcard is deliberately absent: the whole value of the list is that an operator can read it and know what your code may talk to.

Omitting network entirely is legal and much safer — it means a Plugin that makes no outbound requests at all.

The host checks this list from the file on disk, on every fetch. Nothing your guest says at call time can widen it.

Declare a probe (Metadata providers)

The providers screen has a Test connection button, and what it does is run one ordinary metadata_lookup against a reference you name. probe on your provides entry is that reference — one MediaRef, the same shape a real lookup is handed:

"probe": { "kind": "movie", "title": "Inception", "year": 2010 }

The host keeps the judgment and it is short:

Your answer Verdict
matched pass — the host answered and your credential was accepted
no-match pass — same thing. Whether that particular record exists is not what the button is asking
unavailable, a refusal, a fetch error fail, and the reason is the sentence the Admin reads

Declare one. A Metadata provider with no probe is not broken, but its Test connection button answers "this provider declares no connection probe" — which is the most operator-visible feature of the settings screen telling them nothing. This used to be a switch over the eight provider slugs the server shipped, with a reference the author of that file happened to know each source could answer, and an installed third-party provider fell off the end of it into "unknown provider".

Three things worth getting right:

  • Name something that exists and is stable. The point is to reach your source, not to test its catalogue.
  • Fill every field your lookup needs. A probe your own code rejects before it makes a request is a button that passes unconditionally, which is a worse answer than a failure. Obelo shipped exactly that: the Cover Art Archive's probe named an album by title and left album empty, the lookup answered no-match without a single request, and the button had been passing for everyone regardless of what they had typed.
  • If you serve artwork from a second host, name a record that HAS images. A provider that declares both settings.defaultUrl2 and the artwork-candidates capability gets a second call — artwork candidates for the record the probe just resolved — because a lookup proves nothing about an image host that a lookup never touches. A failure there is reported against the image host by name, so the operator knows which of the two URL fields they typed wrong.

This is the MusicBrainz plugin's whole manifest, which is the shipped example of all of it — a probe that reaches both hosts, a second URL, and a raised call budget for a plugin that paces itself:

{
  "id": "musicbrainz",
  "name": "MusicBrainz",
  "version": "1.1.11",
  "apiVersion": 1,
  "description": "Authoritative open music encyclopedia: artists, albums, and tracks. No API key required.",
  "docsUrl": "https://musicbrainz.org/doc/MusicBrainz_API",
  "provides": [
    {
      "kind": "metadata-provider",
      "kinds": ["music"],
      "role": "authoritative",
      "class": "full",
      "capabilities": ["search", "artwork-candidates", "album-tracklist", "external-ref"],
      "probe": {
        "kind": "album",
        "title": "OK Computer",
        "album": "OK Computer",
        "artist": "Radiohead"
      },
      "callBudgetMillis": 90000
    }
  ],
  "network": {
    "hosts": ["musicbrainz.org", "coverartarchive.org"]
  },
  "settings": {
    "defaultUrl": "https://musicbrainz.org/ws/2",
    "defaultUrl2": "https://coverartarchive.org"
  }
}

4. The ABI: four exports and seven imports

The whole calling convention, and it is small enough to state in full.

EXPORTS the guest provides
  obelo_alloc(size u32) -> ptr u32   the host asks the guest for a buffer
  obelo_free(ptr u32)                the host gives one back
  <call>(ptr u32, len u32) -> i64    one per contract call: (ptr<<32 | len) of
                                     the JSON response, or 0
  last_error() -> i64                (ptr<<32 | len) of why the last call
                                     answered 0

IMPORTS the host provides, in module "obelo"
  http_fetch(ptr u32, len u32) -> i64
  log(level u32, ptr u32, len u32)
  kv_get(ptr u32, len u32) -> i64
  kv_set(ptr u32, len u32) -> i64
  kv_delete(ptr u32, len u32) -> i64
  settings_get() -> i64
  socket(ptr u32, len u32) -> i64

Seven imports, and that is the whole world. A module importing any other namespace is refused before it is instantiated, so "what can this code call" is answered by reading the module rather than by watching it run.

The one invariant: the guest owns every buffer, on both sides

The host never fabricates a guest pointer. To hand you a request it calls obelo_alloc, writes into what you returned, calls your export, and frees. When a host function answers you, it does the same thing — it calls back into obelo_alloc from inside the host function rather than inventing an address.

That is why the glue below can refuse a pointer it did not hand out, and it is the rule to hold on to if you port this to another language.

// pinned keeps every buffer the host holds a pointer to reachable, and is how a
// host-supplied pointer becomes a slice again without pointer arithmetic: a
// pointer that did not come out of alloc simply is not in here.
//
// It is a plain map and needs no lock. One Plugin holds one guest instance and
// the host serialises every call through it, so two calls are never inside this
// module at once.
var pinned = map[uint32][]byte{}

//go:wasmexport obelo_alloc
func alloc(size uint32) uint32 {
	if size == 0 {
		size = 1
	}
	buf := make([]byte, size)
	ptr := uint32(uintptr(unsafe.Pointer(unsafe.SliceData(buf))))
	pinned[ptr] = buf
	return ptr
}

//go:wasmexport obelo_free
func free(ptr uint32) {
	delete(pinned, ptr)
}

// lastError is why the last call answered 0. The host reads it through
// last_error() and puts it on the Admin's screen, so it is worth a sentence.
var lastError []byte

//go:wasmexport last_error
func lastErrorFn() uint64 {
	if len(lastError) == 0 {
		return 0
	}
	return emit(lastError)
}

// emit copies b into a fresh guest buffer and packs its pointer and length into
// the single i64 a wasm export may return.
func emit(b []byte) uint64 {
	ptr := alloc(uint32(len(b)))
	copy(pinned[ptr], b)
	return uint64(ptr)<<32 | uint64(len(b))
}

// fail is the 0 answer: no response, and a sentence saying why.
func fail(msg string) uint64 {
	lastError = []byte(msg)
	return 0
}

// reply encodes a response document and hands it back.
func reply(v any) uint64 {
	out, err := json.Marshal(v)
	if err != nil {
		return fail(err.Error())
	}
	lastError = nil
	return emit(out)
}

0 is the single sentinel: it means "no response", from your side and from the host's alike, so you have one thing to check rather than two. When you answer 0, put a sentence in last_error() — it is what the operator will read on the Plugins screen.

The host functions, wired up

//go:wasmimport obelo http_fetch
func hostFetch(ptr, n uint32) uint64

//go:wasmimport obelo log
func hostLog(level, ptr, n uint32)

// The log levels. Anything else the host reads as info, because a bad number is
// not worth losing the operator's line over.
const (
	levelDebug uint32 = 0
	levelInfo  uint32 = 1
	levelWarn  uint32 = 2
	levelError uint32 = 3
)

// fetch asks the HOST to perform a request. The answer comes back in a buffer the
// host obtained from alloc, so it is already one of ours and freeing it is this
// guest's job.
func fetch(req fetchRequest) fetchResponse {
	in, err := json.Marshal(req)
	if err != nil {
		return fetchResponse{Error: err.Error()}
	}
	ptr := alloc(uint32(len(in)))
	copy(pinned[ptr], in)
	packed := hostFetch(ptr, uint32(len(in)))
	free(ptr)

	if packed == 0 {
		return fetchResponse{Error: "the host answered nothing"}
	}
	rptr, rlen := uint32(packed>>32), uint32(packed)
	buf, ok := pinned[rptr]
	if !ok || uint32(len(buf)) < rlen {
		return fetchResponse{Error: "the host answered with a buffer this guest does not hold"}
	}
	var resp fetchResponse
	uerr := json.Unmarshal(buf[:rlen], &resp)
	free(rptr)
	if uerr != nil {
		return fetchResponse{Error: uerr.Error()}
	}
	return resp
}

// logLine writes one line to the server log, prefixed by the host with this
// Plugin's id. It is why a guest needs no stdout.
//
// Never log a secret. The webhook URL is one, and nothing in this file puts it
// here.
func logLine(level uint32, msg string) {
	b := []byte(msg)
	ptr := alloc(uint32(len(b)))
	copy(pinned[ptr], b)
	hostLog(level, ptr, uint32(len(b)))
	free(ptr)
}

5. The host functions, and what they refuse

http_fetch — the only way out

You supply a URL; the host performs the request and hands you an answer or a refusal. You never hold a socket, a connection or a handle.

// FetchRequest / FetchResponse ($defs/FetchRequest, $defs/FetchResponse): the
// host function that is the only way out.
//
// Exactly one of three things is true of a response: Refused is set (the host
// would not make this request, and retrying it unchanged will be refused again),
// Error is set (the host tried and the network failed, which may be worth
// retrying), or neither is and Status is what the target answered. A non-2xx
// status is NOT an error — it is an answer.
type fetchHeader struct {
	Name  string `json:"name"`
	Value string `json:"value"`
}

type fetchRequest struct {
	Method  string        `json:"method,omitempty"`
	URL     string        `json:"url"`
	Headers []fetchHeader `json:"headers,omitempty"`
	Body    []byte        `json:"body,omitempty"`
}

type fetchResponse struct {
	Status  int           `json:"status,omitempty"`
	Headers []fetchHeader `json:"headers,omitempty"`
	Body    []byte        `json:"body,omitempty"`
	Refused string        `json:"refused,omitempty"`
	Error   string        `json:"error,omitempty"`
}

Exactly one of three things is true of a response. Refused is set, or Error is set, or neither is and Status is what the target answered. A non-2xx status is not an error — it is an answer, and what to do about it is your business.

Refused is the host declining. It is host-authored prose and you must not branch on the text: any non-empty refused means this server will not do this, and retrying it unchanged will be refused again. For reference, today's sentences are:

Refusal Why
host not in allowlist the target's host is not in network.hosts (and is not a target the operator typed — your own defaultUrl does not count)
not an absolute http or https URL the URL does not parse, or has no host, or is not http(s)
the target resolves to an address this server will not let a plugin reach the host you chose resolves into loopback / private / link-local space, or does not resolve at all — this check fails closed
the target could not be reached under this server's fetch policy the redirect chain was too long, changed scheme, or aimed inward — or the address actually connected to, at the dial, was loopback / private / link-local space (a name that answered public to the check and resolved inward when dialed); such a dial-time refusal is audited as fetch-policy
the response is larger than a plugin may receive over the byte cap — see below
the request could not be built the method or URL could not make an HTTP request

Every refusal is also an audit line in the server log naming you and the host you reached for:

obelo: plugin audit: refused a fetch: plugin=discord host=not-discord.example.test reason=allowlist

…with reason one of allowlist, private-address, bad-url, fetch-policy, oversize.

Four more things the host decides and you cannot:

  • It writes the User-Agent, and it drops yours. Exactly one agent leaves the server, whatever you send, and it is built from the server's own version plus your id and the version in your manifest:

    obelo/0.1.0 ( https://www.obelo.tv; metadata@obelo.tv ) plugin/tmdb/1.0.0
    

    A User-Agent header in your FetchRequest is skipped (case-insensitively) and a debug line says so, once per Plugin per process. This is not tidiness: a source with a published policy — MusicBrainz is the loud one — throttles an anonymous or malformed agent hardest, and one request carrying two agents is malformed. It also means the far end can always tell which server and which plugin reached it, which is what makes a complaint actionable. Give your manifest a real version, because that is where the last segment comes from. Everything else — an Authorization header built from your secret, a Content-Type — is yours, and the host fills in Content-Length.

  • A fetch always returns before the call's deadline. The host bounds each request by what is left of your call budget, minus a grace margin, so you get an answer with time to turn it into a response rather than being unwound mid-read. If the budget is already spent the request is not made at all — not even the DNS lookup — and you are told:

    the fetch did not finish before this call's deadline
    

    It arrives as Error, never as Refused, and the right thing to do with it is the right thing to do with any Error: answer unavailable. Then the item takes the host's backoff and no failure is counted against you — see Pace yourself and The deadline is real.

  • Redirects are bounded (3 hops) and never followed inward.

  • Responses are capped at 1 MiB, and going over is a refusal, never a truncation: a shortened document is one you would parse as complete. A manifest may raise its own cap with maxFetchBytes on a provides entry, up to the server's ceiling of 8 MiB; ask for more and it is clamped with a log line naming both numbers, rather than your Plugin being refused.

log — why you need no stdout

One line, prefixed by the host with your Plugin id and a level (0 debug, 1 info, 2 warn, 3 error; anything else reads as info). Newlines are stripped and the line is truncated at 2000 characters, so you cannot forge log structure. Never log a secret.

kv_get / kv_set / kv_delete — your own namespace

A small durable store for a cursor, an etag, a token's expiry, a tiny response cache. The namespace is your Plugin id, supplied by the host from the manifest on disk — there is no request field you could put one in, so two Plugins writing the same key never see each other's value.

Cap
Key 256 bytes
Value 64 KiB

Refusals, in the response's error (again: prose, do not branch on it): this server has no key-value store for plugins (a legitimate configuration — a server may simply have none), a key-value entry needs a key, the key is longer than a plugin may use, the value is larger than a plugin may store. An oversize value is refused, never truncated.

kv_delete removes one key. There is no "drop my namespace" — that belongs to uninstall, and a Plugin that could erase its own installation record would be a Plugin deciding it is not installed.

Deleting a key that was never written is not an error. It is the state you asked for.

settings_get — your settings, for the duration of this call

It takes no request, because there is nothing to ask for: you have exactly one settings row and it is your own.

Outside a call it answers a zero Settings — not enabled, no secret, no declared values. That is "secrets at call time only", read literally: nothing is installed into your module, and a guest rebuilt after a trap starts holding nobody's credential.

An Event sink and a Subtitle provider never need it: their settings ride in the call envelope. A Metadata provider does, because it has eight calls and eight per-call envelopes would be eight places to forget a secret.


6. How settings reach the guest

Every Plugin at every Extension point is configured through the same fixed shape: enabled, one secret, a url, an optional url2, a sink's events, and two host-resolved knobs (language, rateLimitMillis). That shape is what the settings screens render, and it is not going anywhere.

What it cannot express is a knob nobody compiled the server around — a region code, a message template, a "mention this role" switch. So a manifest declares its own fields, and the web app renders a form from the declaration.

Declaring them

"settings": {
  "requiresSecret": false,
  "defaultUrl": "https://api.example.test",
  "fields": [
    { "key": "region",  "type": "enum",    "label": "Region", "options": ["eu", "us"], "default": "eu" },
    { "key": "token",   "type": "secret",  "label": "API token", "required": true },
    { "key": "retries", "type": "integer", "label": "Retries", "min": 1, "max": 10 }
  ]
}
type JSON on the wire
string a JSON string
secret a JSON string — never returned by the API, handed to you only inside a call
url a JSON string; an absolute http(s) URL, refused at save otherwise
bool true / false, never "true"
enum a JSON string, one of options
multi-select an array of strings, every element one of options; [] is a value
integer a JSON number with no fractional part — "7" is refused, not coerced

Rules the host enforces, at load and again at save:

  • Keys are [a-z0-9-_], unique, and may not restate a fixed key (enabled, secret, url, url2, events, language, rateLimitMillis). Two controls writing one idea is how an operator configures the wrong one.
  • default is raw JSON of the field's own type. A default your own rules refuse refuses the whole Plugin at load — so test your manifest by installing it.
  • options belong to enum/multi-select only; min/max to integer only.
  • warning belongs to a bool only: a sentence the form shows beside the switch while it is on, for a switch that weakens something.
  • socket_address, socket_tls, socket_allow_plaintext and socket_trusted_ca are the host's own socket settings (below) and may not be declared.
  • required is never applied to a bool: false is an answer, and "a required switch" means "a switch that must be on", which you should express by having no switch.
  • A key your manifest does not declare is refused on save, not ignored.
  • Nothing is written unless every field passes.

Reading them

The declared values arrive in Settings.Values, keyed by the key that declared them, in the JSON shape the type names:

// Settings ($defs/Settings): the fixed shape every Plugin at every Extension
// point is configured through, plus Values — the settings THIS Plugin's own
// manifest declared, keyed by the key that declared them.
//
// Only the two halves this sink reads are declared. URL is the Target URL the
// Admin typed; see the note on it in README.md. Values is where the webhook URL,
// the templates and the mention switch arrive.
type settings struct {
	URL    string         `json:"url"`
	Values map[string]any `json:"values"`
}

// SinkDeliverRequest ($defs/SinkDeliverRequest): what the host hands a sink for
// one event. The settings travel WITH the call and are never installed into the
// guest — which is why a sink needs no settings_get, and why a guest rebuilt
// after a trap starts holding nobody's credential.
type deliverRequest struct {
	Event    sinkEvent `json:"event"`
	Settings settings  `json:"settings"`
}

// SinkDeliverResponse ($defs/SinkDeliverResponse): what a sink answers. There is
// no Outcome: delivery either happened or it did not, and everything that can go
// wrong is transport. Error is in the author's own words and reaches the server
// log and the Plugin's last-error on the Admin's screen, so it should name the
// target and the status rather than restate that something went wrong.
type deliverResponse struct {
	Delivered bool   `json:"delivered"`
	Error     string `json:"error,omitempty"`
}
// stringValue and boolValue read one declared setting out of Settings.Values.
//
// The values arrive as ordinary JSON of the type the manifest named — a `bool`
// field is true, never "true", and an `integer` is a number, never "7" — so the
// type assertion is the check. A key that is ABSENT is not the same as one set to
// its zero value, which is why these answer a bare zero rather than an error: an
// unset switch and a switch turned off mean the same thing to this Plugin, and
// nothing else here has to tell them apart.
func stringValue(values map[string]any, key string) string {
	s, _ := values[key].(string)
	return s
}

func boolValue(values map[string]any, key string) bool {
	b, _ := values[key].(bool)
	return b
}
  • Absent and zero are different. A field the Admin never filled is absent from values — unless your manifest declared a default, which is what the host stores and what you read.
  • A Plugin that declares no fields never sees the values key at all.
  • A save takes effect on your next call. The host stamps the declared values onto every call; there is no rebuild and no restart.
  • Never cache a secret across calls. It exists in a value the host is handing into one call and nowhere else.

The Discord plugin's own settings, read this way:

// The keys this Plugin's manifest declares. They are this author's own
// vocabulary — the host stores and returns them and never interprets one — and
// they must match manifest.json exactly, because a key the manifest does not
// declare is REFUSED at save rather than ignored.
const (
	keyWebhookURL  = "webhook_url"
	keyMentionRole = "mention_role"
	keyRoleID      = "role_id"

	// One template per curated event type. The key is the event type with its dot
	// turned into an underscore, so adding an event type to the contract is one
	// manifest line and one constant here.
	keyTemplateScan    = "template_scan_completed"
	keyTemplateEnrich  = "template_enrich_completed"
	keyTemplatePlayed  = "template_playback_started"
	keyTemplateStopped = "template_playback_stopped"
	keyTemplateChanged = "template_library_changed"
)

// config is the settings this delivery will use, read out of Settings.Values.
//
// It is a local value built per call and thrown away with the call. Nothing here
// is cached: a settings save reaches the very next delivery because the host
// stamps the declared values onto every call, and a Plugin that remembered them
// would be a Plugin serving the Admin's last-but-one answer.
type config struct {
	webhookURL  string
	mentionRole bool
	roleID      string
	values      map[string]any
}

func configure(s settings) config {
	return config{
		webhookURL:  strings.TrimSpace(stringValue(s.Values, keyWebhookURL)),
		mentionRole: boolValue(s.Values, keyMentionRole),
		roleID:      strings.TrimSpace(stringValue(s.Values, keyRoleID)),
		values:      s.Values,
	}
}

// mentions is the allowed_mentions block. Discord resolves EVERY @ in a message
// unless it is told not to, so the block is always present and always empty
// unless a role was configured on purpose.
func (c config) mentions() allowedMentions {
	m := allowedMentions{Parse: []string{}}
	if c.mentionRole && c.roleID != "" {
		m.Roles = []string{c.roleID}
	}
	return m
}

7. Build and install locally

From a clean clone of a plugin repository:

git clone https://github.com/goozakdev/obelo-plugin-discord
cd obelo-plugin-discord
make                       # → plugin.wasm

which is exactly:

GOOS=wasip1 GOARCH=wasm CGO_ENABLED=0 GOFLAGS= \
  go build -buildmode=c-shared -o plugin.wasm .

Three things about that command:

  • -buildmode=c-shared is not optional. It is what makes the module export _initialize rather than _start, which is what the host instantiates. Without it the first call traps inside an uninitialised runtime.
  • GOFLAGS= is cleared so a -mod=vendor or a -tags list from whatever shell you are in cannot leak into a module that imports only the standard library.
  • TinyGo is optional. It produces a module about 13× smaller and compiles it about 14× faster, and you should use it for something you ship — but Obelo's own suite builds with stock Go, and stock Go is the toolchain this guide assumes.

Do not import net/http, not even for a constant. You have no socket to open, so the package can never do anything for you — but linking it costs about 1.9 MB of module (528 KB gzipped), measured: Obelo's own TheTVDB plugin nearly shipped half a megabyte heavier for http.MethodPost, http.StatusUnauthorized and http.StatusNotFound, three integers and a string. Spell them out locally. It is the easiest half-megabyte in a wasm build to leave behind, and the first place to look when a module comes out bigger than its neighbours.

Then, one file: the Plugin package, a zip holding manifest.json, the module the manifest names (plugin.wasm unless it says otherwise) and, optionally, plugin.sig.json (§7a) — at the root of the zip and nothing else. The pack step builds it, and refuses anything the server would refuse:

go run ./cmd/pluginsign pack -manifest manifest.json -module plugin.wasm \
  [-signature plugin.sig.json] -out obelo-discord-0.1.0.zip

Two ways to install it:

Upload. Admin → Plugins → Install a plugin, and pick the zip. Or, the same request by hand:

curl -X POST http://localhost:8080/api/v1/settings/plugins \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -F package=@obelo-discord-0.1.0.zip

One part, named package. The server unpacks it in memory, writes the manifest and the module to fixed names in a directory it chose, and keeps neither the archive nor any path the archive carried.

From a URL. Publish the zip and paste its URL; it is fetched with one request.

curl -X POST http://localhost:8080/api/v1/settings/plugins/from-url \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"url":"https://example.test/obelo-discord/obelo-discord-0.1.0.zip"}'

Unlike every other outbound fetch in Obelo, the first hop is address-checked here: a URL resolving into loopback / private / link-local space is refused, because what comes back is code the server executes. Serving plugins off your own LAN? Upload the package instead.

Either way it takes effect with no restart: the module is compiled in a staging directory before anything is installed, so a refusal leaves nothing on disk and nothing in the database; then the registry is rebuilt and swapped and the Managers reload.

The package is strict

Only these are accepted, all at the root of the zip:

  • manifest.json;
  • the module the manifest names (its module field, else plugin.wasm);
  • optionally plugin.sig.json.

Anything else is refused with a sentence naming what was found and what was expected: a folder wrapping the files (zipping the folder instead of its contents does this), a __MACOSX/ entry, any directory entry, an unknown file, a symbolic link, a duplicate name, a path with .. in it, an encrypted member, or a compression method other than stored/deflate. There is no tolerance for a single wrapping folder — pluginsign pack produces the right shape, so use it.

Each member is capped on what it decompresses to, whatever its header claims: 64 MiB for the module, 256 KiB for the manifest, and the signature cap for the signature. The whole archive is capped at the sum of the three plus 1 MiB. Larger is refused, never truncated.

You can also just place the files by hand at <dataDir>/plugins/<id>/manifest.json and <dataDir>/plugins/<id>/plugin.wasm and restart, which is what the layout on disk is.

What a refused install tells you

Status Code
422 PLUGIN_INVALID_MANIFEST the manifest is not valid JSON, or makes a claim the host refuses
422 PLUGIN_API_VERSION the message names which side to upgrade
409 PLUGIN_DUPLICATE the id is already claimed — by an Installed plugin or a Built-in
422 PLUGIN_INVALID_PACKAGE the file is not a zip, or holds anything but the manifest, the module and an optional signature at its root, or a member unpacks past its cap
422 PLUGIN_INVALID_MODULE no module, or one that will not compile or instantiate
422 PLUGIN_SOURCE_REFUSED the URL was not fetchable under the rules above
422 PLUGIN_SIGNATURE the server has publisher keys pinned and yours does not satisfy them (§7a)
413 — the package part over the archive cap, or an upload body over the request limit

7a. Signing, and publishing to a catalog

Both of these are optional on your side and optional on the operator's. Obelo runs no catalog, hosts no registry and vouches for no publisher: a server browses an index because an Admin typed its address, and trusts your key because an Admin pasted it in. Nothing here makes you findable and nothing here makes you trusted. It makes you checkable, which is the part that can be built.

What is signed

A signature covers two files at once — the manifest and the module — under a domain separator:

message = "obelo-plugin-v1\n" ‖ sha256(manifest bytes) ‖ sha256(module bytes)

16 bytes of separator and two raw 32-byte digests, concatenated with no separator and no length prefix: 80 bytes, signed with ed25519.

There is no canonical form of the manifest and there does not need to be one. An install writes your manifest.json to disk byte for byte as you shipped it and never re-encodes it, so the bytes you signed and the bytes the server stores are the same bytes forever. Reformat it and the signature stops verifying, which is correct: a manifest with one byte changed is a different manifest.

That is also why the signature is detached and has to be. A signature field inside manifest.json would change the very bytes it covers.

The document

plugin.sig.json, which goes inside the Plugin package at its root, beside the manifest and the module:

{
  "publisher": "Example Publisher",
  "keyId": "9f86d081884c7d65",
  "algorithm": "ed25519",
  "manifestSha256": "…",
  "moduleSha256": "…",
  "signature": "<base64>"
}
  • publisher is the lookup, not a label. A server finds its pinned key by this name (case-insensitively) and verifies under that key alone. Change the name and you are a different publisher to every server that pinned you.
  • keyId decides nothing. It is a short fingerprint — the first eight bytes of the key's SHA-256, in hex — for a human comparing what a server has pinned against what you advertise.
  • The two digests let a reader see which artifacts a document claims to cover without holding them. A verifier recomputes both from the real bytes and refuses a mismatch before it looks at the signature, so "this covers a different module" and "this is forged" arrive as different sentences.

Making a key and signing

cmd/pluginsign in the server repository. (It is not cmd/keytool, which seals the maintainer's own provider keys for rotation — a different primitive answering a different question.)

go run ./cmd/pluginsign keygen -out publisher.key
# public key: <base64>        ← this is what an operator pins
# key id:     9f86d081884c7d65

go run ./cmd/pluginsign sign \
  -key publisher.key -publisher "Example Publisher" \
  -manifest manifest.json -module plugin.wasm -out plugin.sig.json

go run ./cmd/pluginsign verify \
  -sig plugin.sig.json -pub "<base64>" \
  -manifest manifest.json -module plugin.wasm

go run ./cmd/pluginsign pack \
  -manifest manifest.json -module plugin.wasm -signature plugin.sig.json \
  -out obelo-discord-0.1.0.zip

go run ./cmd/pluginsign verify -package obelo-discord-0.1.0.zip -pub "<base64>"

verify runs the same package the server runs (internal/plugins/signing), so a document it accepts is a document a server accepts. Re-sign whenever either file changes.

Put plugin.sig.json in the package (pack -signature) and both an upload and a URL install pick it up with no extra instruction. A package without one is simply unsigned. The signature covers the manifest and module bytes, not the archive, so where it travels adds nothing to the trust decision: that is settled by the keys an operator pinned.

What the operator's side does with it

Pinned keys on that server What happens
none (the default) Nothing is verified. Signed and unsigned plugins install identically, and the server records no publisher — it did not check, so it does not claim to know.
one or more Every install must carry a signature naming a pinned publisher and verifying under that publisher's key. Anything else is 422 PLUGIN_SIGNATURE, naming the publisher the plugin claimed.

There is no "warn only" setting. An operator who pinned a key did it to stop something.

Two consequences worth knowing as an author:

  • An already-installed plugin is never re-verified. Pinning a key is a decision about future installs; re-checking what is on disk would mean your key rotation silently stopping plugins that were running.
  • A signature file is kept even when nothing was pinned. It is provenance, so an operator who pins your key later can check what they already have. Its presence on disk is not a claim that anything was verified.

Publishing to a catalog

A catalog is a JSON index at a URL. Anyone can publish one; Obelo publishes none and recommends none.

{
  "version": 1,
  "entries": [
    {
      "id": "discord",
      "name": "Discord",
      "version": "0.1.0",
      "publisher": "Example Publisher",
      "provides": ["event-sink"],
      "packageUrl": "https://example.test/obelo-discord/obelo-discord-0.1.0.zip",
      "description": "Posts a message to a Discord channel when something finishes.",
      "docsUrl": "https://example.test/obelo-plugin-discord"
    }
  ]
}

packageUrl is the entry. Installing from a catalog is POST /settings/plugins/from-url with that URL — the same endpoint, the same safe-fetch policy, the same first-hop address check, the same refusals. What a catalog serves is exactly the Plugin package you publish (§7), so if your plugin installs from a pasted URL it installs from a catalog with no further work. Its signature, if any, is inside the zip.

Everything else in an entry — name, version, publisher, what it provides — is display. The manifest inside the package decides all of them, and only a pinned key makes publisher more than a word in a file. The types are CatalogIndex and CatalogEntry in the JSON schema (§9).


8. Reading the counters and the last error while you develop

There is no /test button and there is not going to be one: a sink's target is the operator's own receiver and a "does this work" probe would be a second code path that can disagree with the real one. What you get instead is the state the Admin sees.

Admin → Plugins (GET /api/v1/settings/plugins):

Field
enabled the Admin's switch, durable across a restart
disabledByFailure the server refusing to call you — refused at load, or stopped after repeated failures
lastError the sentence that says why
source upload, the package URL that was pasted, or placed by hand

enabled and disabledByFailure are not the same thing and are never merged: a Plugin can be switched on and stopped at once, and that is exactly the state that needs explaining.

Admin → Event Sinks (GET /api/v1/settings/event-sinks) adds the delivery tally, per sink, since boot:

Counter Reading it
delivered climbing a working receiver
failed climbing a target that refuses or cannot be reached
dropped climbing a target too slow for the bounded queue (64 deep, drop-oldest)
all three flat you are not subscribed to anything that is happening

lastError is where your own words land. A sink that answers {"delivered": false, "error": "..."} puts that string on the Admin's screen; so does a last_error() after a 0. Name the target and the status rather than restating that something went wrong.

A lastError does not mean you were stopped. disabledByFailure is the only field that says that. A Metadata provider that cleanly answers an error — the operator's key was rejected — puts its sentence here and keeps running; the next call that works retires it. See What a failure costs you.

When you have rebuilt the module: Re-enable on the Plugins screen clears the recorded failure, the consecutive-failure count and the violation count, and reloads the module from disk — so a fixed plugin.wasm gets a genuine second try without a restart. It does not touch the Admin's enabled switch.

log() writes to the server log prefixed with your id:

obelo: plugin discord [info] posted one scan.completed message to Discord

9. The JSON schema, and where it lives

The contract is pluginapi/v1/pluginapi.schema.json — JSON Schema draft 2020-12, $id urn:obelo:pluginapi:v1. It is a URN and not a fetchable URL on purpose: the schema is checked in beside the code it describes, and a plugin author reads the copy in the release they are building against.

Every request and response that crosses the ABI is a $defs entry in it. Reference one as urn:obelo:pluginapi:v1#/$defs/SinkEvent.

The prose that goes with it is pluginapi/v1/doc.go (go doc ./pluginapi/v1), and it is worth reading once.

v1 is frozen and may only change additively. A new field is fine; a removed or retyped one is v2. Every type leaves additionalProperties open, precisely so that a module which ignores a field a later server adds keeps working — which is why the Discord plugin declares only the fields it uses:

// EventEntity ($defs/EventEntity): one thing on the server — its id, the name an
// operator would recognise it by, and its kind. The id is always there; the name
// is best-effort and a sink must not identify anything by it.
type eventEntity struct {
	ID   string `json:"id"`
	Name string `json:"name,omitempty"`
	Kind string `json:"kind,omitempty"`
}

// EventActor ($defs/EventActor): WHO a playback event belongs to, and the one
// field in the contract with a rule rather than a shape.
//
// Exactly one of UserID and LinkID is set. A session relayed from a linked server
// names the LINK and never the person watching on the other side, and Name is
// then the label THIS server's Admin typed for that linked server — never a name
// from the other household. See who() below: printing a person for a Link actor
// is the one thing this Plugin must never do.
type eventActor struct {
	UserID string `json:"userId,omitempty"`
	LinkID string `json:"linkId,omitempty"`
	Name   string `json:"name,omitempty"`
}

// EventScan ($defs/EventScan): the terminal counts of a Scanner pass.
type eventScan struct {
	TitlesFound int    `json:"titlesFound"`
	FilesFound  int    `json:"filesFound"`
	Added       int    `json:"added,omitempty"`
	Removed     int    `json:"removed,omitempty"`
	Scope       string `json:"scope,omitempty"`
}

// EventEnrich ($defs/EventEnrich): the terminal counts of an Enrichment pass.
// Retrying is deliberately apart from Failed — a transient blip will be tried
// again and raising an alarm about it is noise.
type eventEnrich struct {
	Total     int `json:"total"`
	Done      int `json:"done"`
	Matched   int `json:"matched"`
	Unmatched int `json:"unmatched"`
	Failed    int `json:"failed,omitempty"`
	Disabled  int `json:"disabled,omitempty"`
	Retrying  int `json:"retrying,omitempty"`
}

// SinkEvent ($defs/SinkEvent): one curated event, whole. ID, Type and At are
// always present; everything else is omitted when the event has nothing to say
// there, so a sink reads one document shape per type rather than a union.
type sinkEvent struct {
	ID      string       `json:"id"`
	Type    string       `json:"type"`
	At      string       `json:"at"`
	Library eventEntity  `json:"library"`
	Title   eventEntity  `json:"title"`
	Actor   eventActor   `json:"actor"`
	Device  eventEntity  `json:"device"`
	Scan    *eventScan   `json:"scan"`
	Enrich  *eventEnrich `json:"enrich"`
}

Two things a reader of the schema gets wrong, both from Phase 1:

  • A registration may have no factory. Some Plugins are declarative only.
  • url2 need not come from the same registration as url. An image host and an API host are separate facts.

10. The rules you must not break

Identity is the host's

You state facts; the host decides. Say "I searched and this is what came back", not "this is the right answer". A Metadata provider that returns a name for a Title the Authoritative provider already named does not rename it; the host merges only what was left empty. A sink that is told a play started does not get to decide whether it should have.

A Supplement is asked about every entity of the kinds it declares, after the Authoritative provider, with that provider's resolved id added to the reference. So answer no-match — without a fetch — for whatever you don't serve (an artist-only source asked about a track, an id-keyed one handed no id). If you lead and your overview is a placeholder you composed from structured facts rather than prose you hold, set overviewSynthesized: a Supplement's real text will then replace it, where real text of yours would be kept.

The allowlist is not yours to widen

network.hosts is checked host-side, against the file on disk, on every fetch. You cannot restate it at call time, and you are not told why a refusal happened beyond the sentence.

A refused fetch is a violation, and violations are counted separately from call failures and never reset on their own. Three of them and the server stops calling you, with the host you reached for on the Admin's screen. (A Plugin that misbehaves every third call and answers fine in between is still misbehaving; letting a success clear the count would make it invisible.) Only an Admin pressing Re-enable clears it.

There is one asymmetry worth knowing: the operator's own configured target URL is permitted alongside your allowlist, and it is not address-checked. An author cannot know the URL an operator will type for their own receiver, and a receiver on their own LAN is the point of this product. A host you chose is checked against the address rule; the host and port they typed are not.

That covers a url or url2 the Admin actually typed, and nothing else. Your settings.defaultUrl and defaultUrl2 are hosts you chose: when the Admin leaves the field empty and the host fills in your default, that default gets your allowlist and the address rule like any other target. So list your defaults in network.hosts, and do not ship a default on loopback, a private range or a link-local address — it will be refused.

The deadline is real, and the runtime enforces it

Every call into your module runs under a budget:

Seam Budget Raise it?
Metadata provider 30 seconds callBudgetMillis on your provides entry, up to 120 s
Event sink 10 seconds, inside a 15-second delivery budget no
Subtitle provider 10 seconds callBudgetMillis on your provides entry, up to 120 s

maxFetchBytes works on a Subtitle provider's entry the same way, and matters more there: it is also the largest subtitle the host will accept back from your download. The shipped OpenSubtitles plugin asks for 30 s and 8 MiB (plugins/opensubtitles/manifest.json) — the numbers its compiled-in predecessor had. A Subtitle provider's clean error is still a strike, so answer anything that is the SOURCE's state rather than your request's — an outage, a rate limit, a spent download quota — as unavailable: the viewer sees "nothing found" and nothing is counted against you.

A guest that spins past its deadline is unwound by the runtime, not asked to stop: the module is closed and the instance discarded. Do not retry in a loop, do not busy-wait. Three consecutive failures — a trap, a deadline kill, a failure to instantiate, or a response that is not the contract's shape — and you are disabled with a sentence. See What a failure costs you for the one answer that is not on that list.

A slow source is not one of those failures, and that distinction is the whole point of the budget being a number you can see. http_fetch returns before your deadline rather than letting a slow upstream run you into it, so what you get is an Error and what you answer is unavailable — the item is retried, nothing is counted against you, and a deadline kill once again means only that your code spun.

Sleeping spends the budget. If you pace yourself (below), count the waiting: a call that makes twenty-six requests a second apart needs more than thirty seconds before a byte of latency, which is why MusicBrainz's manifest asks for ninety. Ask for less than the host's figure inside your own code, too — a guest that sleeps to its host's exact deadline is a guest that gets unwound, and that IS a strike.

Nothing about a failed delivery is ever shown to a viewer and nothing about it slows the server down. Delivery is off the publish path, always.

What a failure costs you

A call that cannot be answered comes back four different ways and they are not priced the same. The host is not reading your mind — it is reading how your module came back.

What you do What the host sees What it costs
Answer unavailable with a detail a successful call nothing. The item takes the host's backoff and is tried again
Answer 0 with a sentence in last_error() — Metadata provider a call you ran to completion and refused nothing against the Plugin. The item is parked failed, your sentence is on the Plugins screen, you keep your instance and you keep serving
Answer 0 with a sentence — Event sink / Subtitle provider a failed call a strike. Your instance is dropped and three in a row disable you
Trap, spin past the deadline, or answer something that is not the contract's shape a broken module a strike, always, for every seam

So, for a Metadata provider, the whole of the advice is three lines:

  • The source is not answering right now — it is down, it timed out, it rate-limited you, the host refused your fetch, your budget ran out (408, 429, 5xx, any Error from http_fetch): answer unavailable with a detail. The item comes back round. Never answer no-match for this — that is a claim about the catalogue you are in no position to make.
  • The source answered and the answer is no good — a rejected key (401), a forbidden request (403), a malformed one (400), a document you cannot parse: return the error. The item is parked where the operator will see it on the attention list and your sentence is on your Plugins row, which between them is how an Admin learns their key is wrong. This costs you nothing. It used to cost a strike, so three movies against a bad key took a whole provider off the server; that rule was dropped on 2026-09-18. A guest that ran and came back to explain itself is working.
  • Anything else is your code, and that is what the three strikes are for.

Two things not to read into this. A clean error does not clear a run of strikes either — it is not a success, so two traps followed by a refusal followed by a third trap still disables you. And the exemption is for the Metadata provider seam only: a sink's refusal has no item to park and no second place to be seen, so the Plugin's own status is the only record a receiver that can never be written to will ever get.

The SDK draws the line for you and you should let it: pluginsdk.Unavailable(err) answers (detail, true) for exactly the first bullet — a host refusal, a transport failure, a spent call budget and a retryable status (pluginsdk.RetryableStatus: 408, 429, 5xx) — and ("", false) for everything else. Every call path of the seven shipped plugins is the same three lines:

if detail, ok := pluginsdk.Unavailable(err); ok {
    return pluginapi.LookupResponse{Outcome: pluginapi.OutcomeUnavailable, Detail: detail}, nil
}
return pluginapi.LookupResponse{}, err

Pace yourself

The host does not throttle you. http_fetch applies no per-host rate limit (an earlier design said it would; it never did). If your source publishes a rate policy, honouring it is your code's job.

That is less work than it sounds, because of a guarantee you already have: your Plugin is one instance and the host serialises every call into it. One module, one linear memory, one queue — so a plain in-guest pacer is sufficient even when two Libraries enrich at once, and there is no second instance of you racing for the same quota. (The server's own Go providers needed a process-wide limiter keyed by host for exactly this reason; the sandbox made it unnecessary.)

Read the operator's override. The fixed Settings every Metadata provider is handed carries rateLimitMillis, and it has three meanings, which are three different instructions and must not be collapsed:

rateLimitMillis Means
absent no policy saved — use your default
0 the operator says their mirror has no policy — do not pace
n pace at n milliseconds

The SDK does this for you. pluginsdk.PacedHost(h, yourDefault) wraps Fetch and nothing else, re-reading the setting on every call through pluginsdk.IntervalFrom; pluginsdk.NewPacer / Wait / SetInterval are there if you need to pace something else. Wrapping is the whole of it — this is every executable line of the MusicBrainz plugin's main.go:

// main is never called. It exists because a Go program needs one.
func main() {}

func init() {
	host := pluginsdk.PacedHost(pluginsdk.Sandbox(), musicbrainz.DefaultInterval)
	metadata.Serve(musicbrainz.New(host))
}

What the seven plugins Obelo ships actually do, because "pace yourself" with no numbers is not advice:

Plugin Interval Why
MusicBrainz 1 s, and callBudgetMillis: 90000 its published policy
AniDB 2 s its published policy
OMDb, TheTVDB, fanart.tv, TheAudioDB 250 ms no published figure; enough that a large-library backfill does not get the server banned
TMDB none it publishes none, and adding one would have been a behaviour change

Do not pace what you did not fetch. Pacing belongs on the way out, once, which is what wrapping the host buys you: a cache hit costs nothing, and a call site cannot forget.

Memory only grows, so instances get recycled

A wasm linear memory never shrinks. The host retires your instance after 64 MiB of response bytes and rebuilds it from the compiled artifact in under two milliseconds. Keep nothing in a package variable that you cannot lose — and in particular, keep no credential there, because a rebuilt instance must start with nobody's.

Calls into one Plugin are serialised, so you need no locks and you must not assume concurrency.

Refuse a cap rather than shorten an answer

The host states the limits: 1 MiB per fetch response, maxBytes on a subtitle download, 64 KiB per kv value. When you cannot answer inside one, say so. An answer the host has to truncate is one you would have parsed as complete, and the host re-checks — an oversize answer is refused whole and counted as a violation.

Every entity you are handed carries an id; show the name, key on the id

Names are best-effort and may be missing. Ids are not.

Attribution across a Link: never print a person

A playback event that arrived over a link to another household's server carries the link's id and the label this server's Admin typed for it — never a user id, never a name from over there, and no device block at all, because a relayed session's Device is the far household's server presenting itself as one.

The host enforces that on its side. Your job is not to undo it by rendering a link as if it were a person:

// who is the attribution rule, and it is the one place in this Plugin where
// getting it wrong would be a privacy failure rather than a bug.
//
// A playback event that arrived over a Link carries the LINK's id and the label
// THIS server's Admin typed for it. The person on the other sofa is not a User
// here, their name never crossed the wire, and this Plugin must never print
// anything that reads as a person for such an event. So a Link actor is rendered
// as what it is — a linked server — and a local User as their display name.
//
// An event with neither is an unattributed session, which is what the host sends
// when it could not read the session's owner: unattributed is a smaller lie than
// misattributed, and this Plugin keeps it that way.
func who(a eventActor) string {
	switch {
	case a.LinkID != "":
		if a.Name == "" {
			return "a linked server"
		}
		return a.Name + " (a linked server)"
	case a.UserID != "":
		if a.Name == "" {
			return "someone on this server"
		}
		return a.Name
	default:
		return "someone"
	}
}

If the actor cannot be read at all, both ids are absent. That is deliberate: unattributed is a smaller lie than misattributed, and your rendering should keep it that way.

Sign nothing you did not send, and send what you signed

If you sign an outbound document, sign the exact bytes of the body you post. Re-encoding between signing and sending signs one document and sends another. (The Discord plugin signs nothing — Discord authenticates by the webhook URL itself, which is why that URL is a secret field.)


11. Writing a plugin in Go with the SDK

Everything above this section is the contract. This section is a convenience, and only for Go authors.

The plugin this guide teaches from — the Discord Event sink — imports nothing: not the server, not a PDK, not even Obelo's own contract package. That is deliberate and it stays that way, because it is the proof that the schema and six function signatures are genuinely enough. If this section and the rest of this document ever disagree, the schema is the contract and the SDK is wrong.

But Obelo ships seven Metadata providers of its own as plugins, all in Go, and writing the pinned-buffer map and the packed-i64 returns seven times would be seven chances to get them subtly different. So that glue lives in one module, pluginsdk, and you are welcome to it.

What it is

github.com/goozakdev/obelo-server/pluginsdk is a Go module inside the server's repository whose only dependency is .../pluginapi — the contract's wire types, which are themselves a module with no dependencies at all. Nothing of the server comes with either. The packages:

Package What it gives you
pluginsdk Host — the six host functions, typed, and Socket for a Sign-in provider declaring socket. Sandbox() returns the one that calls them. obelo_alloc, obelo_free and last_error are exported from here, once. Also Pacer/PacedHost, and Do/DoJSON/GetJSON with a FetchError that tells a refusal from an outage from a 404.
pluginsdk/metadata Serve(p) — the eight //go:wasmexport Metadata provider calls, in front of the contract's own pluginapi.MetadataProvider.
pluginsdk/sink, pluginsdk/subtitle The same for the other two seams: deliver, and the two subtitle exports.
pluginsdk/signin ServePassword(p) — a Sign-in provider's password flow: sign_in_password, in front of pluginapi.SignInProvider. ServeRedirect(p) — its redirect flow: sign_in_authorize_url and sign_in_exchange, in front of pluginapi.SignInRedirectProvider. And the re-check's sign_in_refresh and sign_in_lookup when the provider you served also implements pluginapi.SignInRefreshProvider or pluginapi.SignInLookupProvider.
pluginsdk/webref, pluginsdk/lyric, pluginsdk/marker Serve(p) — the one export of the Web reference, Lyric and Marker provider seams, in front of the contract's own interface. See below.
pluginsdk/sdktest An in-memory Host for NATIVE tests: a routing table of http.Handlers, a captured log, an in-memory kv and fixed settings.

Your main.go

A provider is a value implementing pluginapi.MetadataProvider — the same three methods a compiled-in source implements. You hand it over, and that is the whole of your main.go:

// main is never called. It exists because a Go program needs one.
func main() {}

// init hands the provider over. PacedHost is the whole of what a plugin owes
// about rate limits: pace yourself, with your own default, and honour the
// operator's RateLimitMillis when they set one. The interval here is short
// because this is a test guest; a real source uses its published policy (one
// second, for MusicBrainz).
func init() {
	host := pluginsdk.PacedHost(pluginsdk.Sandbox(), 5*time.Millisecond)
	metadata.Serve(testprovider.New(host))
}

init(), not main(). A -buildmode=c-shared module is a WASI reactor: the host runs _initialize, which runs package initialization, and main.main is never called at all. A plugin that served its provider from main() would answer every call with "this module serves no Metadata provider" — which is what the SDK puts in your last-error, rather than failing silently.

Pace yourself. PacedHost is the whole of what a plugin owes about rate limits: the host does not throttle you, so carry your source's published interval as your default and let the operator's settings.rateLimitMillis override it. The pacer re-reads that number on every call, because a settings save is not a rebuild.

Your provider

Every outbound request, log line, key-value read and settings read goes through the Host you were handed. There is no net/http in a plugin, and that is not a restriction so much as the point — it is what lets the same type be tested natively:

// ArtworkCandidates lists the one image this source offers for a role — a URL on
// the image host the operator configured, which the HOST downloads.
func (p *Provider) ArtworkCandidates(_ context.Context, req pluginapi.ArtworkCandidatesRequest) (pluginapi.ArtworkCandidatesResponse, error) {
	s := p.host.Settings()
	return pluginapi.ArtworkCandidatesResponse{
		Outcome: pluginapi.OutcomeMatched,
		Candidates: []pluginapi.ArtworkCandidate{{
			URL:    s.URL2 + ArtworkPath,
			Width:  600,
			Height: 900,
			Source: Source,
		}},
	}, nil
}

The optional capabilities are optional Go interfaces: implement pluginapi.EpisodeLister, pluginapi.AlbumTracklister or pluginapi.ExternalRefParser and the SDK routes those exports to you. Do not, and they answer unavailable — the same known state an undeclared capability produces. Declare in your manifest only what you implement: the host reads the declaration before it calls, so an undeclared capability costs no call at all.

Reading an id off the reference

Every id the host holds for an entity arrives in MediaRef.ExternalIDs, keyed by External-id namespace — tmdb, imdb, musicbrainz, thetvdb, anidb, or a third-party source's own, which is its plugin id. Read one with ref.ID(ns):

if id := req.Ref.ID(pluginapi.NamespaceTMDB); id != "" {
	// resolve by id; do not search
}

ID reads the map and trims whitespace; it returns "" for a namespace the reference does not carry. ExternalIDs is the only carrier of namespaced ids on MediaRef; the music coordinates (ReleaseMBID, AlbumHints[].ReleaseGroupMBID) are separate, kind-specific pins. When your record resolves an id, return it in ExternalID with Source set to your plugin id: Source names the namespace the id is in, and the host hands that id back to you under that key next time.

Testing it without a sandbox

sdktest.Host is a Host that answers a fetch from an http.Handler, in memory, with no listener and no port. A test of your provider needs no wasm toolchain, no wazero and no server:

// TestAnInMemoryHostAnswersAFetchFromAnHTTPHandler is the port's ergonomics,
// asserted: an httptest handler becomes the source without a listener, and what
// the provider asked for is readable afterwards.
func TestAnInMemoryHostAnswersAFetchFromAnHTTPHandler(t *testing.T) {
	host := sdktest.New(
		sdktest.WithSettings(sdkTestSettings("fetch")),
		sdktest.WithHandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			if r.Header.Get("X-Nothing") != "" {
				t.Errorf("unexpected header on %s", r.URL.Path)
			}
			w.Header().Set("Content-Type", "application/json")
			_, _ = w.Write([]byte(`{"overview":"from the source itself"}`))
		}),
	)
	p := testprovider.New(host)

	resp, err := p.Lookup(context.Background(), pluginapi.LookupRequest{
		Ref: pluginapi.MediaRef{Kind: "movie", Title: "Dune"},
	})
	if err != nil {
		t.Fatalf("Lookup: %v", err)
	}
	if resp.Record.Overview != "from the source itself" {
		t.Fatalf("overview = %q, want the body the handler served", resp.Record.Overview)
	}
	if paths := host.Paths(); len(paths) != 1 || paths[0] != "/v1"+testprovider.FetchPath {
		t.Errorf("the provider asked for %v, want one GET of /v1%s", paths, testprovider.FetchPath)
	}
}

sdktest.New takes options in the order they are consulted: WithFetch (a function of your own), WithHostHandler (one handler per URL host, for a source whose images come from a second host), WithHandler (everything else) and WithHTTPClient (forward to a real httptest.Server). WithSettings, WithSecret, WithURL and WithRateLimitMillis fix what Host.Settings() answers; WithAllowedHosts makes it refuse exactly as the real host does, so you can prove your provider survives a refusal. Afterwards, Requests(), Paths(), Logs() and KVKeys() say what it did.

That is the seam the bundled providers are tested through. The end-to-end proof — that the module loads, that the exports are spelled right, that the JSON survives the boundary — is a separate suite that runs the real .wasm under wazero. Both exist because they prove different things.

Filling the other Extension points

The Metadata provider is the seam this section has shown so far, and every other seam is filled the same way: a value implementing the contract's own interface, handed to that seam's dispatcher from init(). One module may fill several; the host looks up only the exports the seams in your manifest's provides need.

Seam Dispatcher Your value implements
Web reference provider webref.Serve(p) pluginapi.WebReferenceProvider
Lyric provider lyric.Serve(p) pluginapi.LyricProvider
Marker provider marker.Serve(p) pluginapi.MarkerProvider
Sign-in provider, password flow signin.ServePassword(p) pluginapi.SignInProvider, and pluginapi.SignInLookupProvider for sign-in-lookup
Sign-in provider, redirect flow signin.ServeRedirect(p) pluginapi.SignInRedirectProvider, and pluginapi.SignInRefreshProvider / pluginapi.SignInLookupProvider for the re-check

The SDK's own test guest fills four of them beside its Metadata provider:

func init() {
	webref.Serve(testprovider.References{})
	lyric.Serve(testprovider.NewLyrics(pluginsdk.Sandbox()))
	marker.Serve(testprovider.NewMarkers(pluginsdk.Sandbox()))
	signin.ServePassword(testprovider.NewDirectory(pluginsdk.Sandbox()))
}

Every one of these seams hands your settings WITH the call, so Host.Settings() answers them for that call exactly as it does for a Metadata provider.

Web reference provider

A Web reference provider turns the ids an item already holds into links — "IMDb", "Trakt" — and nothing else. The call has no network: every http_fetch made while it runs is refused and counted against you, so build the URL from the request alone. The host keeps a reference only when it is https and keyed to an id it sent you (namespace and id name it), so there is no point answering anything else. Nothing to link to is an empty list.

// References links an item's IMDb id. It needs no Host: the call is a pure
// computation over the ids in the request, and a fetch made while it runs is
// refused.
type References struct{}

// Links answers one reference when the host holds an IMDb id for the item, keyed
// to that id, and nothing otherwise.
func (References) Links(_ context.Context, req pluginapi.WebReferencesRequest) (pluginapi.WebReferencesResponse, error) {
	id, ok := req.IDs["imdb"]
	if !ok || id == "" {
		return pluginapi.WebReferencesResponse{}, nil
	}
	return pluginapi.WebReferencesResponse{References: []pluginapi.WebReference{{
		Namespace: "imdb",
		ID:        id,
		Label:     "IMDb",
		URL:       "https://www.imdb.com/title/" + url.PathEscape(id) + "/",
	}}}, nil
}

Lyric provider

A Lyric provider is asked for one track's words the first time somebody opens the lyrics view for it, by artist, title, album, duration and — when the host holds one — its MusicBrainz recording id. Settings.URL is the operator's source, reachable beside your manifest's hosts. Answer synced lines with the durationMs they were timed for, or plain text: a Synced answer timed for a recording more than a few seconds off the track's own length is kept only as Plain, and an answer naming another recordingId than the request's is dropped whole. Nothing found is an empty answer, which the host remembers; an error is a failure, which it does not.

// Lyrics asks a lyrics source at the operator's URL for one track's words.
type Lyrics struct {
	host pluginsdk.Host
}

// NewLyrics builds the provider on a Host.
func NewLyrics(h pluginsdk.Host) *Lyrics { return &Lyrics{host: h} }

// Lyrics answers the source's timed lines as Synced, stating the length they
// were timed for, else its text as Plain. A source with nothing is an empty
// answer — a miss the host remembers — and an error is a failure it does not.
func (l *Lyrics) Lyrics(ctx context.Context, req pluginapi.LyricsRequest) (pluginapi.LyricsResponse, error) {
	q := url.Values{"artist": {req.Artist}, "title": {req.Title}}
	if req.Album != "" {
		q.Set("album", req.Album)
	}
	if req.DurationMs > 0 {
		q.Set("duration_ms", strconv.FormatInt(req.DurationMs, 10))
	}
	var out struct {
		Synced     []pluginapi.LyricLine `json:"synced"`
		Plain      string                `json:"plain"`
		DurationMs int64                 `json:"durationMs"`
	}
	err := pluginsdk.GetJSON(ctx, l.host, baseOf(l.host.Settings().URL)+LyricsPath, q, &out)
	var fe *pluginsdk.FetchError
	if errors.As(err, &fe) && fe.IsNotFound() {
		return pluginapi.LyricsResponse{}, nil
	}
	if err != nil {
		return pluginapi.LyricsResponse{}, err
	}
	switch {
	case len(out.Synced) > 0:
		return pluginapi.LyricsResponse{Kind: pluginapi.LyricsSynced, Lines: out.Synced, DurationMs: out.DurationMs}, nil
	case out.Plain != "":
		return pluginapi.LyricsResponse{Kind: pluginapi.LyricsPlain, Text: out.Plain}, nil
	}
	return pluginapi.LyricsResponse{}, nil
}

Marker provider

A Marker provider is asked where a Movie's or an Episode's Intro, Recap, Credits and Preview are, the first time a File is played. Every candidate states the durationMs of the recording it was measured on, and the host drops one more than a few seconds off the File's own length — a Marker is either right where it is timed or not worth having. What survives is used only where the File's own chapters and the host's own detection say nothing. As for lyrics, nothing found is an empty answer and an error is a failure.

// Markers asks a marker database at the operator's URL where a Movie's or an
// Episode's Intro, Recap, Credits and Preview are, by the item's IMDb id.
type Markers struct {
	host pluginsdk.Host
}

// NewMarkers builds the provider on a Host.
func NewMarkers(h pluginsdk.Host) *Markers { return &Markers{host: h} }

// Markers answers every span the source measured, each stating the length of
// the recording it was measured on, which the host checks against the File.
func (m *Markers) Markers(ctx context.Context, req pluginapi.MarkersRequest) (pluginapi.MarkersResponse, error) {
	id, ok := req.IDs["imdb"]
	if !ok || id == "" {
		return pluginapi.MarkersResponse{}, nil
	}
	var out struct {
		DurationMs int64 `json:"durationMs"`
		Markers    []struct {
			Kind    string `json:"kind"`
			StartMs int64  `json:"startMs"`
			EndMs   int64  `json:"endMs"`
		} `json:"markers"`
	}
	err := pluginsdk.GetJSON(ctx, m.host, baseOf(m.host.Settings().URL)+MarkersPath, url.Values{"imdb": {id}}, &out)
	var fe *pluginsdk.FetchError
	if errors.As(err, &fe) && fe.IsNotFound() {
		return pluginapi.MarkersResponse{}, nil
	}
	if err != nil {
		return pluginapi.MarkersResponse{}, err
	}
	var resp pluginapi.MarkersResponse
	for _, found := range out.Markers {
		resp.Markers = append(resp.Markers, pluginapi.MarkerCandidate{
			Kind:       found.Kind,
			StartMs:    found.StartMs,
			EndMs:      found.EndMs,
			DurationMs: out.DurationMs,
		})
	}
	return resp, nil
}

Sign-in provider: the password flow, and lookup

A password-flow Sign-in provider (capability password-sign-in) is handed what was typed into the login form and answers accepted with an identity — a subject that never changes, a username label and the person's groups — or not. A wrong password is a rejection, not an error: answer accepted: false. An error is your directory failing, which the host also treats as a rejection and records for the Admin, never as a reason to sign anybody in. The call carries a password, so the host records nothing it says: no log line, no fetched URL, no error text. It reaches only your manifest's hosts, or — for a directory that is not HTTP — the operator's socket (above).

Declare sign-in-lookup too, and implement pluginapi.SignInLookupProvider on the same value, and the host asks lookup(subject) between sign-ins. Answer gone only when your directory says it no longer knows the person — that revokes every session they hold — and an error for anything that is your source being unreachable.

// Directory checks a username and password against an HTTP directory at the
// URL its operator typed into the `directory` setting — a host its manifest
// allows — and answers lookup(subject) between sign-ins.
type Directory struct {
	host pluginsdk.Host
}

// NewDirectory builds the provider on a Host.
func NewDirectory(h pluginsdk.Host) *Directory { return &Directory{host: h} }

// person is who the directory says someone is.
type person struct {
	ID       string   `json:"id"`
	Name     string   `json:"name"`
	Groups   []string `json:"groups"`
	Disabled bool     `json:"disabled"`
}

func (d *Directory) base() string {
	s, _ := d.host.Settings().Values["directory"].(string)
	return baseOf(s)
}

// CheckPassword answers the person the directory names for a right password. A
// wrong one is a rejection — Accepted false, no error — and an error is the
// directory failing.
func (d *Directory) CheckPassword(ctx context.Context, req pluginapi.SignInPasswordRequest) (pluginapi.SignInPasswordResponse, error) {
	body, err := json.Marshal(map[string]string{"username": req.Username, "password": req.Password})
	if err != nil {
		return pluginapi.SignInPasswordResponse{}, err
	}
	var who person
	err = pluginsdk.DoJSON(ctx, d.host, pluginapi.FetchRequest{
		Method:  "POST",
		URL:     d.base() + "/login",
		Headers: []pluginapi.FetchHeader{pluginsdk.Header("Content-Type", "application/json")},
		Body:    body,
	}, &who)
	var fe *pluginsdk.FetchError
	if errors.As(err, &fe) && (fe.Status == 401 || fe.Status == 403) {
		return pluginapi.SignInPasswordResponse{}, nil
	}
	if err != nil {
		return pluginapi.SignInPasswordResponse{}, err
	}
	if who.ID == "" || who.Disabled {
		return pluginapi.SignInPasswordResponse{}, nil
	}
	return pluginapi.SignInPasswordResponse{
		Accepted: true,
		Identity: &pluginapi.SignInIdentity{Subject: who.ID, Username: who.Name, Groups: who.Groups},
	}, nil
}

// Lookup answers whether the person the directory once named is still there, and
// their groups now. Only a directory that says it no longer knows them is gone:
// every other failure is an error, which changes nothing but when the host asks
// again.
func (d *Directory) Lookup(ctx context.Context, req pluginapi.SignInLookupRequest) (pluginapi.SignInLookupResponse, error) {
	var who person
	err := pluginsdk.GetJSON(ctx, d.host, d.base()+"/users/"+url.PathEscape(req.Subject), nil, &who)
	var fe *pluginsdk.FetchError
	if errors.As(err, &fe) && fe.IsNotFound() {
		return pluginapi.SignInLookupResponse{Status: pluginapi.SignInGone}, nil
	}
	if err != nil {
		return pluginapi.SignInLookupResponse{}, err
	}
	if who.Disabled {
		return pluginapi.SignInLookupResponse{Status: pluginapi.SignInDisabled}, nil
	}
	return pluginapi.SignInLookupResponse{
		Status:   pluginapi.SignInActive,
		Identity: &pluginapi.SignInIdentity{Subject: req.Subject, Username: who.Name, Groups: who.Groups},
	}, nil
}

Sign-in provider: the redirect flow, and refresh

A redirect-flow provider supplies the authorize URL and the code exchange; the host owns state, PKCE, the nonce and the callback (see §2). The Bundled OpenID Connect plugin is the worked example, served like this:

// main is never called. It exists because a Go program needs one.
func main() {}

func init() {
	signin.ServeRedirect(oidc.New(pluginsdk.Sandbox()))
}

and it redeems the refresh token its exchange handed back, so the host can re-check the person with nobody present. A token endpoint refusing the grant is an error, never gone:

// Refresh redeems a refresh token at the token endpoint and answers the fresh
// ID token, and the rotated refresh token when the issuer rotated it. Every
// failure is an error, which the host treats as unreachable — a grant the
// issuer refuses as invalid_grant included: an expired or revoked refresh token
// says nothing about whether the person is still there, and answering gone
// would revoke every session they hold.
func (p *Provider) Refresh(ctx context.Context, req pluginapi.SignInRefreshRequest) (pluginapi.SignInRefreshResponse, error) {
	c, err := p.config()
	if err != nil {
		return pluginapi.SignInRefreshResponse{}, err
	}
	d, err := p.discover(ctx, c.issuer)
	if err != nil {
		return pluginapi.SignInRefreshResponse{}, err
	}
	form := url.Values{}
	form.Set("grant_type", "refresh_token")
	form.Set("refresh_token", req.RefreshToken)
	form.Set("client_id", c.clientID)
	if c.clientSecret != "" {
		form.Set("client_secret", c.clientSecret)
	}
	resp, err := pluginsdk.Do(ctx, p.host, pluginapi.FetchRequest{
		Method: "POST",
		URL:    d.TokenEndpoint,
		Headers: []pluginapi.FetchHeader{
			pluginsdk.Header("Content-Type", "application/x-www-form-urlencoded"),
			pluginsdk.Header("Accept", "application/json"),
		},
		Body: []byte(form.Encode()),
	})
	if err != nil {
		var fe *pluginsdk.FetchError
		if errors.As(err, &fe) && (fe.Status == 400 || fe.Status == 401) {
			var refusal struct {
				Error string `json:"error"`
			}
			if json.Unmarshal(resp.Body, &refusal) == nil && refusal.Error == "invalid_grant" {
				return pluginapi.SignInRefreshResponse{}, errors.New("the issuer refused the refresh token (invalid_grant)")
			}
		}
		return pluginapi.SignInRefreshResponse{}, err
	}
	var tok tokenResponse
	if err := json.Unmarshal(resp.Body, &tok); err != nil {
		return pluginapi.SignInRefreshResponse{}, fmt.Errorf("the token endpoint's answer is not JSON: %w", err)
	}
	if tok.IDToken == "" {
		return pluginapi.SignInRefreshResponse{}, errors.New("the token endpoint issued no ID token on refresh")
	}
	return pluginapi.SignInRefreshResponse{
		Status:       pluginapi.SignInActive,
		IDToken:      tok.IDToken,
		RefreshToken: tok.RefreshToken,
	}, nil
}

The plugin reaches the issuer the operator typed, and — once it has read the issuer's discovery document during a call — the token and userinfo endpoints that document names, for the rest of that call and no others. The document counts only when <issuer>/.well-known/openid-configuration itself answered 200, with no redirect on the way, and names the same issuer. Each endpoint is reachable at exactly the https scheme, host and port it names, nothing else on that host; and one on another host than the issuer's gets the private-address check a manifest host gets, so a document cannot point the plugin at loopback, private or link-local space (the issuer's own host keeps the operator's exemption). So an issuer whose endpoints live on other public hosts than its own (Google's do) works without any network.hosts, and no other host does.

Building it

Exactly the command in §7, unchanged:

GOOS=wasip1 GOARCH=wasm CGO_ENABLED=0 GOFLAGS= \
  go build -buildmode=c-shared -o plugin.wasm .

The SDK changes nothing about the artifact: same ABI, same exports, same manifest, same install. A host cannot tell which of the two plugins in the server repository's own test suite was built with it.


The whole of the Discord plugin's delivery path

For reference, end to end — the export, the send, and the rendering in between.

//go:wasmexport deliver
func deliver(ptr, n uint32) uint64 {
	// The host passes a pointer it got from obelo_alloc. A pointer that is not in
	// `pinned` is one this guest never handed out, and refusing it is what makes
	// "the guest owns every buffer on both sides" checkable from in here.
	buf, ok := pinned[ptr]
	if !ok || uint32(len(buf)) < n {
		return fail("the host passed a pointer this guest did not allocate")
	}
	var req deliverRequest
	if err := json.Unmarshal(buf[:n], &req); err != nil {
		return fail("the request is not a SinkDeliverRequest: " + err.Error())
	}
	return reply(post(req))
}
// post renders the event and sends it to the Discord webhook the Admin pasted
// into this Plugin's settings.
//
// The webhook URL is a CREDENTIAL — anyone holding it can post to the channel —
// so it lives in a `secret` settings field, it is handed over only inside this
// call, and it is never written down, never logged and never cached in a package
// variable. A guest rebuilt after a trap must start with nothing.
func post(req deliverRequest) deliverResponse {
	cfg := configure(req.Settings)
	if cfg.webhookURL == "" {
		return deliverResponse{Error: "no Discord webhook URL is configured for this plugin"}
	}
	content := render(cfg, req.Event)
	if content == "" {
		// A message template an Admin cleared is an instruction not to post this
		// event type, not a failure — so the delivery succeeded and nothing was
		// sent. Reporting it as a failure would disable the Plugin for doing as it
		// was told.
		return deliverResponse{Delivered: true}
	}

	body, err := json.Marshal(discordMessage{
		Content:         content,
		AllowedMentions: cfg.mentions(),
	})
	if err != nil {
		return deliverResponse{Error: "the message could not be encoded: " + err.Error()}
	}

	resp := fetch(fetchRequest{
		Method:  "POST",
		URL:     cfg.webhookURL,
		Headers: []fetchHeader{{Name: "Content-Type", Value: "application/json"}},
		Body:    body,
	})
	switch {
	case resp.Refused != "":
		// The host would not make this request and will refuse it again, so there
		// is nothing to retry and nothing to be clever about. The refusal text is
		// the HOST's prose: report it, never branch on it.
		logLine(levelError, "the host refused the request: "+resp.Refused)
		return deliverResponse{Error: "refused by the host: " + resp.Refused}
	case resp.Error != "":
		return deliverResponse{Error: "the request to Discord failed: " + resp.Error}
	case resp.Status == 429:
		// Discord's rate limit. The host's per-call deadline is already ticking, so
		// sleeping in here would only get the instance killed; the honest answer is
		// a failure the operator can see on the sink's counters.
		return deliverResponse{Error: "Discord rate-limited this webhook (429)"}
	case resp.Status < 200 || resp.Status > 299:
		return deliverResponse{Error: "Discord answered " + itoa(resp.Status)}
	}
	logLine(levelInfo, "posted one "+req.Event.Type+" message to Discord")
	return deliverResponse{Delivered: true}
}
// render turns one event into the line that will be posted, or "" for an event
// this Admin has switched off by clearing its template.
func render(c config, ev sinkEvent) string {
	tmpl, ok := c.template(ev.Type)
	if !ok || strings.TrimSpace(tmpl) == "" {
		return ""
	}
	out := substitute(tmpl, placeholders(ev))
	if c.mentionRole && c.roleID != "" {
		out = "<@&" + c.roleID + "> " + out
	}
	return out
}

// template is the message template for one event type, or "" when the contract
// grows a type this build has no template for — in which case this Plugin says
// nothing rather than inventing a line.
func (c config) template(eventType string) (string, bool) {
	var key string
	switch eventType {
	case "scan.completed":
		key = keyTemplateScan
	case "enrich.completed":
		key = keyTemplateEnrich
	case "playback.started":
		key = keyTemplatePlayed
	case "playback.stopped":
		key = keyTemplateStopped
	case "library.changed":
		key = keyTemplateChanged
	default:
		return "", false
	}
	value, present := c.values[key]
	if !present {
		// ABSENT and empty are different instructions. A key the Admin never filled
		// is absent from Values unless the manifest declared a default, so absence
		// here means "the manifest declares no default and nobody typed one" — post
		// nothing. An empty string means the Admin cleared it — also post nothing,
		// but deliberately.
		return "", false
	}
	text, _ := value.(string)
	return text, true
}

// placeholders is what a template may substitute, and the whole list of it. Every
// value is derived from the event document and from nothing else — there is no
// other source of truth in here, and there is no way to ask for one.
func placeholders(ev sinkEvent) map[string]string {
	p := map[string]string{
		"type":    ev.Type,
		"at":      ev.At,
		"library": nameOr(ev.Library, "a library"),
		"title":   nameOr(ev.Title, "a title"),
		"kind":    firstNonEmpty(ev.Title.Kind, ev.Library.Kind),
		"who":     who(ev.Actor),
		"device":  nameOr(ev.Device, ""),
		"count":   "",
		"total":   "",
		"files":   "",
		"scope":   "",
	}
	switch {
	case ev.Scan != nil:
		p["count"] = itoa(ev.Scan.TitlesFound)
		p["total"] = itoa(ev.Scan.TitlesFound)
		p["files"] = itoa(ev.Scan.FilesFound)
		p["scope"] = ev.Scan.Scope
	case ev.Enrich != nil:
		p["count"] = itoa(ev.Enrich.Matched)
		p["total"] = itoa(ev.Enrich.Total)
		p["files"] = itoa(ev.Enrich.Done)
	}
	return p
}

Where to go next

Obelo

Home

Getting started

Reference

Going further

Plugins

Sharing with another household

Reaching it from away

When it breaks

Clone this wiki locally