Skip to content

plugin reference

Alexander Herling edited this page Oct 13, 2026 · 13 revisions

Plugin reference

This page is the manifest and contract reference for people writing an rDownloader plugin: every manifest field, capability, credential marker and plugin type, and the rules the host holds a plugin to. Start with Developing a plugin for the scaffold and the build, and come back here for the details.

The contract is the WIT package rdownloader:plugin@0.11.0, manifest version 3. Its source is crates/rd-plugin-api/wit/rdownloader.wit, and every scaffold carries a byte-identical copy in wit/rdownloader.wit. The contract at the end of this page lists every world and interface of it, generated from that file.

The manifest

manifest_version = 3
plugin_type = "resolver"                       # resolver | transfer | an extension type (below)
api_version = "0.9.0"                          # rdownloader:plugin WIT version you built against
id = "019d0000-0000-7000-8000-000000000003"   # UUIDv7, stable across versions
name = "FastShare"                             # default display name
version = "1.0.0"                              # strict SemVer
key_id = "fastshare-release-v1"                # names your signing key
public_key = "…base64 Ed25519…"                # the key's public half (see Keys and trust)

match_domains = ["fastshare.example"]          # links this plugin claims
download_domains = ["*.fastshare.example"]     # allowlist for the resolved file URL
secret_fragment_domains = []                   # hosts whose link fragment is key material
max_concurrent_downloads = 5
requires_account = true                        # false opts into account-less (free) resolving

[capabilities]                                 # every permission, in one place
cookies = true                                 # read the account's cookies for cookie_scope
captcha = true                                 # hand a challenge to the solver or the user
secrets = ["fastshare_password"]               # {{secret:…}} markers you may expand

[capabilities.net_http]
domains = ["fastshare.example", "*.fastshare.example", "api.fastshare.example"]

[metadata]
description = "Resolves FastShare links with a premium account."  # required
author = "FastShare GmbH"                      # required
homepage = "https://fastshare.example"         # optional, https
support_url = "https://fastshare.example/help" # optional, https
license = "MIT"                                # optional, SPDX
min_app_version = "0.7.0"                      # optional; refuses older cores

[provider]
slug = "fastshare"              # ^[a-z0-9_]{2,32}$; stored as accounts.provider
kind = "hoster"                 # or "multihoster"
credentials = "username_password"  # api_key | username_password | api_key_or_cookies | cookies | login_or_api_key | oauth | none
username_required = true
secret_reference = "fastshare_password"       # must appear in capabilities.secrets
secret_domains = ["api.fastshare.example"]    # hosts the credential may reach
transfer_auth = "basic"         # optional; the engine sends Basic username:secret to secret_domains on the transfer
cookie_scope = "https://fastshare.example/"   # optional, https; needs the cookies capability
host_aliases = [["fs.example", "fastshare.example"]]

# Only for credentials = "login_or_api_key": one slot per mode, replacing the singular
# secret_reference/secret_domains pair above. Each mode's credential stays pinned to its own
# hosts, and the first entry is the default an account stored before the choice falls back to.
[[provider.secrets]]
reference = "fastshare_password"
domains = ["fastshare.example"]
mode = "login"

[[provider.secrets]]
reference = "fastshare_api_key"
domains = ["api.fastshare.example"]
mode = "api_key"

[limits]                                       # each field optional; the values are the defaults
memory_bytes = 67108864                        # at most 512 MiB
fuel = 2000000000                              # at most 20 x the default
timeout_milliseconds = 15000                   # at most 30 min
max_response_bytes = 8388608                   # at most 64 MiB
wait_budget_milliseconds = 600000              # waiting only, not compute time; at most 30 min

manifest_version = 3 is the only revision the application accepts, and api_version has to be the contract version it supports — see Compatibility.

Top-level keys go above the first [table] header. secret_fragment_domains, oauth_flows and the other bare keys belong to the manifest itself; written after [capabilities.net_http], TOML makes them keys of that table instead, where they are silently ignored.

[limits] names only what you change. Every field defaults to the value shown above, so a manifest states only the limits it raises or lowers, and one without [limits] runs on all five defaults. A value above its ceiling — twenty times the default fuel (40 000 000 000), 512 MiB of memory, 30 minutes of run time or of waiting, 64 MiB per response — is refused when the plugin is packaged.

Every other required field stays yours to state. The bundled plugins keep their shared fields — manifest_version, api_version, key_id, public_key and [metadata] author, homepage, license and min_app_version — in one file of their repository, and plugin package --defaults <file> writes each field a manifest leaves out into the packaged copy, so every package still carries a complete manifest. A field the manifest states wins over the file. A plugin of your own needs no such file.

The provider row

[provider] is what makes a provider. Every installed manifest contributes a row to the provider registry, so a new slug immediately supports account creation, credential gating, link intake and the HTTP sandbox. The registry is rebuilt whenever a plugin is installed, removed or switched off; an installation with no plugins offers no providers.

The rules the application enforces on it:

  • A plugin can never take a slug another plugin claimed first. A newer version of the same plugin id replaces its own row. What protects a well-known provider is signature trust — an untrusted package does not load — together with the secret-reference rule below.
  • A plugin can never claim a secret reference another provider owns: that would widen where an existing credential may be sent.
  • Every secret reference must be listed in capabilities.secrets, and cookie_scope needs the cookies capability: the manifest must not show a grant the runtime does not enforce.
  • Every slot's domains and cookie_scope must be covered by capabilities.net_http.domains.
  • transfer_auth = "basic" needs credentials = "api_key" or "username_password" and exactly one secret slot with domains: the engine sends that one credential to those hosts and nowhere else.
  • credentials = "login_or_api_key" offers the user a choice between signing in with a username and password and pasting a ready-made API key. Such a row describes both in [[provider.secrets]], one entry per mode, instead of the singular secret_reference/secret_domains pair — the two spellings cannot be combined. Each account stores which mode it uses, and the host admits only that mode's slot: a resolver in api_key mode cannot have the key posted to a login form, and one in login mode cannot have the password sent to the API host. The resolver learns the mode from secret-available, which answers per reference, so the mode itself never crosses the sandbox boundary.
  • credentials = "oauth" declares a provider signed in through a redirect or a device code and kept alive by renewal. The person registers their own OAuth client — the project ships no client id — and everything after that is fetched by the flow: the code the callback carries, the access token, and the material that mints the next one. The flow itself belongs to an oauth plugin; a provider declaring this without one installed is offered no sign-in. See OAuth providers.
  • credentials = "none" declares a provider that takes no account: it registers for resolving and never appears in the accounts settings. Such a row must not describe an account either — no secret_reference, no secret_domains, no cookie_scope, no username_required. It exists for a resolver that spans several installations of the same hosting script, where an account at one is not an account at another.

A slot may also carry filled_by = "flow": see Two slots.

Capabilities

Each grant is one entry in [capabilities] and one WIT interface in the host, and a component may import only the interfaces its manifest names. There is no way to ask for something at run time: a component that imports an interface it was not granted is refused when the package is built and again when it is installed, and it cannot be instantiated at all.

Capability Interface What it allows
(always) rdownloader:plugin/host log, wait, secret-available, now-unix-seconds, random-bytes
(plugin type) rdownloader:plugin/sink writing into this transfer's own destination
net_http rdownloader:plugin/http HTTPS requests, confined to capabilities.net_http.domains
cookies rdownloader:plugin/cookies reading the account's cookies for one URL
captcha rdownloader:plugin/captcha handing a challenge to the configured solver or the user
secrets (no interface) expanding the listed {{secret:…}} and {{basic:…}} markers
net_stream rdownloader:plugin/net raw TCP/TLS to the declared hosts and ports (transfer backends and storage destinations)
key_derivation rdownloader:plugin/key-derivation asking the host to compute over a credential you named but never see

key_derivation is accepted only on the three plugin types whose worlds import the interface — auth, crawler and stream-transform — and only together with at least one capabilities.secrets entry.

Three interfaces are granted by type rather than by capability, because they are what the type is rather than something it asks for. A transfer backend imports sink; a post-processing step and an upload destination import source; an authentication plugin imports credentials. Neither of the first two can name a file: a source reader is given a package handle and the list of files it may read, and a read outside that list is refused even when the path would resolve inside the directory. The list holds every file of the package, folders walked, as paths relative to it with / between folders (Film/film.mkv); symbolic links are not listed. source is not read-only: rename gives one file from that list a new bare name in the folder it is in, and a name carrying a separator, or one that already exists, is refused. No manifest can ask for any of the three.

The domain allowlist. capabilities.net_http.domains is the sandbox allowlist and the only authority for it. In a domain pattern, *.suffix matches sub-domains and not the bare suffix, so list example.test as well if you need it. The list holds for every redirect hop too, and each hop is checked before it is followed: a hop outside the list — or outside the one host an invocation was narrowed to — is never requested, and the call fails with plugin.redirect_outside_domains. A redirect to a CDN therefore needs that CDN's domain in the list.

Names, not addresses. A domain is a name with at least two labels, and — beside the bare * of a crawler for a service nobody hosts centrally — * stands only as a leading *. (*.example.test); *.com, *foo.com and cdn.*.com are refused when the plugin is packaged and installed. An IP address, localhost and *.localhost are refused too: a plugin's reach is the public internet. At request time a name is resolved once and only the checked addresses are dialled, for http and net alike. This machine and the local network are reached only through an address the person entered — a storage destination, a crawled address, a notification target — and otherwise refused with plugin.http_local_target; rDownloader's own listeners (its web interface and API, Click'n'Load on port 9666) never, even then (plugin.http_own_service), and link-local addresses never.

An extension type's requests are measured against its own manifest alone. A resolver is additionally held to the provider registry's request domains.

HTTP methods. GET, POST, HEAD and PROPFIND are what every plugin may send, and the Depth header a listing needs is allowed with PROPFIND. PUT, DELETE and MKCOL are an upload destination's alone.

The file system, other processes and WASI are not capabilities: there is no interface for them, so they cannot be granted at all. How to check a component's imports before you package it is in Developing a plugin.

Credentials the plugin never sees

A plugin never holds a credential. It names one, and the host substitutes, sends or computes over it on the plugin's behalf. An account's secret is reachable only from an invocation that was started for that account.

Sending a credential you never see

A request template is where a plugin names a credential without holding one. Four markers are expanded by the host, in the query, the headers and a UTF-8 body, and the substituted value is encoded for the document it lands in — JSON-escaped for application/json, percent-encoded for application/x-www-form-urlencoded; query and header values are encoded by the HTTP layer itself.

Marker Expands to Gates
{{secret:<reference>}} the account's secret in that slot the slot has to be the account's active one and to allow this address
{{secret}} the one secret this invocation was granted for plugin types with no provider account behind them; also expands inside the request address, for a webhook whose token is part of its path
{{username}} the account's stored username credential material, so the same domain gate as the secret
{{basic:<reference>}} base64 of username:secret, for Authorization: Basic … both of the above at once

One request may name several references, and each marker gets its own. An OAuth renewal with an application the person registered has to: it sends the client secret and the refresh material side by side. Every distinct {{secret:…}}/{{basic:…}} reference is loaded on its own and passes its own gate; one that fails refuses the whole request before anything is sent, and a marker whose reference was not loaded is plugin.secret_missing, never a neighbour's value. The same reference may appear any number of times. At most four distinct references per request, above that plugin.secret_references_exceeded.

{{basic:<reference>}} exists for a provider whose whole API is HTTP Basic. A guest cannot base64-encode {{username}}:{{secret:…}} itself, because markers substitute on the way out. The marker expands to base64 of username:secret only — no header name, no scheme word — so the plugin still writes Basic itself. Both halves have to be present; a missing one refuses under plugin.secret_missing or plugin.username_missing rather than sending an empty half.

The one exception is the provider row's own: a row with username_required = false may build the pair with an empty name, because for such a provider that is the credential's shape rather than half of it — Pixeldrain takes its API key as the Basic password under an empty user name. The allowance covers the {{basic:…}} pair alone; a bare {{username}} still refuses an empty name. A user name containing a colon is refused under plugin.basic_username_invalid, as RFC 7617 requires. The bundled seedr, seedr-jobs and pixeldrain plugins use it.

Where a request came from travels to third parties, so no marker is expanded into Referer, Origin or X-Requested-With; a template that tries ends with plugin.secret_target_not_allowed.

The credential on the transfer

A resolver states download headers as values, and it never holds a credential to put in one. Two provider-row declarations let the download engine attach the account's credential to the transfer itself instead:

  • credentials = "oauth" puts the account's token on the transfer. For every other credential kind, secret_domains bounds where a plugin's request may carry the secret. For an OAuth provider it bounds one thing more: the download engine attaches Authorization: Bearer <access token> to the transfer when the resolved address is one of those exact hosts, over TLS, and never otherwise; no plugin ever holds the token. Keep secret_domains as narrow as the hosts that actually serve bytes: it decides where the application sends a person's token. A resolver may still answer with a different host — a CDN, say — and the token will not follow it there.
  • transfer_auth = "basic" does the same for HTTP Basic. A provider whose file addresses answer the account's own Basic credential and nothing else declares it on its [provider] row, and the download engine then sends Authorization: Basic base64(username:secret) on the transfer — built by the same host code as {{basic:…}}, with the same empty-name rule — to the exact hosts of that slot's secret_domains, over TLS, and never otherwise. It needs credentials = "api_key" or "username_password" and exactly one secret slot with domains; anything else is refused when the manifest is read.

In both cases the address checked is the one each request goes to. The engine probes the source, drops Authorization at any change of host on the way, fetches the chunks from where the probe ended, and decides the credential again for that address.

Computing over a credential you never see

capabilities.key_derivation = true grants rdownloader:plugin/key-derivation, whose single function is:

derive: func(secret: secret-handle, steps: list<step>) -> result<list<u8>, failure>;

It exists for providers whose sign-in takes not the password but a value derived from it — MEGA's us call wants the second half of PBKDF2-HMAC-SHA512(password, salt, 100 000), and the first half unwraps the account's master key. {{secret:…}} only sends a credential; it cannot compute with one.

You name the credential; the host computes. A secret-handle carries a reference your own manifest lists under capabilities.secrets, exactly as a {{secret:<reference>}} marker would. The steps form a chain: each step's output is the next one's input, the first step's input is the credential, and only the last step's output comes back.

step what it does
pbkdf2-hmac-sha512(pbkdf2) PBKDF2-HMAC-SHA512 over the running value, with your salt and rounds
aes-ecb-decrypt(list<u8>) AES-128-ECB over those bytes, keyed by the first sixteen bytes of the running value
take(span) narrows the running value to a window of itself

What the host refuses:

  • over a credential a person typed, a chain that does not begin with pbkdf2-hmac-sha512, or one asking for fewer than 100 000 rounds (plugin.key_derivation_needs_one_way, plugin.key_derivation_rounds_too_low). The first thing that happens to a guessable value is the one-way thing, or this interface would be an oracle a dictionary runs through;
  • over the key material a sign-in stored (below), a chain that does not begin with aes-ecb-decrypt (plugin.key_derivation_needs_key_step);
  • a chain that begins with take, whatever the credential: it would answer with the credential itself, or read a stored key a byte at a time;
  • a reference your manifest does not list, or the capability not being declared;
  • a plugin whose capabilities.net_http.domains reach beyond the domains the credential's own slot allows (plugin.key_derivation_reach_too_wide). Derived bytes are not domain-gated once they are in your memory, so you may compute over a credential exactly when everything you can reach is somewhere that credential could itself have been sent. Compared as patterns: a *.suffix reach needs that wildcard (or a wider one) in the slot too, and a slot's *.suffix entry admits sub-domains for sending, never the bare suffix;
  • more than eight steps, more than 64 bytes out of a PBKDF2 step, or more than 4 KiB into an AES step.

Which rule applies is the host's decision, from where it reads the value. A reference whose slot the person fills is read from the account's credential and opens with PBKDF2. A reference whose slot is declared filled_by = "flow" is read from beside the sign-in, and a chain over it runs over the key material the sign-in stored there — never over the token beside it, never over anything a person typed. Your guest cannot say which kind it means: the handle is a name, and nothing else.

A sign-in leaves key material by storing its session in exactly this shape:

{"token": "<what {{secret:<flow slot>}} sends>", "key": "<base64url, exactly 16 bytes>"}

The host splits it: the token goes where a marker finds it, the key where only derive does. Any other JSON object is refused (plugin.store_token_session_invalid) rather than stored as a token, because a token is sent whole and this one would carry the key. A plain string is a plain token. A slot whose sign-in stored a plain token, and every OAuth token, has no key material: a chain over it finds nothing to compute over (plugin.provider_secret_missing), never the token. A renewal that replaces the token drops the key.

MEGA is the user: the bundled mega-auth stores the session identifier and the master key, and mega and mega-crawler unwrap a node key with a single aes-ecb-decrypt step over mega_session — they get the node's key back, never the master key.

It costs fuel, at the price the same computation costs inside the sandbox: 33 383 per PBKDF2 round per hash block, 8 166 per AES block, 1 per byte a take keeps, and 2 for the call. The fuel is charged before the work; a chain that costs more than the invocation has left is refused (plugin.key_derivation_budget). Host time counts against the invocation's timeout exactly as guest time does. Declare the budget your sign-in needs in [limits] fuel; mega-auth declares 12 000 000 000 for three chains.

Ask for as little as you can. A single PBKDF2 step with length: 32 would hand you both halves and let you do the AES yourself for almost nothing — and would put the password key, the one value that is a direct function of the password, inside the sandbox. mega-auth makes three separate calls instead, so that value never crosses. The host guarantees the credential never leaves; it cannot stop a plugin from asking for key material it did not need.

Two slots: what the person typed and what the sign-in obtained

Some providers need two credentials at once: what the person typed or registered, and what the sign-in obtained from it. One stored value cannot be both, so the provider declares two slots and says which is which:

[[provider.secrets]]
reference = "example_client_secret"      # what the person registered and typed
domains = ["api.example.test"]

[[provider.secrets]]
reference = "example_access_token"       # what the sign-in obtained
domains = ["api.example.test"]
filled_by = "flow"

filled_by = "flow" stops the sign-in writing its result over the person's credential, which every later renewal or sign-in needs. The host keeps a flow-filled slot beside the flow instead, secret-available answers about the flow for it (so an account that has only registered an application does not read as signed in), and everything else — the domain gate, the {{secret:…}} marker, the sandbox — is unchanged. It is allowed on credentials = "oauth" and "username_password" providers, beside exactly one slot the person fills.

A link fragment that is key material

secret_fragment_domains is empty in almost every manifest. Intake strips the fragment off every address before a LinkGrabber candidate exists, because a share password in it would be printed in every LinkGrabber row. A provider that encrypts on the client keeps its file key in exactly that fragment.

Naming the hosts here keeps it. For an address on one of them, intake puts the fragment in the credential vault and keeps only a reference beside the row; at resolve time the host restores it onto the address before handing it to the plugin. The plugin sees the address as pasted, and no row, event, log record, diagnostic bundle, API answer or interface ever carries the key.

secret_fragment_domains = ["mega.nz", "mega.co.nz"]

Four things are worth knowing before reaching for it:

  • Write it above the first [table] header. It is a top-level key, and a bare key written after [capabilities.net_http] belongs to that table instead — where it is silently ignored.
  • The catch-all * is refused. An exact host or *.suffix for its sub-domains, nothing else: * would turn every fragment on the internet into vaulted key material.
  • Declare it once, per host, not per world. A crawler that emits child links of the same provider needs no declaration of its own — the address decides, not the plugin that produced it. The bundled mega declares the hosts and mega-crawler does not.
  • The secret is owned by exactly one row. It moves from the LinkGrabber candidate to the queue entry when the link is enqueued, and it is removed from the vault when the row that owns it is deleted. Nothing else keeps a copy.

Without the declaration the fragment is dropped.

Captchas

interface captcha knows six kinds of challenge, and two calls to hand one over. Which call depends on the shape of the answer, not on who answers it:

Kind Record Answered by Answer
recaptcha-v2, hcaptcha, turnstile widget-challenge (site key, page, invisible) a solver service, or the person in their own browser through the extension token
image image-challenge (raw bytes, MIME type, optional prompt) a solver service, or the person in the web interface typed text
click-point image-challenge — the same picture, a different question a solver service (CoordinatesTask), or the person clicking the picture in the web interface click-point { x, y }, in pixels of the image as you served it
cutcaptcha cutcaptcha-challenge (site-key — the widget's data-apikey, misery-key — the page's CUTCAPTCHA_MISERY_KEY, page) a solver service, and nobody else token
  • solve-captcha: func(captcha-challenge) -> result<captcha-solution, failure> is the token form. It answers the widget kinds, image and cutcaptcha. A click-point challenge is refused here with captcha.answer_shape before any service or person is asked, because captcha-solution { token } cannot carry the answer.
  • solve-challenge: func(captcha-challenge) -> result<captcha-answer, failure> takes every kind and returns variant captcha-answer { token(string), point(click-point) } — a point for click-point, a token for everything else. New code can use it for all six kinds.
  • The page of a widget — cutcaptcha included — must lie inside your declared domains, or the call ends with plugin.captcha_target_not_allowed. Both CutCaptcha keys are held to the site-key rule: empty or over 256 bytes is captcha.site_key_invalid. A picture above 512 KiB is captcha.image_invalid.
  • A CutCaptcha on an installation without a solver service ends at once with captcha.cutcaptcha_needs_solver (category needs-captcha): no browser rDownloader drives can read its token, so it is neither queued for a person nor offered to the extension. A service that does not know the task answers with its own error, passed on as captcha.solver_failed with the service's reason.
  • The coordinate a person clicks is translated by the interface into the image's own pixels whatever size it was rendered at; your plugin submits it as the hoster expects, usually as the click position relative to the served image.

In plugin-common, CaptchaChallenge::ClickPoint(ImageChallenge) and CaptchaChallenge::Cutcaptcha(CutcaptchaChallenge) are the two cases for the last two kinds, and PluginHost::solve_challenge returns CaptchaAnswer::{Token, Point}; solve_captcha keeps the token-only signature and the refusal above.

Plugin types

A manifest carries exactly one plugin_type. That is why a provider with several jobs ships several sibling plugins — a resolver, a crawler, a sign-in, a remote job — each with its own package, rather than one plugin that does everything.

Resolvers and transfer backends have sections of their own, [provider] and [transfer]. The ten other types share one:

plugin_type = "intake"

[extension]
slug = "my_parser"
# What the plugin claims, read per type. Empty means "offered for everything of this type".
claims = []

A manifest of an extension type must not declare [transfer], and a resolver or transfer manifest must not declare [extension]. Each section grants a different thing, and a manifest naming two of them is either confused or trying for both. Of the extension types, only a stream transform may also carry a [provider] section — see Stream transforms. A notification destination may also declare [[extension.settings]], what each of its targets can be set to — see Settings of a target.

plugin_type World What it does
resolver resolver-plugin Turns a hoster link into a downloadable URL and checks links
transfer transfer-plugin Carries the bytes of a protocol the core does not know
intake intake-plugin Turns text and URLs into LinkGrabber candidates (intake-mirrors-plugin also states every source of a file)
auth auth-plugin Runs a provider authentication flow
oauth oauth-plugin Runs an OAuth redirect or device sign-in and renews its token
crawler crawler-plugin Turns one address into the files behind it
enricher enricher-plugin Adds metadata to a link or a queued item
notifier notifier-plugin Delivers notifications to another destination
postprocess postprocess-plugin Runs one post-processing step
storage storage-plugin Uploads finished files to a destination
remote-job remote-job-plugin Runs a job that lives at the provider and outlives the call
stream-transform stream-transform-plugin Answers with an address and how the bytes behind it become a file

Every extension type reaches the network the same way a resolver does — through the host, confined to capabilities.net_http.domains, with {{secret:<reference>}} expanded on the way out and never handed back.

A guest is instantiated fresh for every call and remembers nothing between calls. Whatever a type needs to carry from one call to the next has a field the host stores and hands back.

Resolvers

A resolver exports match-url, check-account, resolve, check and hosters, and imports host plus whichever of http, cookies and captcha its manifest grants. resolve answers with exactly one file: an address, a name, a size and headers.

One resolver may serve many hosts: the bundled xfs-generic claims the stock XFileSharing installations of every domain in its match_domains and needs no account.

A link check answers online, offline, unknown — or cached. online says the file exists. cached says the provider holds it in its own cache right now and can hand it over at once, which is stronger and expires without notice. The host stores the answer with the time of the check, and the LinkGrabber shows it as a neutral chip with that time. It never replaces the state word: the link stays online, and the chip shows when the cache was measured, not a promise. Answer cached only for a file your provider said is cached; a file it knows but has not fetched is online.

Patterns from the bundled resolvers worth copying rather than rediscovering:

  • A download address that expires is never stored. When a provider hands out short-lived tickets with no stable form, the durable address is the canonical file id or link code the queue already holds. The queue re-resolves from it on every attempt and before every resume, and the size and checksum read afresh with each ticket are what say it is still the same bytes. A minted address written into a row would be dead the next time anybody looked at it.
  • A download address that does not expire needs nothing renewed, but the metadata is still re-read on every call: that is what turns a file deleted in the meantime into an honest refusal rather than a 404 in the middle of a transfer.
  • Pin a download to a version where the provider has versions, so the stable address a resume asks for again still means the bytes the partial file came from.
  • A content endpoint that names the file in a header works: the resolver states the stable endpoint as the URL and the file in a header, and the queue carries every resolved header onto the probe and the transfer.
  • Check a quota before the queue is handed anything. The bytes are fetched by the transfer engine, not by the plugin, so a spent allowance has to become a scheduled wait at resolve time or it arrives as a 429 mid-transfer. An answer the quota check cannot read is not a refusal: blocking a good file would be worse than the 429 it guards against.
  • A credential slot is only worth declaring if something can send it. If the provider's scheme cannot be expressed with the markers and the transfer declarations above, an account field would be one nothing can use.

A resolver installed for the first time runs at once; an update runs from the next start; see Superseded versions. Start one with rdownloader plugin new --type resolver; Writing a resolver plugin walks from the scaffold to a published package.

Transfer backends

plugin_type = "transfer" carries the bytes of a protocol the core does not know; the host decides where they land and whether they count.

plugin_type = "transfer"

[transfer]
slug = "fastftp"                    # message namespace, as provider.slug is for a resolver
schemes = ["fastftp", "fastftps"]   # links carrying these schemes route to this backend

[capabilities.net_stream]
hosts = ["files.example.net", "*.example.net"]
ports = [21, 990]                   # no wildcard: a backend free to reach any port on a host
                                    # it named is a port scanner with a manifest

A transfer backend exports probe and run and imports host, net, sink and http; its world links neither cookies nor captcha. It declares no [provider], which is refused for this type — it serves URL schemes, not an account.

The contract is deliberately lopsided:

  • The plugin never sees a path. sink has no handle and no file name; the store already knows which download it belongs to, so write-at writes into this transfer's part file and there is nothing to address another one with.
  • The host owns the sockets. net::connect returns an opaque number, valid only inside the invocation it was issued in. Every socket is closed when the invocation ends. Reads are paced by the transfer's bandwidth limiter before the bytes reach the plugin.
  • The host decides what "finished" means. It checks the length against the probe and performs the promotion into the package folder itself, so a backend that reports success on a short file produces a failed attempt, not a truncated file presented as complete.
  • The checkpoint is opaque. stopped and complete may carry any bytes the backend wants; the host stores them and hands them back verbatim. It also stores which version wrote them, and resumes on exactly that version — a build whose checkpoint format differs is never handed somebody else's state. An upgrade takes effect the next time a job starts.
  • Resume is validated, not assumed. Size and modification time are recorded on the first attempt and rechecked before every continuation; a mismatch keeps the partial file and refuses the transfer. A backend reporting resumable: false starts over instead.

Start one with rdownloader plugin new --type transfer; Writing a transfer backend walks from the scaffold to a published package.

Intake parsers

An intake parser is asked claims before it is shown anything, so a parser for one format is not handed every paste in the application. What it returns are proposals: they go through the same LinkGrabber review, the same domain blocklist and the same routing rules a pasted link does. A parser cannot queue anything, and it never supplies request metadata — that would be a credential path it has no claim to.

normalize may tidy an address. A rewrite that is not a URL, or that changes the host or the scheme, is discarded: a normaliser canonicalises, it does not redirect.

A parser that knows every source of a file may also implement mirror-sets and point its world at intake-mirrors-plugin. sets answers, per file, the address it proposed through parse, every other address of the same bytes with priority and location, and the whole-file and piece hashes. The host checks each field — a known scheme, no password in the address, at most 32 sources, hex of the right width, a piece list that covers the stated size exactly — keeps the set beside the candidate and hands it to the queue, where one transfer fetches chunks from several sources. An intake plugin without the interface keeps the plain intake-plugin world and is asked nothing more. The bundled Metalink parser implements it.

Start one with rdownloader plugin new --type intake, or --type intake-mirrors for a parser that states every source: Writing an intake parser and Writing an intake parser with mirrors walk from the scaffold to a published package.

Authentication providers

An authentication plugin signs an account in without a password ever being typed into rDownloader: begin starts the flow and returns what the person has to do, poll asks the provider whether they have done it.

The plugin never sees a credential and never names one. It is given an opaque credential-ref, and what it obtained goes back through credentials.store-token, which takes an account and a value — no reference. The host looks up which vault reference the account's provider owns, refuses if that provider keeps no credential of its own, writes the new value and only then drops the old one. There is no call that reads a value back, so the interface can only ever add a secret the plugin already held. A sign-in that also leaves key material stores it in the shape described under Computing over a credential you never see.

Three things confine it: credentials is linked into no other world, the account is compared with the one the invocation was started for, and the reference is the host's to choose. A verification URL must lie on a domain the manifest declares under net_http, and it is shown rather than opened, so a plugin cannot send someone to an address of its choosing.

Whatever the next poll needs — a device code, a PIN check value — goes back in user-prompt.flow-state. The host stores it verbatim, hands it to poll, and never shows it, so a sign-in survives a closed browser and a service restart.

The waiting is the host's: a plugin answers pending(n) and the host asks again in n seconds.

The five bundled sign-in plugins show the shapes the contract carries: an OAuth device flow (debridlink-auth, premiumize-auth), a PIN flow (alldebrid-auth), an e-mail address and password through a computed credential (mega-auth), and no flow at all — torbox-auth takes the API key the person pastes from their TorBox settings, begin and poll each check it with one request and answer at once, and the plugin stores nothing.

Start one with rdownloader plugin new --type auth; Writing a sign-in plugin walks from the scaffold to a published package.

OAuth providers

The sibling of the above, for a token that expires and has to be renewed. begin builds the address the person is sent to, poll exchanges the code the callback carried, and refresh mints a new access token from stored refresh material — driven by the host's renewal sweep rather than by the person, so nobody is asked twice.

Two ways in, one way on. Beside the redirect, device-begin and device-poll run a device code: the plugin asks the provider for one, the person types a short code on another screen, and the host polls until they have. There is no callback, so there is no state to echo — what survives from one call to the next is the device code, in flow-state, which the host stores verbatim and never shows. Whichever entrance somebody came through, the renewal that follows is the same refresh.

Which entrances a plugin serves is stated in its manifest:

oauth_flows = ["redirect", "device"]   # in the order this plugin prefers them

The field is top-level — written above the first [table] header, or TOML makes it a key of that table — and refused on any other plugin type. Leaving it out means ["redirect"]. The host calls only what is named there, so a provider that offers one way in needs no stub for the other; being asked anyway is a refusal with a stable code. The first entry is what the accounts form offers.

A device flow's verification_url is held to the same rule as an authorization URL: it must be on a domain the manifest's net_http declares, even when the plugin never requests anything from it.

The redirect comes back to one fixed address, /api/v1/oauth/callback, which is what the person registers with the provider. What ties an arriving callback to an account is the state the plugin generated and the provider echoed back; a callback quoting a value no flow claims belongs to nobody.

Everything an exchange produces goes to the vault through credentials.store-oauth-token as three values: access token, refresh material and expiry. Both tokens are encrypted; only the expiry is kept in the clear. A later request reaches the stored value only as the template {{secret:<reference>}}, which the host expands on the way out. The renewal sweep selects flows carrying both refresh material and an expiry.

The rules the flow turns on

  • "Not yet" is waiting. authorization_pending and slow_down come back as pending(n), and only a refusal the provider actually made becomes failed. Poll at the interval the provider named when the flow started; a slow_down adds five seconds to it (RFC 8628). An error page instead of an answer is an outage, not a refusal: keep waiting.
  • A refusal and an outage are not the same answer. A provider that refuses ends the flow: token-outcome.failed records a failure the person is shown, and the renewal stops asking. A provider that could not be reached must not — return the failure http-request gave you and the host keeps the stored token and tries again later. A rate limit is the third answer, pending(n), carrying the provider's own Retry-After.
  • Nothing a provider wrote is repeated verbatim, because an error_description may echo part of a token. Keep the RFC 6749 error code — lowercase letters and underscores, at most forty of them — and drop anything else whole rather than filtering it, because filtering keeps the digits of a leaked token.

PKCE, and where its entropy comes from

The scaffold runs authorization code with PKCE and computes the S256 challenge itself: a guest has no WASI, so SHA-256 and base64url are written out in src/pkce.rs. The randomness underneath comes from host.random-bytes, which hands out bytes from the operating system's own generator — 32 of them per value, base64url without padding, which is the 43 unreserved characters RFC 7636 asks for at minimum. The verifier and the state are drawn separately, so neither is computable from the other.

Nothing else is an acceptable source: a value derived from the clock or the account id can be recomputed by anybody holding those inputs. host.random-bytes refuses a request for nothing or for more than 1024 bytes by answering with an empty list, and a guest must fail the flow on a short answer rather than fall back to something it computed itself.

The client id and the registered application

Never put a client id in your plugin. Write the marker {{client_id}} where the client id belongs, and the host substitutes what this installation registered — in the outbound request, and in the authorization URL your begin returns, which is the one place the host expands anything into a string it did not send itself. Providers count their quotas per client, a client id in a public repository cannot be revoked, and whether the token endpoint wants a client_secret depends on each installation's registration.

The account's username field is where it lives, so declare username_required = true on your provider row and give username_label and username_hint in all four catalogues; the hint is where the registration steps belong. It is stored in the clear: a client id identifies the application, not the person. {{client_id}} is therefore not a secret marker — it does not make a request carry credentials and it does not narrow a redirect — and its gates are only that the account's provider is credentials = "oauth" and that the address is one your manifest allows. An account with no client id refuses under oauth.client_not_configured before anybody is sent anywhere.

When the provider needs a client secret too, the person's registration takes the client id as the account's username and the client secret as its credential, and the plugin only names them: {{username}} and {{secret:<reference>}}. The token the sign-in obtains then goes into a second slot declared filled_by = "flow" (see Two slots), so it never overwrites the registration. A plugin should refuse before its first request when the registration is missing: host.secret-available answers whether a credential exists without revealing it, and a refusal that says where to register beats the provider's own invalid_client.

A renewal that needs more than one stored value uses that registered application and the two slots. store-oauth-token keeps one value that the host later expands as one marker, and a composite cannot be split back apart, because the host percent-encodes what it substitutes into a form body.

Where the renewal material comes from. refresh is handed a credential-ref and writes it as {{secret:<reference>}} like any other credential — but that reference is one the host minted when it stored the token, not one of the provider's declared secret slots. The host recognises it as this account's own renewal reference and expands it; a reference that is not exactly the one stored on this account's flow row resolves to nothing, and the address is still held to the hosts the provider's own credential may reach.

Provider details worth copying

  • Google: two parameters in the authorization URL — access_type=offline and prompt=consent. Without the first Google issues no refresh material at all; without the second it issues it exactly once, so an account signed in a second time comes back with nothing to renew from.
  • Microsoft: authorization_pending and authorization_declined are its device-flow spellings of "not yet" and "the person said no"; the first is a wait and must never end the flow, the second ends it under the same code as access_denied. Its error_description is never read: it is a sentence with a code, a timestamp and trace ids in it, and no shape check makes a sentence safe. Repeat the scope on the refresh request, because Microsoft's token endpoint answers a narrower token when it is left out.
  • Dropbox: token_access_type=offline, or Dropbox issues a four-hour access token and no refresh material. Its scopes are named in the App Console rather than granted on request, so the setup instructions have to say which boxes to tick.
  • A provider with no PKCE and no refresh token (pCloud): a confidential client with two credential slots, a begin that returns flow_state: None rather than bookkeeping that proves nothing, and a refresh that honestly refuses. The refusal is safe because poll stores neither refresh material nor an expiry, so the renewal sweep never asks.

Start one with rdownloader plugin new --type oauth; Writing an OAuth sign-in plugin walks from the scaffold to a published package.

Folder crawlers

A crawler answers the question a resolver cannot: what lies behind this address? resolve returns exactly one file, so a cloud folder, a directory share or a release page has no shape to come back in. crawl returns a list, and everything in that list then goes through the same review, the same domain blocklist and the same routing rules a pasted link does. A crawler's world imports host and http, and cookies and captcha when its manifest grants them.

claims-url is asked first and reaches nothing: a plugin that has no business with an address never fetches it. Claim narrowly — a crawler claiming every http(s) address, as the multihoster resolvers do, would try to list a folder behind every link in the queue.

[extension] claims names the provider slug whose account the crawl runs as. The host looks the account up and the plugin never learns which one it is; its requests carry {{secret:<reference>}}, expanded on the way out and only towards the hosts that reference is allowed to reach. A crawler for a public share names no provider and runs without an account.

Four rules a crawler has to keep, because a folder is a stranger's data structure:

  • Bound the walk yourself. Maximum depth, maximum number of files, maximum number of folders — and a set of the folder ids already visited, or a folder that contains itself is walked for ever. The fuel and timeout budget is the last resort: a crawler leaning on it reports a timeout where it should report "this folder is larger than I will list".
  • An empty answer is a failure. A folder that is empty, gone or unreachable returns a failure with a stable code from your own catalogue. An empty list would create a package with nothing in it and nothing to explain why.
  • Names come from strangers. A file name or a folder name may not carry a path separator out of where it belongs; the host strips them again, and so should you.
  • Never hand back your own input. The host drops it.

package-hint is where a file sat, relative to the crawled address — Show/Season 1. Links sharing a hint become one package, so the structure a crawler walked survives into the LinkGrabber; an explicit package name still overrides all of them.

The host keeps at most a thousand links of an answer, and trims the rest without saying so. Make your own limit the host's: refuse at exactly that number with a code of your own, so a folder comes back whole or as a refusal, never as a package quietly missing its tail.

Paging

  • Cap the pages. A folder API that pages hands back a continuation for as long as anybody asks. Follow at most ten pages per folder and record that the walk stopped, exactly as it records the other limits.
  • Keep the cursor in the walk's pending entry, not in a local: a folder with more pages goes back to the front of the queue carrying the cursor its last page ended on, and a page does not count as a second folder. Any page can then be resumed from its entry.
  • Follow a next-page address only while it still points at the API, and refuse one anywhere else before it is fetched.
  • An API that does not page needs no page cap; bound the walk by its other limits.

An address that could be a file or a folder

Where the provider's address does not say whether it is a file or a folder, the crawler claims it, asks the provider's API what it is, and answers with one file when that is what it finds. The resolver sibling then only ever sees the canonical address behind it.

A crawl that fails with the kind unsupported means "not mine after all": the selection carries on to the next crawler, and an address every crawler disclaims stays exactly what it was. Every other refusal ends the search, because "this folder is empty" is an answer.

A crawler that hands an address to its resolver sibling can pass only the address: nothing else travels between two sibling packages. Anything the resolver needs — a share password, say — has to ride in that address.

A provider with more than one installation or region: the address names one, only the refusals that can mean "wrong installation" are retried, once, at the other, and the one that answered is pinned and written into every address handed on. Where the provider reports a refusal as a number, carry the number as a parameter and never its English sentence: a number needs no sanitising.

A crawler for a service nobody hosts centrally

A crawler for self-hosted software (a Nextcloud) or for any web server's directory listing cannot name a domain in its manifest and cannot recognise its own addresses by host. Two things in the host exist for that shape:

  • * in capabilities.net_http.domains, narrowed per crawl. The manifest's * means "wherever the address points". For the duration of one crawl the host builds the store with exactly one reachable host — the host of the address that was pasted — so the plugin reaches that server and no other, and a redirect off it is refused with plugin.redirect_outside_domains. A crawler that names its domains is not narrowed.
  • generic = true. A crawler that claims by the shape of an address rather than by a service declares it in its [extension] section and is asked after every crawler that names a service; within each group the order is by name. The flag is refused on any other plugin type.

A protected share

A share password reaches the crawler as the fragment of the pasted address — appended after a #. A fragment is never sent to a server, and a crawler is handed the pasted address with its fragment. A share password is not an account: it authenticates one share and means nothing anywhere else, so the host keeps it as an auth profile scoped to a host and a path prefix.

crawled-link carries an address and nothing else, so a crawler that needs a login puts the user name in the address's userinfo — https://anonymous@cloud.example.org/public.php/dav/files/<token>/file.bin — and never a password. The host lifts the user name out, drops any link whose userinfo carries a password, and keeps the bare address. It then derives one credential for the whole answer: the same user name on the same host for every file, scoped to the longest path prefix they all share. A disagreement, a second host, or a prefix that has shrunk to / ends in no credential at all, so a crawler can never have a credential minted for a whole host.

The password from the fragment goes into the vault behind a Basic auth profile with that scope, and both the online check and the transfer send the same Authorization: Basic the crawl used. When no crawler claims the address at all, the fragment is dropped instead of stored.

The bundled premiumize-crawler is the reference implementation, and pixeldrain-crawler the smallest. Start one with rdownloader plugin new --type crawler; Writing a folder crawler walks from the scaffold to a published package.

Remote jobs

A remote job is work that happens at somebody else's provider and outlives the call that started it. A magnet handed to a debrid account answers with an identifier, not a file: it runs for minutes or hours, may stop half-way until a person has said which files they want, and the account keeps it afterwards.

Every function a remote-job plugin exports is short and none of them waits. Each returns on the provider's next answer. What has to last — the row, the remote identifier, the clock, the person's answer, the restart — belongs to the host.

A remote-job manifest has an [extension] section, no [provider] and no [transfer], and at least one capabilities.net_http domain. [extension] claims names the provider slug whose account the job runs on, exactly as a crawler's does; the plugin never learns which account it is, and its requests carry {{secret:<reference>}}.

Nine functions:

Function What it does Reaches the provider
cache-kinds Which kinds of source the provider's cache can be asked about no
check-cached Whether the provider holds each source ready right now yes, read-only
claims Whether this plugin takes the source at all no
identify The content key of a source, derived locally no
submit Hands the source over; answers with the job yes
adopt The job the provider already holds for a content key yes
poll Where the job stands now yes
choose Answers the question awaiting-choice asked yes
discard Removes the job at the provider yes

Four rules a remote-job plugin has to keep:

  • identify never makes a request, and never invents a key. It is what stops a duplicate: the host writes the key into the row before it calls submit, and a unique index on (account, key) then makes a second submit of the same content impossible before any network call happens. A key that changed between two calls, or that needed a request to compute, would defeat the guard it exists for. For BitTorrent it is the info hash, and a magnet and the matching .torrent file must answer with the same one.
  • submit does not retry. At these providers calling it twice creates two jobs and charges for both. Everything protecting that is on the host's side — the key, the unique row, the two-attempt ceiling and adopt between the attempts.
  • The host owns the clock. preparing may carry a suggested wait in seconds; the host clamps it into its own bounds and decides when the next poll happens. A job in awaiting-choice is not polled at all: nothing changes until a person answers.
  • discard is called from one place and never as a side effect. Removing the local package, a failed job or a cleanup sweep never reaches it. What rDownloader did not put in somebody's account on its own it does not take out on its own.

The cache question

cache-kinds and check-cached reach no job: they are the LinkGrabber's question, asked during the online check before anybody hands a source over. The host decides what a link is — torrent (a magnet, or the magnet of a stored .torrent's info hash), usenet (an indexer's NZB address) or hoster (a file address that an installed hoster plugin resolves) — because an address alone cannot say it; your plugin derives its own key from the source, exactly as identify does. The rules:

  • cache-kinds is local and asked once per load. An empty list means the host never calls check-cached; a kind named twice counts once.
  • One answer per query, in the order given. The host pairs answers by position and refuses a list of any other length as a whole (remote_job.cache_answer_misaligned) — a shifted list would put one link's cache on another. At most 100 queries arrive per call, only of the kinds you named, and a source carrying a credential marker never reaches you: the host answers unknown in its place.
  • Read-only at the provider. It creates nothing — no job, no transfer, no folder. A provider whose only way to find out is to submit answers unknown.
  • cached, known, unknown — and never offline. cached is held ready now, known is known but not held (the host stores it as online), unknown is everything else, including "not in my cache": a cache that does not hold a file has not shown the file is gone. A failure drops the whole call's answers and changes no link.

What the host does with the answers: it asks one enabled account per provider slug, at most 500 queries per provider and check run, before the links are checked, and merges each answer into that link's result when it is written. A cache answer only raises — online/unknown to cached, unknown to online on known — and never lowers a result, changes the link's provider or its route. The LinkGrabber names the provider that answered next to the time of the check. The remote-job template answers "no kinds" and unknown, which is right for every provider without a cache query.

What the host does with the other calls

  • A person's source goes through claims and identify first, both local, and the answer is written into a row under UNIQUE(account, key) before anything else happens. A second submit of the same content on the same account is refused at that row, with no request made.
  • A sweep reads every due row every five seconds and does exactly one thing per row: submit for a fresh row, with the attempt counted before the call goes out; adopt for a row that was submitted and never answered, which is what a restart mid-call looks like; one more submit if adoption found nothing; and after two attempts the row fails under remote_job.submit_unconfirmed rather than trying a third time. A row with an identifier is only ever polled.
  • The wait you suggest on preparing, and the Retry-After a rate limit carries, are clamped into five seconds to fifteen minutes. A refusal whose category is transient, rate-limited or IP-blocked keeps the row and waits; every other category ends it under your code. A guest that traps or runs out of fuel ends the row under plugin.remote_job_failed.
  • awaiting-choice parks the row: it is not polled again until choose was called with at least one of the entries you offered. An empty entry list is refused as plugin.remote_job_empty, because a question with no options cannot be answered.
  • Answer awaiting-choice only if your provider keeps the answer. choose returns nothing, and the host never hands the recorded choice back to the guest, so the poll after choose has to read the answer back from the provider. A provider that cannot select server-side never asks, and refuses choose under its own code. The host enforces this: if a job asks again after its question was answered, it ends under remote_job.choice_not_kept and is never put back in awaiting_choice.
  • ready becomes one LinkGrabber batch: http(s) addresses only, at most 500, names and package-hints reduced to paths that cannot leave the package, labelled with your plugin's name and checked online like a pasted list. The row is closed with the package it produced and never polled again. A ready with nothing usable in it is plugin.remote_job_empty.
  • discard is called from nothing in the sweep. Its one caller is POST /api/v1/remote-jobs/{id}/discard with confirmed: true in the body. A request without that value is refused under remote_job.not_confirmed and reaches no plugin. Afterwards the row stays, in discarded, still naming the job you created. Removing the row from the person's list is a different endpoint (DELETE /api/v1/remote-jobs/{id}) that calls nothing at all.

What a finished job returns is addresses, not bytes. At a debrid provider the links a finished torrent carries are still restricted ones, so they go to the resolver that already exists and then down the ordinary resumable queue. An empty ready is a failure, for the same reason an empty crawl is.

Decisions from the bundled remote-job plugins

  • Map the provider's states conservatively. A queued job is preparing, not "downloading at zero percent"; compressing and uploading after the download are preparing too, because the links do not exist yet; and a state the plugin does not recognise waits rather than failing.
  • Let the content key carry the kind when a provider runs several kinds of job (torrent:<sha1>, usenet:<md5>, web:<md5>), so poll and adopt know which endpoints to use from the key alone and two kinds cannot collide in the account's one key space. Use the provider's own digests where it has them: a key the plugin invented would match nothing at the provider, and adoption could not work.
  • The kind can also travel on the handle, in job-state: the host stores what it is handed and gives it back.
  • A provider without server-side selection never answers awaiting-choice. It hands every file of the finished job over as one package, and the person picks in the LinkGrabber the finished addresses land in. Answering ok to choose would report a selection nothing acted on. Expressing a selection by deleting the unwanted files at the provider is an implicit remote deletion, and not allowed.
  • Hand back stable addresses, not signed ones. A signed storage address expires while the job waits in the queue. The finished address names the file; the resolver sibling adds the credential and mints the short-lived ticket on every attempt.
  • A container can become a magnet when the provider only takes an address: read it locally and re-express it as a magnet with the same info hash, name and trackers. The content key is unchanged, which keeps a magnet and the matching file one job.
  • Answer adopt with none when the provider's job list carries nothing derived from what was handed over. Matching on a name would eventually poll somebody else's job as this one; the duplicate guard is then entirely the host's row.
  • A finished job that turns into something else (Seedr turns a finished transfer into a folder) is polled where both states are visible. discard removes the job, never the files it produced, and a 404 there is success.

rdownloader plugin new --type remote-job scaffolds one; Writing a remote job plugin walks from the scaffold to a published package. The bundled realdebrid-torrents is the reference implementation.

Stream transforms

A few providers encrypt every file on the client: the key rides in a link fragment and what a plain GET returns is ciphertext. A resolver has no field the key could travel in, and putting it in a header would put it in every log line.

In this world the bytes never leave the host. A plugin answers with the address and a declarative description of the transform: a named primitive with its parameters. The host applies the primitive on its own write path — no second pass, no second copy on disk — and checks the provider's integrity value before the part file is promoted.

Four properties are what an author has to write against:

  • The plugin describes, the host computes. What crosses the boundary is parameters for primitives the application implements, never code and never a callback per buffer. A primitive the host does not know is a refusal when the description is taken in — with the stable code transform.cipher_unknown or transform.integrity_unknown — and never a fallback.
  • Key material is a secret from the moment it arrives. The host puts the bytes in the vault and keeps a reference. Nothing that is written down, logged, broadcast or shown carries the key.
  • The integrity value is checked, and a failure keeps the partial file. A wrong key otherwise produces a file of exactly the right length under exactly the right name. A mismatch is transform.integrity_mismatch, a failed attempt, and the partial file stays.
  • Resume validates the transform, not only the bytes. The checkpoint carries the finished chunk MACs and a fingerprint of the description that wrote them. A continuation whose description differs starts over rather than resuming somebody else's state. The same key gets the same vault reference back, so a continuation still recognises the chunk MACs it wrote itself, while a different key starts the download over.

Two functions:

Function What it does Reaches the provider
claims-url Whether this plugin takes the address at all no
resolve The address, and how its bytes become a file yes

The primitives the host implements:

Field Name What the host does
cipher.algorithm aes-128-ctr AES-128 in counter mode, counter block nonce (8 bytes) followed by a big-endian u64 block index starting at first-block. Every 16-byte boundary decrypts on its own, which is what keeps ranges, resume and parallel chunks working.
integrity.algorithm cbc-mac-chain One CBC-MAC per chunk over the plaintext, zero-padded to 16 bytes, keyed with the cipher's key and starting from iv; then a second CBC-MAC over those in order under the same key and a zero IV; then the 16 bytes folded to 8 as m[0..4] xor m[4..8] followed by m[8..12] xor m[12..16].

integrity.boundaries are absolute plaintext offsets, strictly ascending, the last one the file's size. They are also the only offsets at which the host may still split the download across parallel connections: the MAC is sequential within a chunk and independent between chunks. A run whose connection boundaries do not line up with them falls back to a single connection rather than condensing a value it cannot compute — correct and slower, never fast and wrong. The host bounds the list at 200 000 entries.

The provider's address is not its identity. A provider may answer a fresh storage address on every call, so nothing that has to survive a restart may be derived from it; what identifies the file to the host is the description — nonce, key, integrity value and chunk boundaries.

A stream-transform plugin is a resolver in everything but the world it exports, so it may carry a [provider] section beside its [extension] section, and the bundled mega does. A manifest's message namespace is its provider slug when it has one, so the provider row belongs on the plugin whose codes already live in that namespace.

Start one with rdownloader plugin new --type stream-transform; Writing a stream transform walks from the scaffold to a published package. The bundled mega is the reference plugin of this world, with mega-crawler beside it for folders.

Metadata enrichers

An enricher adds fields to a link that has just resolved. It adds: a field whose name collides with something the core resolved — file_name, size, provider, media and the rest — is dropped and counted. What it contributes is stored beside the core fields, with the plugin that said so and when, and shown that way — on the candidate in the LinkGrabber, and on the package and the queue row it becomes.

Enrichment is global opt-in, off by default, and the switch is checked before the plugins are even compiled. [extension] claims narrows it further: a plugin that claims youtube.com is not told about anything else.

A plugin may also claim nothing — for an indexer hit the URL points at the indexer and the content is in the file name. Such a plugin is asked about every link and has to decide for itself, so its negative answer must be cheap. A source that is silent, slow or talking nonsense should leave the row exactly as the online check left it.

An enricher is instantiated without an account, so the host expands no {{secret:…}} on this path: a service that wants an API key cannot be reached from an enricher.

What known carries

enrich-subject.known is "what the core already knows about this subject, as JSON":

  • The resolved media metadata sits at the top level of the object.
  • An indexer key holds a flat string-to-string map of what the indexer declared about the subscription hit this link came from — imdb, imdbscore, imdbplot, coverurl, resolution, size and whatever else that feed emitted. The key is absent for every link no subscription produced: a pasted address, a captured browser download, a container import.
{
  "title": "Some Release",
  "duration_seconds": 5400,
  "indexer": {
    "imdb": "tt0111161",
    "imdbscore": "9.3",
    "coverurl": "https://indexer.example/cover.jpg",
    "password": "1"
  }
}

Ignore keys you do not know: new ones may be added without a contract change.

What reaches a plugin is filtered, and the filter is applied on the way out, not only on the way into the database:

  • Attributes whose name is a credential (apikey, api_key, auth, passkey, rsstoken, secret, token) are dropped whole.
  • A credential inside a value — the passkey Torznab indexers like to put in magneturl or nfo — is redacted, keeping the structure and losing the secret.
  • coverurl and backdropcoverurl survive only as absolute http/https addresses; a data: or relative address is dropped.
  • Newznab's password is a flag (1 or 2), never a password. An indexer that wrote a real password there has it taken away from the map and kept for the extractor alone; the plugin sees "1".
  • At most 40 attributes, 1024 bytes per value, 8192 bytes in total.

Start one with rdownloader plugin new --type enricher; Writing a metadata enricher walks from the scaffold to a published package.

Notification destinations

A destination delivers one message and reports how it went. It does not decide whether to try again: retries, backoff and quiet hours stay with the notification hub, which owns them for the built-in webhook, SMTP and Apprise targets too. The category of the failure it reports is what the hub goes by: a retryable one (transient, rate-limited, offline) walks the backoff, anything else — permanent, auth-required — ends the delivery as failed on the first attempt.

A person creates a target of kind plugin and names the destination; endpoint is where it sends — a topic, a chat id — and the token goes into the vault. The plugin receives the destination and a has-secret flag, never the token: it writes the reference-less marker {{secret}} into a header, a query value, the body or even the address, and the host substitutes the one secret this delivery was granted on the way out. When the marker is in the address, the host expands it after the domain gate and checks afterwards that the host has not changed. A marker written without a grant is refused rather than sent empty.

A destination on a server of its own. A notifier may add * to its net_http domains, next to the services it names — ntfy declares ["ntfy.sh", "*"]. The * never reaches the sandbox: a destination written as an http(s):// address reaches that address's host and nothing else, and anything else — a bare topic — reaches the named services. A redirect off that host is refused before it is followed. https is required; http:// is accepted only for an address inside the person's own network — the private IPv4 ranges, loopback, link-local, IPv6 unique-local, localhost, *.lan, *.local and a single-label name such as ntfy — and anything else is refused as plugin.destination_not_encrypted before a request is made, and already when the target is saved. A manifest without * keeps its list for every destination, whatever the destination says.

Start one with rdownloader plugin new --type notifier; Writing a notification destination walks from the scaffold to a published package.

Post-processing steps

A step runs after cleanup and before a user script. It is switched on globally or per category — installing one changes nothing until somebody enables it — and the list is ordered. A category's empty list is not "inherit" but "none here", which is how a category switches a globally enabled step off.

A step is given a package handle and the list of files it may read — every file of the package, folders walked, as relative paths (Film/film.mkv); it cannot name paths at all. It may rename a file from that list — to a bare name in the folder it is in, never over an existing one. Stopping is normal: return stopped with a checkpoint and the host stores it, so a service restart resumes the step. Return skipped for "nothing to do here"; a failure would fail every package in a category the step was switched on for.

removed names the files the package had that post-processing removed before the plugin steps ran — archive volumes once they are unpacked, PAR2 files once the repair is through, what the cleanup deleted — the same way files names what is there. None of them can be read; a list of files your step reads that names one is not wrong for it. complete carries a step-complete: the final checkpoint and warnings, at most sixteen translatable parts (a <slug>.* code from your catalogue, its parameters, an English fallback) for what the person should know although the step passed. The step is shown as completed with the warning on it: a single warning translated, several as their English texts in one line.

Three steps are bundled: sha256-postprocess and md5-postprocess each verify their own sidecar format, .sha256 and .md5, and rename-postprocess tidies file names. The two checksum steps are two plugins so each can be updated and switched off on its own; with both enabled a package is read twice.

Start one with rdownloader plugin new --type postprocess; Writing a post-processing step walks from the scaffold to a published package.

Upload destinations

Set the post-processing upload target to plugin:<plugin-id>/<destination> and the package goes there instead of through rclone. Everything after the first slash is the plugin's own vocabulary — for WebDAV, the address of the collection — and the core does not interpret it.

Commit before delete. put uploads a file and returns the destination's own identifier for it; verify is a separate call that asks whether the destination really holds it, and only a true allows the local copy to be removed under upload_mode = "move".

A file in a folder of the package is offered under its relative path (Film/film.mkv), and the destination keeps that path: the WebDAV one creates each folder as a collection before the PUT. Under move, a folder goes once every file in it is confirmed and removed, and only when nothing else is left in it.

Two grants are narrower than they look. A storage manifest declares domains = ["*"], but what applies is the host of the destination this upload is for, and nothing else. And a storage plugin — alone among the types — may use PUT, MKCOL, PROPFIND and DELETE.

Credentials come from the stored remote logins the FTP, SFTP and WebDAV transports already use, matched on the destination's host and port. The plugin is given the user name and told whether a password exists; the password itself reaches the request as {{secret}} and never the guest.

source.progress(done, total) moves the upload step's own percentage. The numbers count the file the plugin is sending; the host adds the earlier files of the package and throttles the writes — a plugin may report per chunk.

Start one with rdownloader plugin new --type storage; Writing an upload destination walks from the scaffold to a published package.

Compatibility

manifest_version = 3 is the only revision the application accepts, and api_version must be one it supports: today that is 0.9.0 alone. A package written for an older manifest revision is refused with plugin.manifest_outdated, and one asking for an unknown type, capability or api_version with plugin.capability_unknown — a plugin built against 0.8.0 is refused that way. Both stay listed in the plugin manager so they can be recognised and removed rather than silently disappearing. Incompatible installed components are logged with plugin id and version at startup and skipped; a newer bundled version can thereby replace them.

Rebuild against the current WIT, and raise api_version together with your plugin's own version. The WIT package version is part of every interface name the host binds, so a plugin of any world has to be rebuilt when the contract version moves, even when its source builds unchanged.

What a contract change means for a component that is already built:

  • A new world, a new interface, or a new function on an interface a component imports from the host is additive in the binary sense: a component built before the change does not import it and instantiates unchanged. An older host refuses a package of a type it does not know outright — the manifest is rejected as an unknown plugin type rather than half-loaded.
  • A new function on an interface a plugin exports makes a component of that world built before the change fail to instantiate.
  • A new record field or a new variant case is additive only at the source level: your plugin builds unchanged against the new contract, but a component built before the change fails to instantiate, because the canonical ABI checks records and variants for their exact shape.
  • A replaced or removed field breaks a plugin at the source level too.

The policy: a break is versioned. Every change a component already built cannot survive — removed, renamed or re-typed functions, fields and cases, and the binary-only breaks above — moves the contract version by a minor step while it is below 1.0 (0.9.0 → 0.10.0), and by a major step after that. Every release is checked against the previous one for this.

Translations

Everything specific to a provider lives in the package, not the core: its failure codes, display name, description and credential labels.

{
  "name": "FastShare",
  "description": "Resolves FastShare links with a premium account.",
  "account": {
    "secret_label": "Account password",
    "secret_hint": "Enter your account e-mail as the user and your password as the secret."
  },
  "codes": {
    "fastshare.bad_credentials": "FastShare login or password is wrong",
    "fastshare.limit_reached": "The FastShare traffic limit was reached"
  }
}

account may also carry username_label and username_hint for a provider that asks for a user name.

Every key under codes must start with the plugin's own namespace and a dot — the provider slug when the manifest has a [provider], otherwise the [transfer] or [extension] slug — so a plugin can never override the core's plugin.* catalogue or another provider's strings. locales/en.json is mandatory whenever any locale ships; the interface serves the active language and falls back to the plugin's English, then to the English text the backend sent. The interface is offered in German, English, Spanish and French.

The resolver reaches the interface through Failure { code, params }: emit a stable code plus flat parameters, and the web app interpolates the translation. Codes outside your namespace (link.*, plugin.*) are core codes translated by the core.

The account label

account-status.label goes through the same channel as a failure. It is a list of label-part { code, params, message } records: the interface translates every part on its own — code in the active language, then in English, with params interpolated — and joins them with ·. message is the English, redaction-safe text for a code no catalogue knows; it is the last rung, never the channel. A part whose code is empty is refused by the host with plugin.account_label_invalid, so free text cannot be smuggled past the catalogue. An empty list is a valid answer: it says the check has nothing to add to valid and premium.

What every plugin says the same way has a core code, translated in four languages by the application and constructed once in plugin-common (Label), so no plugin formulates or translates it:

Code Params Says
plugin.account.user user Signed in as user — the name or address the provider echoed, bounded to 80 characters and stripped of controls
plugin.account.premium_until until Premium until until, the date as the provider states it
plugin.account.premium_lifetime — a subscription that never runs out
plugin.account.premium_expired — a subscription the provider reports as run out
plugin.account.premium_unchecked — the check proved the credentials and read nothing about the subscription (see below)
plugin.account.cookies count count cookies loaded; zero says a cookie session is required
plugin.account.signed_in — the plugin signed in with the stored credentials during this check
plugin.account.session_active — the check found the session already established

What only your provider says stays in your namespace and your catalogue, exactly like a failure code: LabelPart::coded("<slug>.account.tier", "Tier gold").with_param("tier", "gold") with "<slug>.account.tier": "Tier {tier}" in each locales/*.json.

Leave out what the row already shows: the provider's name (the account row names it) and the word "premium" (the interface prints it itself, as Premium active or premium status not confirmed). Explanations about the application — why the browser stays signed out, what a cookie session is — do not belong in a label.

What an account check may assert

account-status.premium is a finding, not a default. Answer true only where this call read the subscription — an expiry from the provider's API, a plan the account page states — and false everywhere else, including where the call proved the credentials and nothing more: a sign-in that succeeded, a cookie session that carries the sign-out link, an API key that answered. Where the value is unread, add the label part plugin.account.premium_unchecked, because false on its own cannot be told apart from a lapsed subscription. The interface then reports premium status not confirmed.

How strictly a provider's answer may be read

A provider's documentation states a shape; production sends that shape plus whatever it has always sent. Both halves of that are a plugin author's problem.

  • Read every field you do not need as optional. A provider may document a size as a byte count and quote it as a string in its own published example; a String or a u64 without Option turns that into a total read failure. The link is the only field a resolver cannot do without. Accept a number, a float and a quoted string wherever a count arrives, treat null as an absent field, and keep a fallback where the provider offers one, deprecated or not.
  • A read failure is Permanent, never Transient. A body whose shape does not fit will not fit on the next attempt either.
  • Say which field failed, and only which field. serde_path_to_error::deserialize keeps the path — content[0].size — which is what a failure message needs. Do not put the value in the message: a response body may hold a signed delivery link or a token, and a Failure message is meant to be redaction-safe.
  • Match the provider's error codes, not remembered ones. Where a provider publishes a stable code vocabulary, match that vocabulary and keep old strings only as aliases. Where no code is sent at all, read the message rather than calling the answer permanent unseen.
  • The body is the answer, not the status line. Some providers report every refusal with HTTP 200 and an error envelope. A stable code in the body travels on; the sentence beside it is classified and then dropped, because a provider's sentence cannot be translated.
  • Check that a body is an object before reading a refusal out of it. Serde builds a struct from a JSON sequence as readily as from a map, taking the elements in field order — so an error envelope read straight out of a bare array of addresses becomes a refusal invented out of a perfectly good answer.

Keys and trust

Generating a key, packaging, verifying and the trust-on-first-use flow a user goes through are on Packaging, signing and distribution and Installing and trust. What a plugin author additionally needs:

rdownloader plugin keygen --output ~/.config/rdownloader

produces rdownloader-plugin.key (PKCS#8 PEM, private, permissions 0600) and rdownloader-plugin.pub (Base64). Put the Base64 public half into your manifest's public_key; the private key stays out of the repository.

  • Installing a package signed by an unknown key does not fail outright. The application verifies the package against the key the manifest itself declares — proving the author holds it — then reports the key's fingerprint and asks for confirmation. Publish your fingerprint somewhere users can check it: that comparison is the whole security of the flow.
  • A trusted key_id is pinned to its key: a package cannot claim someone else's key id with a different key. So a new key gets a new key_id (…-v2) together with its new public_key.
  • Revoking a key blocks new installs at once and skips its already-installed plugins from the next start, with a warning; startup never fails because of one bad package.

Withdrawing one version, without revoking its key. Revoking a key takes down every plugin its author ever signed; the trust store can instead withdraw one exact package by its content digest — the length-prefixed hash over manifest.toml, the component and the locales, the same payload the signature covers, and not a hash of the .rdplug file:

GET    /api/v1/plugins/revocations
POST   /api/v1/plugins/revocations     {"digest": "<64 hex>", "reason": "…"}
POST   /api/v1/plugins/revocations     {"plugin_id": "fastshare", "version": "1.0.0", "reason": "…"}
DELETE /api/v1/plugins/revocations/{digest}

All of them need the api:secrets permission, like the key routes. The id-and-version spelling hashes what is installed here; the digest spelling works for a package that is not installed at all. Withdrawals survive a restart, and the plugin's author keeps their key. Nothing already running is torn down: the refusal applies the next time plugins are loaded, exactly as a revoked key does.

A download pinned to a withdrawn version. A job is pinned to the exact resolver version it started with. After a withdrawal that version no longer loads, so the pin is released — but not silently. A download that would have started on its own (queued, waiting for a retry, or interrupted mid-transfer) is held in blocked; one that already waits for a person keeps its state. Both carry plugin.pinned_version_withdrawn with the plugin id and the version, and resuming runs the plugin's current version. A withdrawal recorded by digest alone names no version, so its pins are released onto the current version without a word, like those of a version that is merely gone.

Withdrawals from a repository travel in its signed index (see Plugin repository index). An accepted index's withdrawn digests join the same store as an operator's, and its withdrawn keys a key list read at start: a package signed by a withdrawn key is refused outright, trust prompt included, and a trusted key of the same id and fingerprint stops being trusted. Only the official repository withdraws keys and any digest; a third-party repository withdraws only package versions that were installed here from it.

Building a package

A .rdplug is a ZIP with exactly these members; anything else is rejected:

Member Required Limit
manifest.toml yes 1 MiB
component.wasm yes 64 MiB
signature.ed25519 yes outside development mode 1 KiB
locales/<lang>.json optional, en required if any 256 KiB each, 16 files

The signature covers all of it: the signed digest is len‖manifest ‖ len‖component ‖ every locale file by name, so translations are exactly as tamper-evident as the code.

rdownloader plugin package validates the manifest, the locales and the component (no WASI imports, sandbox compilation), checks that public_key matches the signing key, and re-verifies the finished archive before writing it. --defaults <file> first completes the manifest from a shared file, as described under The manifest; the signing key supplies public_key when neither names it. --development produces an unsigned package that only serve --plugin-development-mode or verify --development-mode accepts. The commands are on Packaging, signing and distribution.

What conformance checks

rdownloader plugin conformance <package> [--json] answers one question: would this application run this package? It verifies archive, manifest and signature exactly as installation does, checks the ABI version, instantiates the component against the world its type declares, and — for a hoster resolver — asks the component whether it claims links belonging to no hoster it declares. Every other type but OAuth answers one behavioural question of its own, without a network, each with a stable check id; the table is on Developing a plugin. It exits non-zero if anything failed.

It does not check that a plugin resolves a real link: that needs a hoster, an account and a network. A package that passes every check can still be wrong about its provider.

The SDK's ci/plugin.yml runs the build, the import check, packaging, verification and conformance against a pinned rDownloader release.

Superseded versions

Installing never removes the version it replaces. Two version directories under one plugin id are the normal state after an upgrade, and the highest installed SemVer is the one that loads unless a version choice says otherwise (see Active, staged and previous versions); the other one stays because a job already under way keeps the version that started it.

  • On startup, serve installs the bundled *.rdplug packages when their version is not yet installed or is newer than every installed version. Older versions are left in place, so jobs pinned to one keep resolving. A package whose content changed under an unchanged version is therefore never taken: raise version with every change you ship.
  • A first install of a resolver, auth or oauth plugin runs at once, so an account can be created and tested right after installing. Every other type, and every update of a plugin that already runs, takes effect from the next start, so a running download never meets another version half-way.

Settings → Plugins → Installed shows one card per plugin, not per directory: the loaded version is the card, and the superseded ones sit under it in a collapsed sub-entry.

A superseded version can be removed by hand, from the sub-entry or with DELETE /api/v1/plugins/{id}/{version}. The request is refused with 409 and the code plugin.version_in_use while unfinished work is bound to that exact version:

  • a resolver pin, claimed by the job the first time it resolved,
  • a transfer checkpoint, which only the version that wrote it can read.

Only a completed download holds nothing. Everything else still holds its version, a cancelled job included: cancelling keeps the partial data and resuming puts the job back in the queue, so its checkpoint is a claim like any other. A job that is really gone is deleted, and deleting it takes its pin and its checkpoint with it. The guard applies to the loaded version too, so uninstalling a plugin cannot pull a running job's component away either. The refusal names the first blocking downloads as well as their number.

All superseded versions go at once with DELETE /api/v1/plugins/superseded (every plugin) or DELETE /api/v1/plugins/{id}/superseded (one plugin), or the MCP tool remove_superseded_plugin_versions with an optional plugin id. The rules are those of removing one, in the administration scope with an audit record per version. The answer lists removed and kept; every kept version carries its reason as a code: plugin.version_in_use as above, plugin.version_next_start for the version the next start loads, plugin.version_under_test for a version under test. The version that runs is never touched.

Nothing removes a superseded version on its own.

Active, staged and previous versions

Per plugin the operator can choose which installed version runs, put a second one under test and return to the one before. Every choice takes effect at the next start, like an update.

  • The default version — the one new work runs on — is the chosen active version while it loads and is not the staged one, otherwise the newest loaded version that is not staged. A plugin without a choice runs its newest version.
  • A staged version is never the default: only a download pinned to it runs it.
  • Every other loaded version is retained: it serves the jobs pinned to it and nothing new. An unpinned lookup never falls through to an older version when the default does not claim an address.
  • A withdrawn package or a revoked key never loads, so a withdrawn version can be neither active nor staged.
POST   /api/v1/plugins/{id}/lifecycle/activate   {"version": "1.0.0"}
POST   /api/v1/plugins/{id}/lifecycle/stage      {"version": "2.0.0"}
DELETE /api/v1/plugins/{id}/lifecycle/stage
POST   /api/v1/plugins/{id}/lifecycle/rollback
PUT    /api/v1/plugins/{id}/lifecycle/policy     {"policy": "manual" | "automatic"}
POST   /api/v1/plugins/{id}/lifecycle/trial      {"download_id": "…"}

All of them need api:admin, like installing, and the MCP toolbox leaves them out. Activating and staging first verify the version in full, as the next start would load it — signature, withdrawal, manifest, locales and the component's compilation — and refuse with plugin.version_unhealthy and the reason otherwise. Activating makes the replaced version the rollback target; a rollback swaps the two, so everything that starts after the next start runs on one version, while downloads already pinned to the other keep it. Staging the version that would become the default — a newer one just installed — keeps the running one active instead. trial points one download that is not running at the staged version; it needs the staged version loaded (a restart after staging) and a resolver plugin, because only resolver jobs carry a pin. Removing a version clears every choice that names it. Every choice is audited as plugin_version_chosen.

The update policy is per plugin: manual shows an offered update and installs it on a click, automatic lets the repository check install it on its own — still only under a plugin key that is already trusted, and never an update that asks for a permission the installed version lacks. An installed update — either way — applies from the next start. A plugin that ran its newest version keeps doing so: the update becomes active and the version it replaces the rollback target. A plugin held below its newest version on purpose — rolled back, an older version activated, or a newer one under test — keeps the version it runs; the update waits installed beside it. A first install or an older version from a repository moves nothing.

GET /api/v1/plugins reports each plugin's lifecycle next to its versions — the version that runs now, the one the next start runs, the staged and the previous one, the policy and whether a restart is pending.

Plugin repository index

A repository publishes one signed JSON document, rdownloader-plugin-index.json, listing the packages it offers and what it withdraws. A repository delivers; it vouches for nothing. The index is signed by a repository key — the compiled-in root rdownloader-repository-v1 for the official repository, a key the person approved for any other — and every package it lists still has to verify under a trusted plugin key on install. The index names each package by its package_digest — the hash the plugin signature covers, not the .rdplug file hash.

The envelope is a signed document under the domain rdownloader.plugin-index.v1. The payload (schema version 1, closed — an unknown field anywhere refuses the whole index):

{
  "schema_version": 1,
  "sequence": 1790000000,
  "issued_at": "2026-09-26T12:00:00Z",
  "not_after": "2026-12-25T12:00:00Z",
  "packages": [{
    "id": "019d…", "name": "DDownload", "version": "0.3.1", "plugin_type": "resolver",
    "api_version": "0.9.0", "min_app_version": "1.4.0",
    "package_digest": "<64 lowercase hex>", "size": 412345,
    "url": "https://example.org/plugins/ddownload-0.3.1.rdplug",
    "publisher": { "key_id": "example-plugin-v1", "fingerprint": "<64 lowercase hex>", "author": "Example" },
    "permissions": { "granted": ["net_http", "captcha"], "http_domains": ["ddownload.com"], "stream_hosts": [] },
    "release_notes": "Plain text."
  }],
  "revoked": {
    "package_digests": ["<64 lowercase hex>"],
    "keys": [{ "key_id": "leaked-v1", "fingerprint": "<64 lowercase hex>" }]
  }
}
  • api_version is the contract the component was built against; an entry with a type or contract this build does not speak is listed, and only the install refuses it.
  • url is absolute https://, or a plain relative path resolved against the index's own address that cannot leave its directory (no / prefix, .., \, scheme, query or fragment).
  • publisher.fingerprint is the SHA-256 of the manifest's public key.
  • A withdrawn key is named by id and fingerprint. An index that offers a package it withdraws, or one signed by a key it withdraws, is refused as contradictory.
  • The entry is held to the package. A downloaded package is refused unless its size and package_digest match (plugin_repository.digest_mismatch), it is signed — development mode or not — and its plugin id, name, version, type, contract, signing key, author and permissions equal the entry's (plugin_repository.package_mismatch).

Verification order: size, envelope, revocation and signature, schema version, freshness (the sequence must exceed the highest one already accepted from that repository, not_after must not have passed, issued_at at most an hour ahead), content.

Limit Value
Signed index 4 MiB, checked before parsing
Packages per index 512
Release notes per package 2,000 characters of plain text; no control characters except newline and tab
Withdrawn digests / keys 4,096 / 256
Declared package size 96 MiB
Validity window not_after required, at most 366 days after issued_at; plugin index build uses 90
Name, author / key id / list entry / URL 120 / 128 / 256 characters / 1,024 bytes; at most 4,096 entries per permission list

Building one is on Packaging, signing and distribution.

Repositories, updates and the install preview

  • The official repository is built in (id official): the index attached to the newest release, under the compiled-in repository key. Its packages are the assets of the separate release plugins-vX.Y.Z on the same commit. It can be switched off, never removed.
  • A third-party repository needs its key approved. POST /api/v1/plugins/repositories with url (plain https://, no credentials) and public_key (Base64 Ed25519) fetches the index, checks that the key signs it, and answers 409 plugin_repository.key_unconfirmed with key_id, fingerprint and the package count. The same request with ?trust_fingerprint=<fingerprint> adds it.
  • Refresh: two minutes after start, then every refresh_hours (default 24, 1–168, PUT /api/v1/plugins/repositories/settings), and on POST /api/v1/plugins/repositories/refresh. Every redirect hop must be https, at most five. A failure is recorded on the repository (last_error, a stable code) and changes neither the replay floor nor the cached index.
  • Offline: at start every cached index is verified again — signature, expiry, not below the floor — and one that fails is deleted.
  • PATCH /api/v1/plugins/repositories/{id} {"enabled": false} switches one off; DELETE removes a third-party one. Neither removes an installed plugin.
  • Updates (GET /api/v1/plugins/updates): per installed plugin the newest compatible offer newer than every installed version and signed with the key of the newest installed one. available lists the packages of plugins not installed here with their compatibility (contract_unsupported, app_too_old, withdrawn); installed every offered version of an installed plugin, with its release notes. adds_permissions marks an update that asks for a capability, domain or stream host the installed version lacks.
  • Installing from a repository: POST /api/v1/plugins/repositories/{id}/preview and …/install with plugin_id and version; an unknown plugin key is confirmed with trust_fingerprint in the body. POST /api/v1/plugins/preview with a .rdplug body previews an upload and reports the key's standing (trusted, untrusted, mismatch, withdrawn, unsigned) without installing or trusting anything.

All of these routes need api:admin, like installing. MCP offers list_plugin_updates; adding a repository, refreshing, previewing and installing stay in the interface.

Diagnostics

Every plugin invocation is recorded: which plugin, which version, which entry point, how it ended and how long it took. Settings → Plugins → Installed shows the newest entries per plugin, and GET /api/v1/plugins/{id}/executions returns the same list.

The outcome separates the things a user acts on differently — ok, failed (the plugin worked, the hoster said no), crash, timeout, fuel, memory, host_error and denied (it asked for something its manifest does not grant). A run of denied means a manifest that promises too little; a run of crash is a bug report for the plugin's author.

The message passes the same redaction every persisted failure passes, so a credential or a session token in a plugin's error text never reaches the history. Each entry carries a bare correlation id that names the entry and nothing else, so it can be quoted in a report. The history is capped per plugin and is not backed up: it is a diagnostic, not a record.

The contract

Every world, interface, function and type of the WIT, with the WIT's own comments. This part is generated from rdownloader.wit whenever the contract changes and is never edited by hand; the sections above explain what the host does with each call.

Contract reference: rdownloader:plugin@0.11.0

Every world a plugin can be built against, and every interface in the contract, as the WIT states them. A world's name is also the scaffold's: rdownloader plugin new --type <name> for the world <name>-plugin.

Worlds

World Imports Exports Scaffold
resolver-plugin host, http, cookies, captcha resolver --type resolver
transfer-plugin host, net, sink, http transfer --type transfer
intake-plugin host, http intake --type intake
intake-mirrors-plugin host, http intake, mirror-sets --type intake-mirrors
crawler-plugin host, http, cookies, captcha, key-derivation crawler --type crawler
remote-job-plugin host, http, job-context remote-job --type remote-job
auth-plugin host, http, credentials, key-derivation auth --type auth
oauth-plugin host, http, credentials oauth --type oauth
enricher-plugin host, http enricher --type enricher
notifier-plugin host, http, destination-settings notifier --type notifier
postprocess-plugin host, source postprocess --type postprocess
storage-plugin host, http, net, source storage --type storage
stream-transform-plugin host, http, key-derivation stream-transform --type stream-transform
  • intake-mirrors-plugin: An intake parser that also states every source of the files it proposes.

Interface types

variant failure-kind

Case Payload Meaning
transient option<u64>
permanent
offline
auth-required
account-invalid
rate-limited option<u64>
needs-captcha
unsupported
ip-blocked option<u64> This IP may not start another free download yet; the scheduler blocks only this hoster for the given number of seconds and keeps other hosters running.
captcha-failed A captcha was solved but the hoster rejected the answer.

record failure

Field Type Meaning
category failure-kind
message string English, redaction-safe text.
code option<string> Stable translation code such as plugin.account_invalid.
params list<tuple<string, string>> Flat parameters referenced by the translated text.

record client-identity

Field Type Meaning
account-id option<string>
proxy-profile-id option<string>
tls-revision u64

record resolve-request

Field Type Meaning
url string
client client-identity

record resolved-header

Field Type Meaning
name string
value string

record resolved-download

Field Type Meaning
url string
file-name option<string>
size option<u64>
headers list<resolved-header>
checksum-algorithm option<string>
checksum-value option<string>
client client-identity

record label-part

One translatable part of what stands next to a provider account, or of what a post-processing step warns about in postprocess.step-complete.

Translated exactly like failure: the interface looks code up in the active language, then in English, interpolates params, and only when no catalogue knows the code does it print message. A part without a code is refused by the host as plugin.account_label_invalid -- the text is the last rung, never a second channel.

Field Type Meaning
code string Stable translation code: plugin.account.* from the core catalogue, which every installation carries in four languages, or <provider.slug>.* from the plugin's own.
params list<tuple<string, string>> Flat parameters referenced by the translated text.
message string English, redaction-safe text for a code no catalogue translates.

record account-status

Field Type Meaning
valid bool
premium bool
label list<label-part> What the interface prints next to the account, one translated part after another, joined with a separator of its own choosing. Empty when the check has nothing to add to valid and premium; a provider's name or the word "premium" belong to the row, not here.
traffic-left option<u64> Remaining traffic in bytes. A provider whose API reports another unit has to convert; the host stores this verbatim and the interface formats it as bytes.

enum link-status

Case Meaning
online
offline
unknown
cached The provider holds the file in its own cache right now. Not the same as online, which says the file exists: this says it is ready to hand over at once, and that expires without notice. The host stamps it with the moment of the check and shows it with that time, never as a promise. Answer it only for a file your provider said is cached; a file it knows but has not fetched is online.

record link-check-result

Field Type Meaning
url string
status link-status
file-name option<string>
size option<u64>

record check-request

Field Type Meaning
urls list<string>
client client-identity

Interface host

Always granted. Nothing here reaches the network, a credential value or the user.

Uses failure from types.

  • log: func(level: string, message: string)

  • wait: func(seconds: u32) -> result<_, failure>

    Waits out a hoster countdown. The host owns the clock, so the guest neither busy-loops nor burns its compute budget; the wait does not count towards the execution timeout, but it is capped by the plugin's wait budget and aborts with the download.

  • secret-available: func(account-id: string, reference: string) -> bool

    Reports whether a credential exists, never its value.

  • now-unix-seconds: func() -> u64

    Seconds since the Unix epoch, from the host's clock.

    Not a capability: the time of day identifies nobody and reaches nothing. A guest has no clock of its own — there is no WASI here — so without this it cannot answer questions as ordinary as whether an account's premium period has run out.

  • random-bytes: func(count: u32) -> list<u8>

    Cryptographically strong random bytes, from the host's own operating-system source.

    Not a capability either: it hands entropy out, it reads nothing back. A guest has no random source — there is no WASI here, and a clock plus an account id is a value anyone can recompute — so without this a plugin cannot produce the one thing a PKCE verifier or an OAuth state has to be, which is unguessable.

    The host answers with exactly count bytes, or with an empty list when count is zero or above the host's cap of 1024. An answer shorter than what was asked for is a refusal: a guest must fail on it rather than fall back to a value it derived itself.

Interface http

Capability net_http: outbound HTTPS confined to the declared domains.

Uses failure from types.

  • http-request: func(method: string, url: string, query: list<request-query>, headers: list<request-header>, body: list<u8>) -> result<http-response, failure>

record request-header

Field Type Meaning
name string
value-template string

record request-query

Field Type Meaning
name string
value-template string

record http-response

Field Type Meaning
status u16
final-url string
headers list<tuple<string, string>>
body list<u8>

Interface cookies

Capability cookies: read cookies scoped to one account and URL, never the jar itself.

  • cookies-get: func(account-id: string, url: string) -> list<tuple<string, string>>

Interface captcha

Capability captcha: hand a challenge to the application, which solves it through a configured solver service or by asking the user.

Uses failure from types.

  • solve-captcha: func(challenge: captcha-challenge) -> result<captcha-solution, failure>

    Token-shaped solving. A click-point challenge is refused here with captcha.answer_shape before anyone is asked, because this function cannot carry its answer.

  • solve-challenge: func(challenge: captcha-challenge) -> result<captcha-answer, failure>

    Solves any challenge and returns the answer in its own shape.

record widget-challenge

Challenge served by a captcha widget, solved from its site key and page URL.

Field Type Meaning
site-key string
page-url string
invisible bool

record image-challenge

Classic image captcha; data is the raw image as served by the hoster. The same record carries a click-point captcha, where the answer is a spot in the picture rather than text.

Field Type Meaning
mime string
data list<u8>
prompt option<string>

record cutcaptcha-challenge

CutCaptcha widget. Its solver task needs both keys the page loads the widget with: the widget's own identifier (site-key, the data-apikey of its script) and the page's CUTCAPTCHA_MISERY_KEY. Answered by a solver service only — no browser reads its token.

Field Type Meaning
site-key string
misery-key string
page-url string

variant captcha-challenge

Cases are only ever appended. The first four answer with a token through solve-captcha; click-point answers with a coordinate and therefore only through solve-challenge.

Case Payload Meaning
recaptcha-v2 widget-challenge
hcaptcha widget-challenge
turnstile widget-challenge
image image-challenge
click-point image-challenge
cutcaptcha cutcaptcha-challenge

record captcha-solution

Widget token, or the typed text of an image captcha.

Field Type Meaning
token string

record click-point

Where the person clicked, in pixels of the image exactly as it was served — never of the rendering, which the application may have scaled.

Field Type Meaning
x u32
y u32

variant captcha-answer

The answer in the shape the challenge has: a token for every widget and the typed text of an image captcha, a point for a click-point captcha.

Case Payload Meaning
token string
point click-point

Interface net

Capability net_stream: raw TCP, optionally upgraded to TLS, to the declared hosts and ports. A transfer backend needs this because FTP and SFTP are not HTTP derivatives; with http-request alone the plugin type would be limited to protocols that already are.

A connection is an opaque number, not a resource handle. The number is meaningful only inside the invocation that received it and only to the host, which owns every socket and closes all of them when the invocation ends — so a leaked handle costs nothing and a forged one addresses nothing.

Uses failure from types.

  • connect: func(host: string, port: u16, tls: bool) -> result<u32, failure>

    Opens a connection and returns its handle. tls starts the handshake immediately; a protocol that negotiates encryption in-band connects plain and calls start-tls.

  • read: func(connection: u32, max: u32) -> result<list<u8>, failure>

    Reads up to max bytes. An empty result means the peer closed the connection.

  • write: func(connection: u32, bytes: list<u8>) -> result<u32, failure>

  • start-tls: func(connection: u32, server-name: string) -> result<_, failure>

  • close: func(connection: u32)

    Releases the handle early. Not required — every connection is closed when the invocation ends — but it frees a slot for a protocol that opens a channel per file.

Interface sink

The destination of the transfer this invocation is running.

There is deliberately no handle and no path here. The store already knows which download it belongs to, so the guest writes into "its" file and cannot name, reach or even refer to another one — the storage root is not guarded, it is unreachable.

Uses failure from types.

  • write-at: func(offset: u64, bytes: list<u8>) -> result<_, failure>

  • committed: func() -> u64

    Bytes the host has already accepted, so a resumed transfer knows where it stopped.

  • sync: func() -> result<_, failure>

  • progress: func(committed: u64, total: option<u64>)

    Reports progress and, when the backend knows it, the total size.

  • should-stop: func() -> bool

    Whether the download was paused or cancelled; a backend that ignores this is stopped by its own execution budget instead.

Interface credentials

Writing back what an authentication flow produced. Imported by auth-plugin and oauth-plugin.

The plugin names no vault reference and reads nothing: it hands over the token it just obtained for the account this invocation is running for, and the host decides where that belongs — which reference the claimed provider owns — before storing it. There is no counterpart that reads a value back, so this interface can only ever add a secret the plugin already held in its own hands.

Uses failure from types.

  • store-token: func(account-id: string, value: string) -> result<_, failure>

  • store-oauth-token: func(account-id: string, access-token: string, refresh-token: option<string>, expires-in-seconds: option<u64>) -> result<_, failure>

    Stores what an OAuth exchange produced: the access token, the refresh material that mints the next one, and how long the access token is good for.

    Three values rather than one because a host that cannot tell them apart cannot renew anything — it would have to ask the person again every time a token aged out. Both tokens go to the vault. The expiry is the only part kept in the clear, and only because a sweep has to be able to ask whether a flow is due without decrypting a secret to find out.

  • store-flow-secret: func(account-id: string, name: string, value: string) -> result<_, failure>

    Stores one named part of what a sign-in produced, beside its token.

    For a provider whose renewal needs more than a refresh token: Real-Debrid's open-source device flow hands every person a client id and a client secret of their own, and every renewal needs both. name must be a slot the account's provider declares with filled_by = "flow" other than the one the access token fills. The host keeps each part as a vault entry of its own, so parts are never joined into one value that would have to be split again, and replaces a part stored under the same name. There is no call that reads one back: a later request names it as {{secret:<name>}}, expanded by the host towards the slot's own domains and nowhere else.

Interface source

Reading the files of a package, for the plugin types that work on finished downloads.

The counterpart of sink: a handle, never a path. A post-processing or storage plugin cannot name a file outside the package it was given, because it never names files at all.

Uses failure from types.

  • read-at: func(handle: string, file: string, offset: u64, length: u32) -> result<list<u8>, failure>

    Reads up to length bytes of one file of the package.

  • size-of: func(handle: string, file: string) -> result<u64, failure>

    Size of one file of the package.

  • progress: func(done: u64, total: option<u64>)

    Reports progress of a long-running step.

  • should-stop: func() -> bool

    Whether the work was cancelled.

  • rename: func(handle: string, file: string, new-name: string) -> result<_, failure>

    Renames one file of the package.

    Still a handle and a name, never a path: file must be one of the entries the host offered, and new-name is a bare file name inside the same package. The host refuses a name carrying a separator, and refuses to overwrite an existing file. Added to source rather than as a new interface so a plugin that only reads is unaffected.

Interface transfer

Uses failure from types.

  • probe: func(url: string, credential-ref: option<string>) -> result<remote-file, failure>
  • run: func(job: job) -> transfer-end

record remote-file

Field Type Meaning
size option<u64>
last-modified option<string> RFC 3339, as reported by the server; compared verbatim on every continuation.
resumable bool Whether this backend can continue a partial file at all.

record job

Field Type Meaning
url string
credential-ref option<string> Opaque reference to a stored login; the value never reaches the guest.
checkpoint option<list<u8>> The checkpoint this backend wrote when it last stopped, if any.

variant transfer-end

How a transfer ended. The payload is the backend's own checkpoint, opaque to the host, persisted as it arrives and handed back verbatim on the next attempt.

Case Payload Meaning
complete option<list<u8>>
stopped list<u8>
failed failure

Interface resolver

Uses account-status, check-request, failure, link-check-result, resolve-request, resolved-download from types.

  • match-url: func(url: string) -> bool

  • check-account: func(account-id: string) -> result<account-status, failure>

  • resolve: func(request: resolve-request) -> result<resolved-download, failure>

  • check: func(request: check-request) -> result<list<link-check-result>, failure>

  • hosters: func(account-id: string) -> result<list<string>, failure>

    Hoster domains the account can download from (multihosters return their catalogue).

Interface intake

Turning raw text and URLs into LinkGrabber candidates.

A parser proposes; it never decides. Everything it returns goes through the same review, validation and routing a pasted link does, so a plugin cannot queue anything by itself.

Uses failure from types.

  • claims: func(input: string) -> bool

    Whether this plugin wants to look at the given text at all.

  • parse: func(input: string) -> result<list<intake-candidate>, failure>

    Extracts candidates. An empty list means "nothing here", not an error.

  • normalize: func(url: string) -> result<option<string>, failure>

    Rewrites one URL into its canonical form, or leaves it alone.

record intake-candidate

Field Type Meaning
url string
file-name option<string>
size option<u64>
package-hint option<string> Groups candidates the parser considers one release. The host decides whether that grouping survives; it is a suggestion, not a package.

Interface mirror-sets

Every source of a file, not just the first.

intake proposes one link per file, which is right for the LinkGrabber and loses what a Metalink document is for: the same bytes at several addresses, ranked, with the hashes they must match. A parser that knows a file's sources states them here as well; the host checks every field, keeps the set beside the candidate it proposed through parse, and hands it to the transfer, which fetches chunks from several sources at once.

Its own interface and world, so every intake plugin that does not implement it keeps satisfying intake-plugin exactly as before. The host asks it only of a component that exports it.

Uses failure from types.

  • sets: func(input: string) -> result<list<source-set>, failure>

    The source sets in input, one per file. Empty when there are none.

record set-source

One address of the file.

Field Type Meaning
url string
priority option<u32> Lower is preferred, as Metalink 4 counts it (1 to 999999). Absent sorts last.
location option<string> ISO 3166-1 alpha-2 country code the document gave.

record stated-hash

A digest the document states, e.g. sha-256 and its hex value.

Field Type Meaning
algorithm string
value string

record piece-hashes

Hashes of consecutive pieces of length bytes; the last piece may be shorter.

Field Type Meaning
algorithm string
length u64
hashes list<string>

record source-set

One file and every place it can be fetched from.

Field Type Meaning
primary-url string The address this file was proposed under in parse; the host matches the two by it.
file-name option<string>
size option<u64>
sources list<set-source> In document order; the host sorts them by priority.
hashes list<stated-hash>
pieces option<piece-hashes>

Interface crawler

Turning one address into the many files behind it.

The counterpart of resolver, and the reason it is not part of it: resolve answers with exactly one resolved-download, so an address pointing at a folder has no shape to come back in. Teaching resolved-download to be either would break every .rdplug already signed and in the field, and teaching intake to fetch a page would mean lifting the rule that a parser may not change a link's host — the rule that stops a parser redirecting somebody. So a folder gets its own interface and its own world, and every shipped resolver is left exactly as it is.

A crawler proposes, like a parser does. What it returns goes through the same review, validation and routing a pasted link does; a crawler queues nothing by itself, and the host bounds how many links it will take from one answer.

Uses failure from types.

  • claims-url: func(url: string) -> bool

    Whether this plugin claims url. The counterpart of the resolver's match-url, and asked first so a plugin that has nothing to do with an address never fetches it.

  • crawl: func(url: string) -> result<list<crawled-link>, failure>

    Enumerates what lies behind url.

    An empty list is not a result: a folder that is empty, gone or unreachable is a failure with a stable code, so the person is told rather than handed a package with nothing in it. Depth, count and cycles are the plugin's own to bound — one invocation runs under one fuel and time budget, and a crawler that walks forever spends it and is stopped.

record crawled-link

One file found behind the crawled address.

Field Type Meaning
url string The address of the file itself. It may point at another host: unlike normalize in intake, which rewrites one link and must not move it, a crawler was asked what lies behind an address and answering that is its whole purpose.
file-name option<string>
size option<u64>
package-hint option<string> Where this file sat, as a path relative to the crawled address — Season 1 for a file one folder down, empty or absent for one lying directly behind it. The host reads the outermost segment as the package suggestion and keeps the rest as the file's place in the structure; it is a suggestion, not a package.

Interface job-context

What the host knows about the job a remote-job call is made for, beyond the source.

Additive (it came within contract 0.9.0): an interface of its own, imported by remote-job-plugin only, so a component built before it existed links unchanged and a record the guest already knows keeps its shape.

  • source-name: func() -> option<string>

    The name the person's source was added under -- a container's file name, reduced by the host to one path segment without control characters -- during submit, when the client sent one. none in every other call and for a source that came without a name.

    It exists for providers that name a job after the uploaded file's name: an upload under a fixed name gave every job one name, and at Premiumize one cloud folder, so two jobs read each other's files back (2026-09-27). It is a label, never a path, and never a key: the content key stays what identify derives from the bytes.

Interface remote-job

A job that runs at the provider, for days if it has to.

The fifth shape of long-running work, and the first that none of the other worlds could hold. resolve answers with one file, crawl with a list in one call, parse with what was already in hand, and run carries bytes for exactly as long as one download lasts. A magnet handed to a debrid provider is none of those: it answers with an identifier, works for minutes or hours, stops half-way to ask which files are wanted, and leaves something in the account afterwards that only an explicit delete removes.

So the work is split. Every function here is short and returns on the provider's next answer -- nothing waits, nothing sleeps, nothing carries state of its own between calls. The waiting, the remote identifier, the clock, the person's answer and the restart all belong to the host, which writes them down. docs/adr/0003-a-job-that-runs-at-the-provider.md records why that line is where it is, and the four designs that were refused.

Two functions reach no job at all. cache-kinds and check-cached ask whether the provider holds something ready before anybody hands it over -- the LinkGrabber's question, not the sweep's. They read and never create: nothing at the provider changes, no row is written, and submit is still the only call that puts anything in an account.

Uses failure from types.

  • cache-kinds: func() -> list<cache-kind>

    The kinds this plugin can ask its provider's cache about. Reaches nothing, and is asked once per load, not per check. Empty for a provider without a cache query: the host then never calls check-cached. A kind listed twice counts once.

  • check-cached: func(account-id: string, queries: list<cache-query>) -> result<list<cache-answer>, failure>

    Whether the provider holds each source ready right now.

    Exactly one answer per query, in the order given. The host pairs them by position and refuses a list of any other length as a whole -- a shifted list would put one link's cache on another. At most 100 queries arrive per call, and only of kinds cache-kinds named; a query this plugin cannot answer is unknown, not a failure.

    Read-only at the provider: it creates nothing and costs nothing but a request. A provider whose only way to find out is to submit answers unknown. A failure drops the whole call's answers and changes no link -- the check without it is what the person would have seen anyway.

  • claims: func(source: job-source) -> bool

    Whether this plugin takes the source at all. Reaches nothing: answered from the source alone, before anything is handed to any provider.

  • identify: func(source: job-source) -> result<string, failure>

    The content key of a source: derived locally, without a request, and the same for the same content every time.

    This is what makes a duplicate preventable. The host writes it into the row before it calls submit, and refuses a second row for the same key on the same account -- so a magnet submitted twice cannot become two jobs, whatever happens to the process in between. A plugin that cannot derive one refuses rather than inventing something unstable; a key that changed between two calls would defeat the guard it exists for.

  • submit: func(request: submit-request) -> result<remote-handle, failure>

    Hands the source to the provider and answers with the job it created.

    Assumed not idempotent, because at every provider this world was designed for it is not: calling it twice creates two jobs and charges for both. Everything protecting that is on the host's side of the line -- the key, the unique row, the two-attempt ceiling -- so a plugin here has one obligation: answer with the identifier, or fail.

  • adopt: func(account-id: string, content-key: string) -> result<option<remote-handle>, failure>

    The job the provider already holds for content-key, if there is one.

    The one window the row cannot close: a crash between the request going out and the identifier coming back. The host asks this before it would ever submit a second time, so the answer comes from the provider's own list instead of from a guess. none means the provider has nothing for that key, which is a real answer and not an error.

  • poll: func(handle: remote-handle) -> result<remote-progress, failure>

    Where the job stands now. Called on the host's schedule and never on the plugin's.

  • choose: func(handle: remote-handle, chosen: list<u32>) -> result<_, failure>

    Answers the question awaiting-choice asked, with the entry ids that were chosen.

    Never called with an empty list. At one provider that is an error and at another it silently means "all of them", and neither is an answer anybody gave.

  • discard: func(handle: remote-handle) -> result<_, failure>

    Removes the job at the provider.

    Reached from one explicit, confirmed request and from no other path -- not from removing the local package, not from a job that failed, not from a cleanup sweep. What rDownloader did not put in somebody's account on its own it does not take out on its own.

variant job-source

What a person handed over.

Case Payload Meaning
magnet string A magnet: address, verbatim.
container list<u8> The bytes of a container the provider accepts -- a .torrent file, or an .nzb.
address string A plain address the provider fetches for itself. The third shape, and the one the first two could not hold. A debrid provider's src takes an ordinary http(s) link as readily as a magnet, and answers about it the same way -- an identifier, then minutes of work, then files. Handing that link to the local HTTP engine instead would be a different download with a different outcome: the provider is what makes the address fetchable at all. It is an address and not a download: the host checks the scheme before it ever reaches a plugin, so a file: or a data: address is refused here rather than somewhere further in.

record remote-handle

The provider's job, once it exists.

Field Type Meaning
remote-id string The provider's own identifier for it. The host writes this the moment submit answers, because until it is written down a crash cannot tell "already submitted" from "not submitted yet" -- and at a provider whose submit is not idempotent, that difference is a duplicate in somebody's account.
account-id string The account the job runs on. The plugin never learns the credential behind it.
job-state option<string> Whatever the plugin needs on the next call, stored verbatim by the host and handed back, because a guest is instantiated fresh for every call and remembers nothing of its own. Not a credential and never shown, exactly as auth's flow-state.

record submit-request

Field Type Meaning
source job-source
account-id string
content-key string What identify answered for this source. Passed back so a provider that can index by it does not have to derive it a second time.

record remote-entry

One thing inside the remote job that a person may or may not want.

Field Type Meaning
id u32 The provider's own identifier for this entry, stable for the life of the job. It is what choose names, so it is the provider's numbering and not a position in the list.
path string Where it sits inside the job, as a relative path -- Show/Season 1/episode.mkv.
size option<u64>
selected bool Whether the provider already considers this entry chosen. A provider that preselects everything is showing a default, not an answer.

record remote-artifact

One address the finished job produced.

An address and not bytes: at a debrid provider the links a finished torrent carries are still restricted ones, so what comes back here goes through the resolver that already exists and then down the ordinary resumable queue.

Field Type Meaning
url string
file-name option<string>
size option<u64>
package-hint option<string> Where the file sat inside the job, as crawled-link.package-hint means it.

record remote-work

How far a job that needs nobody has got.

Field Type Meaning
progress-permille option<u16> Thousandths, so a long job's progress is visible without floating point. Above 1000 is clamped by the host rather than believed.
speed-bytes-per-second option<u64>
seconds-remaining option<u64>

enum cache-kind

What the host knows a source to be, beyond its shape.

Decided by the host before it asks -- from the scheme, the extension, or the content type an indexer declared -- because an address alone cannot say it: an indexer's NZB link and a hoster's file link are both https. The plugin derives its own key from the source, exactly as identify does; the kind only says which of its caches to ask.

Case Meaning
torrent BitTorrent content: a magnet. A .torrent the host holds arrives as the magnet of its info hash, never as bytes.
usenet An NZB: an address that serves one, or the bytes of one as container.
hoster A file address that one of the installed hoster plugins resolves. Asking about it does not change who resolves it.

record cache-query

One source the host asks after.

Field Type Meaning
source job-source
kind cache-kind

enum cache-state

What the provider said about one source.

Case Meaning
cached Held ready right now and handed over at once. Expires without notice; the host shows it with the time it was measured, as link-status::cached means it.
known Known to the provider, not held. The host stores it as online.
unknown Nothing to say -- and that includes "not in my cache". There is no offline here on purpose: a cache that does not hold a file has not shown the file is gone.

record cache-answer

The answer for one cache-query.

Field Type Meaning
state cache-state
file-name option<string> The name the provider knows the content by, when it said one. Reduced to a plain name by the host, like every other name a provider sends.
size option<u64>

variant remote-progress

Where the job stands, as the provider last described it.

Case Payload Meaning
preparing option<u64> The provider has it and is doing something that needs nobody -- converting a magnet, compressing, uploading. The optional value is a suggested wait in seconds, which the host treats as a suggestion: it owns the clock, because a plugin that set the poll interval could spend the whole account's request budget.
awaiting-choice list<remote-entry> Nothing moves until a person has chosen. The list is what they are shown, and the next call the host makes for this job is choose. Only for a provider that keeps the answer itself. choose returns nothing and the host never hands the recorded choice back, so the poll after it has to read the answer from the provider -- Real-Debrid leaves waiting_files_selection once selectFiles succeeded. A job that asks again after its answer is ended under remote_job.choice_not_kept; it never goes back to waiting.
working remote-work The provider is fetching. Same clock rule as preparing.
ready list<remote-artifact> Finished, with the addresses it produced. An empty list is a failure, not a result: a job that finished with nothing has to say why, in a language somebody reads.
failed failure The provider ended it. Terminal; the host stops polling and keeps the row so the person can see what happened and delete it if they want to.

Interface key-derivation

Capability key_derivation: the host computes over a credential the guest never sees.

auth states in its own header that a plugin never sees a credential, and the host keeps that promise by substituting {{username}} and {{secret:<reference>}} on the way out of the guest. A guest can therefore send a credential; it cannot compute with one. That is enough for every provider whose sign-in posts the password and for none whose sign-in posts a value derived from it -- MEGA's us wants the second half of PBKDF2(password, salt, 100 000), and the first half unwraps the account's master key.

So the host computes and the guest only names. derive takes a handle -- the same reference the plugin would have written into a {{secret:<reference>}} marker, and nothing else -- runs the named steps over the credential behind it, and answers with the last step's output. No step's input is ever returned, so the credential and every intermediate value stay on the host's side of the boundary; what comes back is the one value the provider is meant to receive.

Three things bound it, and all three are enforced by the host:

  • The handle names a reference this manifest already declares under capabilities.secrets, so the primitive reaches no credential the plugin could not already have caused to be sent. It changes the direction of the computation, not what may be touched.
  • A credential a person typed must pass through pbkdf2-hmac-sha512 first, and below the host's round floor it is refused. Without that rule this interface would be an oracle cheaper than the guest's own arithmetic, and a dictionary would run through it.
  • What a call costs is charged against the caller's fuel, at the price the same computation was measured to cost inside the sandbox. Computing here is therefore never a discount -- only the credential's whereabouts change.

docs/adr/0020-the-host-computes-over-the-secret.md records why the derived bytes do not reconstruct the credential, and what the host refuses in order to keep that true.

Uses failure from types.

  • derive: func(secret: secret-handle, steps: list<step>) -> result<list<u8>, failure>

    Runs steps over the credential behind secret and answers with the last step's output.

    An empty list is refused: it would be asking for the credential itself.

record secret-handle

Which credential a computation runs over. A name, never a value.

Field Type Meaning
reference string One of the references this plugin's manifest declares under capabilities.secrets.

record pbkdf2

PBKDF2-HMAC-SHA512 over the running value.

Field Type Meaning
salt list<u8> The provider's salt, as the provider sent it.
rounds u32 Iterations. The host refuses anything below its floor.
length u32 How many bytes of output the next step works on. Bounded by the host.

record span

A window onto the running value.

Field Type Meaning
offset u32
length u32

variant step

One stage of a derivation.

Each step's output is the next step's input; the first step's input is the credential behind the handle, and only the last step's output is returned.

Case Payload Meaning
pbkdf2-hmac-sha512 pbkdf2 PBKDF2-HMAC-SHA512 over the running value, with the provider's salt and rounds.
aes-ecb-decrypt list<u8> AES-128-ECB, decrypting these bytes under the first sixteen bytes of the running value. How a provider hands a wrapped key over: the running value is the key and the wrapped bytes are what the guest already holds in the clear. The key width is not in the name because a WIT identifier has no segment that begins with a digit; it is sixteen bytes, always.
take span Narrows the running value. The step that makes one half of a derivation sendable while the other half stays on the host.

Interface auth

Provider-specific authentication flows.

The plugin never sees a credential. It is handed an opaque reference and asks the host to use it; what comes back is a state, never a token.

Uses failure from types.

  • begin: func(account-id: string, credential-ref: option<string>) -> result<auth-state, failure>

    Starts a flow for one account. credential-ref is opaque and stays that way.

  • poll: func(account-id: string, flow-state: option<string>) -> result<auth-state, failure>

    Continues a flow the host started earlier, with the bookkeeping begin handed over.

variant auth-state

Case Payload Meaning
authorized Authentication succeeded; the host stores whatever the flow produced.
user-action user-prompt The user has to visit a URL and confirm, then the host polls again.
pending u64 Still waiting; retry after this many seconds.
failed failure

record user-prompt

Field Type Meaning
verification-url string
user-code option<string>
expires-in-seconds option<u64>
flow-state option<string> Whatever the plugin needs on the next poll — a device code, a PIN check value. The host stores it verbatim and hands it back, because a guest is instantiated fresh for every call and remembers nothing of its own. It is not a credential and is never shown; a plugin that put one here would be storing it somewhere it does not belong.

Interface oauth

OAuth, and the refresh that keeps it alive.

Separate from auth on purpose. auth covers the flows that ask nothing of a token after it was granted, and its auth-state has no way to say that one dies in an hour. Teaching auth the difference would mean adding an export, and every plugin already exporting auth would stop satisfying its world the moment the host learned to ask for it. So the renewable flows get an interface and a world of their own, and the three shipped auth plugins are left exactly as they are.

Two ways in, one way on. begin/poll run a redirect; device-begin/ device-poll run a device code, for the providers whose sign-in is a short code typed on another screen. They meet at refresh: whichever entrance a person came through, the renewal that follows is the same one, and that is the whole reason the device path lives here rather than in auth. Which entrances a plugin actually serves is stated in its manifest (oauth_flows), so the host never calls one that was not offered.

The plugin still never sees a credential. It is handed an opaque reference, and what comes back from a call is a state, never a token.

Uses failure from types.

  • begin: func(account-id: string, credential-ref: option<string>) -> result<authorization-request, failure>

    Builds the address to send the person to, with the challenge already in it.

  • poll: func(account-id: string, code: string, flow-state: option<string>) -> result<token-outcome, failure>

    Exchanges the code the callback carried, using the bookkeeping begin handed over.

  • device-begin: func(account-id: string, credential-ref: option<string>) -> result<device-authorization, failure>

    Asks the provider for a device code and reports what the person has to be shown.

    The host only calls this on a plugin whose manifest offers device. A plugin that serves the redirect alone still has to export it — the world says so — and answers with a stable code rather than a trap.

  • device-poll: func(account-id: string, flow-state: option<string>) -> result<token-outcome, failure>

    Asks whether the person has confirmed yet, with the bookkeeping device-begin handed over. authorization-pending and slow-down are waiting, not failure: both come back as pending, and only a refusal the provider actually made becomes failed.

  • refresh: func(account-id: string, credential-ref: option<string>) -> result<token-outcome, failure>

    Mints a new access token from the stored refresh material. Driven by the host's sweep, never by the person — the point of it is that nobody is asked twice. The one call both entrances share, and the reason they share an interface.

variant token-outcome

Case Payload Meaning
authorized The exchange succeeded and the host has stored what it produced.
pending u64 Not finished yet; ask again after this many seconds.
failed failure

record authorization-request

Field Type Meaning
authorization-url string Where the person has to go. The host shows this; it never opens it by itself.
state string The value the host must find in the callback before it believes a word of it.
expires-in-seconds option<u64>
flow-state option<string> The plugin's own bookkeeping for the exchange — the PKCE verifier belongs here. Stored verbatim and handed back, because a guest is instantiated fresh for every call and remembers nothing of its own. It is never shown and never serialised out of the API.

record device-authorization

What a device flow puts in front of the person.

The counterpart of authorization-request for the entrance that has no redirect: there is no callback, so there is no state to echo, and what takes its place is the device code the plugin keeps in flow-state and polls with.

Field Type Meaning
verification-url string Where the person has to go. The host shows this and never opens it by itself, and it is held to the same rule as an authorization address: it must be on a domain the manifest declares, or the host refuses it.
user-code option<string> The short code the person types there. Absent when the provider put the code into verification-url itself and asks for nothing to be typed.
expires-in-seconds option<u64>
interval-seconds option<u64> How long the provider asked to be left alone between polls. The host treats it as a floor, never as permission to ask faster.
flow-state option<string> The plugin's own bookkeeping for the exchange — the device code belongs here. Stored verbatim and handed back, because a guest is instantiated fresh for every call and remembers nothing of its own. It is never shown and never serialised out of the API.

Interface enricher

Additional metadata for a link or a queued item.

Uses failure from types.

  • enrich: func(subject: enrich-subject) -> result<list<enrich-field>, failure>

    Returns additional fields only. The host never lets these overwrite a core field; a name that collides with one is dropped and reported.

record enrich-subject

Field Type Meaning
url string
file-name option<string>
known option<string> Normalised media metadata the core already resolved, as JSON.

record enrich-field

Field Type Meaning
name string
value string

Interface destination-settings

The settings a notification target was saved with.

Additive (it came within contract 0.9.0): an interface of its own, imported by notifier-plugin only, so a destination built before it existed links unchanged and the notification record keeps its shape. What a destination may be set to is declared in its manifest ([[extension.settings]], a name and the values it accepts); the host refuses anything else when the target is saved, so what arrives here is always one of those values. A setting that declares no values is free text since contract 0.11.0: one line, or several where the manifest says multiline, within the host's length limit and without control characters other than the line breaks; the plugin reads it as written.

  • setting: func(name: string) -> option<string>

    The value of the declared setting name for the delivery in progress: what the person chose, else the manifest's default. none when neither exists -- a setting left unset on purpose, or a name the manifest does not declare.

Interface notifier

A notification destination.

Uses failure from types.

  • deliver: func(message: notification) -> result<_, failure>

    Delivers one notification. Retrying is the host's decision, driven by the failure kind.

    A destination's token is written as {{secret}} in a header, a query value or the body: the host substitutes the one secret this invocation was granted on the way out, so the plugin never holds it and never names which one it is.

record notification

Field Type Meaning
title string
body string
event string Stable event name, e.g. package.completed.
severity string
idempotency-key string Derived key; a destination that can deduplicate should use it.
destination string Where this destination sends: a topic, a channel, a chat id — whatever the person configured. Never a secret; a token belongs in the vault and reaches the request as {{secret}} instead.
has-secret bool Whether a secret is stored for this destination. Not the secret, and not a reference to it: only whether there is one, so a plugin whose service takes an optional token can leave the header off instead of sending an empty one.
package-path option<string> The folder of the finished package, as this service sees it, since contract 0.11.0: set for package_completed only, none for every other event and for a test message. Somebody else wrote its last segments -- a release name -- so its braces are made inert like those of title and body; a path that carried one is no longer the folder on disk, and a plugin that maps it onto another server should not use it.

Interface postprocess

One step in the post-processing pipeline.

Uses failure, label-part from types.

  • run: func(input: step-input) -> step-end

record step-input

Field Type Meaning
handle string Package-scoped handle. Not a path: the plugin cannot name a file of its own.
files list<string> Files in the package, relative to its directory, / between folders.
removed list<string> Files the package had that the pipeline removed before this step ran, named like files: archive volumes once they are unpacked, PAR2 files once the repair is through, what the cleanup deleted. None of them can be read; a checksum list that names one is not wrong for it, and the step need not guess from a file's extension.
checkpoint option<list<u8>> The checkpoint this step wrote when it last stopped, if any.

record step-complete

How a step that finished ends.

Field Type Meaning
checkpoint option<list<u8>> An optional final checkpoint the host stores.
warnings list<label-part> What the person should know although the step passed, such as listed files it could not check. Shown on the step and translated like failure, with <provider.slug>.* codes from the plugin's own catalogue; never a failure. Empty for a plain pass.

variant step-end

Case Payload Meaning
complete step-complete Finished, with what the host keeps and shows of it.
stopped list<u8> Interrupted with a checkpoint to resume from.
skipped Nothing to do for this package, which is not a failure.
failed failure

Interface storage

An upload or storage destination.

Uses failure from types.

  • put: func(job: upload-job) -> upload-end

    Reads the next chunk of the file being uploaded. The host owns the file.

  • verify: func(handle: string, remote-id: string) -> result<bool, failure>

    Confirms the destination holds the object at the expected size. The host deletes nothing until this says so.

record upload-job

Field Type Meaning
handle string Package-scoped handle; the plugin reads through the host, never by path.
file-name string
size u64
destination string Where this destination writes, as configured — for a WebDAV target the collection's address. Never a secret: a password belongs in the vault and reaches the request as {{secret}}, which the host substitutes on the way out.
username option<string> The stored login's user name, when the destination has one. Not a secret, and shown in the interface next to the address it belongs to.
credential-ref option<string> Opaque handle of the stored credential, and nothing more: some says a password was found for this destination and {{secret}} will expand, none says it was not.
checkpoint option<list<u8>>

variant upload-end

Case Payload Meaning
complete option<string> Uploaded and verified by the plugin's own destination check.
stopped list<u8>
failed failure

Interface stream-transform

A stream the host has to transform before it is a file.

Some providers encrypt every file on the client and keep the key out of their own reach: it rides in a link fragment, the storage server never sees it, and what a plain GET returns is ciphertext. resolver cannot carry that -- resolved-download is an address and headers, with no field key material could travel in, and a header would be logged and replayed on every chunk request.

So the key travels here, and the bytes never leave the host. A plugin answers with the address and a declarative description of the transform: a named primitive with its parameters. The host owns the byte stream, applies the primitive on its own write path -- no second pass, no second copy on disk -- and checks the provider's integrity value before the part file is promoted. docs/adr/0011-mega-a-stream-the-host-must-decrypt.md records why the alternatives (a transfer backend, a post-processing step, a new field on resolved-download) were refused.

The rule that makes this safe to review: the plugin describes, the host computes. What crosses the boundary is parameters for primitives this repository implements, never code and never a callback per buffer. A primitive the host does not know is a refusal when the description is taken in, not a fallback -- so which ciphers exist in the product stays a decision of this repository and not of a signed blob.

Uses failure, resolve-request, resolved-download from types.

  • claims-url: func(url: string) -> bool

    Whether this plugin claims url. Asked first, so a plugin that has nothing to do with an address never fetches it.

  • resolve: func(request: resolve-request) -> result<transformed-download, failure>

    Answers with the address and how its bytes must be treated.

record stream-cipher

The cipher the host applies to every byte it writes.

algorithm is a name rather than a closed variant so the set can grow without a contract change, and an older host refuses a name it does not know instead of guessing. One name exists today: aes-128-ctr.

Field Type Meaning
algorithm string aes-128-ctr: AES-128 in counter mode, counter block nonce followed by a big-endian u64 block index. The index of the file's first plaintext byte is first-block and grows by one every 16 bytes, so every 16-byte boundary decrypts on its own, with no preceding byte -- which is what keeps ranges, resume and parallel chunks working exactly as they do for an ordinary download.
key list<u8> The raw key. It is a secret from the moment this call returns: the host puts it in the vault, keeps a reference, and excludes it from every surface that prints an address or a header. It is never written into a download row and never logged.
nonce list<u8> The counter prefix. Exactly 8 bytes for aes-128-ctr.
first-block u64 Block index the first plaintext byte of the file sits at; 0 for a file encrypted from its start.

record stream-integrity

How the host proves the plaintext it wrote is what the provider stored.

Without this a wrong key is indistinguishable from a correct download: the length matches, the file is named correctly, and every byte of it is rubbish.

Field Type Meaning
algorithm string cbc-mac-chain: one CBC-MAC per chunk over the plaintext (zero-padded to 16 bytes, keyed with the cipher's key, each starting from iv), then a second CBC-MAC over those chunk MACs in order under the same key and a zero IV, then the 16 result bytes folded to 8 as m[0..4] xor m[4..8] followed by m[8..12] xor m[12..16].
boundaries list<u64> Where one chunk ends and the next begins, as absolute plaintext offsets, strictly ascending, the last one the file's size. The MAC is sequential within a chunk and independent between chunks, so these are also the boundaries at which the host may still split the download across parallel connections. The host bounds how many it accepts.
iv list<u8> The 16 bytes every chunk MAC starts from.
expected list<u8> The expected condensed value, byte for byte. 8 bytes for cbc-mac-chain.

record content-transform

Field Type Meaning
cipher stream-cipher
integrity option<stream-integrity> Absent when the provider publishes nothing to check the plaintext against. The host then downloads and transforms, and says so; it does not invent a check.

record transformed-download

Field Type Meaning
download resolved-download The address, exactly as resolver would have answered it. Short-lived addresses are no problem: the scheduler re-asks before it resumes, and gets a fresh description with it.
transform content-transform

Related pages

Clone this wiki locally