Skip to content

The manifest

A plugin is one plugin.toml at the root of its repository or directory. A source declaring two plugins is refused.

The reference below is not written here. It is read out of contract/plugin-manifest.schema.json at the revision this site pins — the schema the binary is generated from and validates against, and the one lemonfiber plugin schema prints for your editor. It starts at the root, PluginManifest, and every type a field names links to its own entry. The normative prose, with the reasons, is the plugin manifest contract.

What the reader refuses that a schema cannot say

Section titled “What the reader refuses that a schema cannot say”

A manifest can match the schema and still be refused: an id with a character that is not a lower-case letter, a digit or a hyphen, a port with no bind, a hostname on a service nobody proxies, a [requires] naming something this build does not offer. The rules, each with its reason, are the contract’s validation, and the fields a manifest has no room for at all — environment, kernel capabilities, devices, privilege, a fragment of Compose — are the fields that are absent, and why. Neither is restated here, because the specification says the schema is the only description of the format published to authors (F10-R2), and a second list is one more to fall behind.

The way to know what this build refuses is to ask it. lemonfiber plugin claims <path> reads your manifest the way install will, and reports every violation in one pass rather than stopping at the first. A refusal is total: nothing of a refused manifest is acted on, and PLUGIN-3 lists everything that was wrong with it.

PluginManifest

A whole plugin manifest.

FieldTypeRequiredWhat it is
claimarray ofClaimno

The core capabilities this plugin claims, and the probes each is shown by.

contributionarray ofContributionno

The rows it adds at the points lemonfiber publishes.

overridearray ofOverrideno

Every bundled thing it will change.

pluginPluginyes

Who the plugin is, and where it came from.

proofarray ofProofno

What must hold before it is installed.

recipearray ofRecipeno

The ordered calls that configure what it installed.

requiresRequiresornullno

What the plugin needs of lemonfiber.

schema_versionintegeruint32, ≥ 0yes

The manifest format generation.

secretarray ofSecretno

Every value it will hold.

servicearray ofPluginServiceno

What runs. One or more.

More than one because a plugin is often a thing and the thing beside it: Plex and the reader of its watch history are one install and one uninstall to an operator, and are two containers on two tiers with two criticalities. A format permitting one forces the author of the pair to choose the wider tier for both halves, or to publish two plugins an operator has to keep in step by hand.

wiringarray ofWiringno

How the stack's own proxy and dashboard reach its services. One each, at most.

Api

How lemonfiber talks to a service when seeding.

The same shape on a plugin's service as on the stack's own, which is what lets a plugin name one of these adapters rather than supply one of its own.

FieldTypeRequiredWhat it is
key_sourceKeySourceyes

Where the credential comes from.

kindApiKindyes

Selects the client implementation.

pathstringornullno

The file holding the credential, where one applies.

versionintegerornulluint32, ≥ 0no

The major version of the service's HTTP API — the /api/vN path segment.

Required for the servarr shape and read there, because that one shape spans two versions (Sonarr and Radarr at v3, Lidarr and Prowlarr at v1), so the version is data rather than a guess from a service's name. Absent for the other kinds, whose one fixed version their client already knows.

ApiKind

The API shapes lemonfiber knows how to speak.

Four services share the servarr shape, which is what makes one client enough for them. Bindery is its own kind deliberately: it is not a Servarr application and Prowlarr's app sync does not reach it. Bazarr is its own for the neighbouring reason: it is told about the *arrs rather than being one of them, in a form body a client of the shared shape could not send.

One of these:

  • "servarr"

    Sonarr, Radarr, Lidarr and Prowlarr.

  • "sabnzbd"

    SABnzbd.

  • "qbittorrent"

    qBittorrent's WebUI API.

  • "seerr"

    Seerr.

  • "bindery"

    Bindery.

  • "jellyfin"

    Jellyfin — a media server whose account lemonfiber creates rather than a key it reads, so it has a key_source of generated.

  • "bazarr"

    Bazarr — the subtitle finder, which is told which *arrs to watch.

  • "audiobookshelf"

    Audiobookshelf — a listening server whose first account lemonfiber creates, like Jellyfin's, so its key_source is generated too. The token it hands back on sign-in is stable, so it is read again rather than recorded twice.

  • "nzbhydra2"

    NZBHydra2 — the Usenet indexer aggregator, which starts with no authentication, so lemonfiber turns it on with an administrator it names and a password it mints.

Capture

A value taken out of an answer.

FieldTypeRequiredWhat it is
fromstringyes

Where in the answer it is read from: an expectation key, or header.<name>.

namestringyes

What the value is called, for a later call to name.

originPluginValueOriginyes

Whose value it is: the service in this stack or the host outside it that answered.

Claim

The core capability a plugin claims, and the probes it is demonstrated by.

The vocabulary owns what must be shown; this owns where to ask. A capability is one contract with many claimants, each answering at a path of its own, so the published probe declares the question and the statuses that answer it, and the binding declares the method, the path and the recorded response.

FieldTypeRequiredWhat it is
capabilitystringyes

A core name, which must also appear in the service's provides.

probearray ofClaimProbeno

One binding per probe the capability declares. Every one of them, exactly once.

ClaimProbe

One probe of a claimed capability, bound to a request on this plugin's service.

FieldTypeRequiredWhat it is
expectExpectyes

What the answer must be, within what the probe permits.

fixturestringyes

The recorded response.

Required here where it is optional on a proof: a claim nobody can demonstrate without owning the service is a claim the catalogue's own checks cannot make.

idstringyes

Names a probe the capability declares.

requestPluginRequestyes

Method and path on this plugin's own service.

Contribution

A row in a register lemonfiber already runs.

The fields beyond at and id are the row its point declares, and the point is published — so the required set, the optional set, the closed sets and the bounds are read from extension-points.json rather than restated here. What this type fixes is that a contribution is declared in this block and nowhere else, and that it carries nothing outside the union of the rows the published points take.

FieldTypeRequiredWhat it is
actionstringornullno

What to do, in the imperative.

atstringyes

A point the build publishes. One it does not is refused by name.

categorystringornullno

Which family a check is narrowed to.

detailstringornullno

The technical half of a remedy, which must not lead.

expectExpectornullno

What the answer must be.

expectedarray ofPluginExpectedFailureno

Recordings the check fails on, each with the constraint that fails there and why.

fixturestringornullno

The recorded response the check is proved against.

forstringornullno

The check a remedy is for, which must be one this same plugin declared.

idstringyes

Namespaced with the declaring plugin's id, always.

requestPluginRequestornullno

Method and path on the plugin's own service.

There is no field for a host, so a check cannot be pointed at another service, at the machine, or off it. A plugin wanting to say something about a service it did not install is asking to speak for somebody else's software.

servicestringornullno

Which service the finding is about. Defaults to the plugin's own.

timeout_sintegerornulluint32, ≥ 0no

How long a check may run, within the bounds the point declares.

titlestringornullno

The one-line summary of what was checked.

whystringornullno

Why this is worth checking.

Expect

What the answer has to be.

A status is a claim about the network path rather than about the service: Docker publishes a port by putting a proxy in front of it, and that proxy accepts a connection before knowing whether anything inside is listening. So a status alone is not evidence — except for a refusal, which is the one answer no port proxy can produce.

A key of the four key-wise constraints is a place rather than a name. A plain name is a top-level member, and one beginning with / is a JSON Pointer, extended with a step that picks an entry of a list by a field it holds. A flat name was enough while every service answered a flat object, and the only thing it could say about a service that nests its payload was that the envelope was there — which is a probe that passes by observing that something replied.

FieldTypeRequiredWhat it is
body_starts_withstringornullno

What the body must begin with, where it is not JSON.

content_typestringornullno

A substring of the content type the answer was served as.

jsonmap ofExpectedornullno

Places the answer must carry, each with the exact value it must hold.

json_array_minintegerornulluint64, ≥ 0no

The answer read as an array, with at least this many entries.

A catalogue is very often a list rather than an object, and none of the object-shaped constraints can say anything about one.

json_at_leastmap ofintegerint64ornullno

Places the answer must carry, each with a number it must not be below.

json_has_keysarray ofstringornullno

Places the answer must carry, whatever they hold.

json_is_absentbooleanornullno

The answer did not parse as JSON at all.

Which is what a service that serves its application shell for every path it does not implement answers — and the reason a status alone proves nothing against one: the shell comes back 200 whether the API behind it exists or not, so what has to be said is that the body was not a document.

json_typesmap ofExpectedKindornullno

Places the answer must carry, each with the kind of value it must be.

statusintegerornulluint16, ≥ 0, ≤ 65535no

The status the answer must carry.

Expected

Exactly what a place must hold.

Three kinds and no nesting: a flag that must be set, a number that must match, or a word. A value deeper than this is asking about a document rather than about a claim — and where the thing worth asserting is deeper in the answer, the key reaches it rather than the value growing to match.

It is booleanorintegerint64orstring

ExpectedKind

The kind of value a key must hold.

Five, and closed. A name outside them is one no runner could evaluate, and an assertion nothing evaluates is a proof that silently checks less than it says — which is worse than one that fails.

Named for what it is the kind of, rather than Kind, because this is published and whoever generates from it flattens every definition into one scope. Kind is the one name there a generator is certain to want for itself: every envelope this contract describes is keyed by its kind, so the union of them is a Kind too, and two of them in one module is a definition nothing can be compiled against.

One of these:

  • "bool"

    A true or a false.

  • "int"

    A whole number.

  • "str"

    A string.

  • "list"

    An array.

  • "dict"

    An object.

Input

A value a recipe brings in from outside an answer.

FieldTypeRequiredWhat it is
askstringornullno

What the operator is asked, in one line, where the operator supplies it.

namestringyes

What the value is called, for a call to name.

ofstringornullno

The service whose credential it is, where the credential store holds it.

originPluginValueOriginyes

Whose value it is: the credential store, or the operator.

KeySource

Where a service's credential comes from.

One of these:

  • "config-xml"

    The service writes it to an XML file lemonfiber reads.

  • "config-ini"

    The service writes it to an INI file lemonfiber reads.

  • "config-json"

    The service writes it to a JSON file lemonfiber reads.

  • "config-yaml"

    The service writes it to a YAML file lemonfiber reads.

  • "api-settings"

    Retrieved over the service's own API once authenticated.

  • "generated"

    The service offers nothing durable, so lemonfiber mints and records one.

  • "none"

    The API needs no credential.

Override

A bundled thing the plugin will change.

FieldTypeRequiredWhat it is
idstringyes

Which bundled setting is changed.

whystringyes

What changing it is for.

Pair

One value, and the one destination it may reach.

FieldTypeRequiredWhat it is
tostringyes

Where it may be carried.

valuestringyes

The value, by the name its capture or its input gives it.

Plugin

Who the plugin is, and where it came from.

FieldTypeRequiredWhat it is
descriptionstringyes

What it does for the operator.

formsarray ofstringyes

Which forms the service joins.

idstringyes

Unique, lowercase. The name it is installed and journalled under.

licensestringyes

SPDX identifier.

Recorded and shown rather than constrained. The bundled set is a curated list this project stands behind and is OSI-licensed throughout; a plugin is the operator's own choice, and refusing to install proprietary software on somebody else's machine would be the tool standing between an operator and their stack. A plugin whose licence is absent is refused; one whose licence is merely not open is installed and said so.

namestringyes

Human-facing.

upstreamstringyes

Project URL, so the operator can judge the thing rather than the wrapper.

versionstringyes

Semver. The plugin's own content version, moved by its author.

without_itstringyes

The consequence of its absence.

PluginBind

Which tier a plugin's port is published on.

A tier, not an address. lemonfiber renders it to an address exactly as it does for a bundled service, so the two-tier policy stays a property of the system rather than a request the plugin makes.

One of these:

  • "loopback"

    Reachable only from the host.

  • "lan"

    Reachable from the local network.

PluginConstraint

A kind of constraint an expectation can put on a body.

The same vocabulary a proof's expectation and a contributed check's use, so that "a status alone is not evidence" is one rule rather than three. Read as well as written: a declaration that an assertion fails on a recording names the one of these that fails there.

One of these:

  • "status"

    The status alone.

  • "json"

    Keys carrying exactly these values.

  • "json_has_keys"

    Keys that must be present, whatever they hold.

  • "json_types"

    Keys that must hold a given kind of thing.

  • "json_at_least"

    Keys that must hold at least a given number.

  • "json_array_min"

    The answer read as an array, with at least a given number of entries.

  • "json_is_absent"

    The answer did not parse as JSON at all.

  • "content_type"

    The content type it is served as.

  • "body_starts_with"

    What the body must begin with.

PluginCriticality

How much a plugin's service costs when it is absent.

Four rather than the stack's five. critical means its failure has consequences outside the machine, and in the bundled stack exactly one service holds it; it is not a severity a contributor assigns to their own work, because the classification drives how failures are reported and how hard lemonfiber tries to stop the operator proceeding. A manifest declaring it is refused by name, with these four listed.

One of these:

  • "core"

    The form does not work without it.

  • "important"

    The form works, badly.

  • "enhancing"

    Makes things better.

  • "optional"

    Nice to have.

PluginDeclaredVerdict

The one verdict a declaration may name.

One of these:

  • "fails"

    The assertion fails on the recording, on the constraint the declaration names.

PluginExpectedFailure

A recording an assertion fails on, the one constraint of its expectation that fails there, and why.

For an assertion whose passing state nobody can record, such as a check that a server has an owner where claiming one needs an account the plugin's CI does not hold: the state it exists to find can be recorded, and the declaration says which constraint tells the two states apart. It changes the verdict on that recording and on nothing else — the live service and every other recording are held to the expectation as it is written.

It names a constraint rather than only a recording, because a recording that began failing for another reason — a truncated file, an error recorded by mistake — would otherwise read as failing as declared, excusing a failure nobody had looked at.

FieldTypeRequiredWhat it is
constraintPluginConstraintyes

The key of the expectation that fails on the recording, and one it carries.

fixturestringyes

The recording the assertion fails on. It may be the assertion's own fixture.

placestringornullno

Where within that constraint it fails, written exactly as the expectation writes it. Present for a key-wise constraint and absent for one about the whole answer.

reasonstringyes

Why this recording is one the assertion fails on. Reported with the verdict every time.

verdictPluginDeclaredVerdictyes

fails, and nothing else: passing is what the expectation already says, and could-not-run is never excused.

PluginHealth

How to tell a plugin's service has started.

FieldTypeRequiredWhat it is
kindPluginHealthKindyes

Which kind of probe to use.

pathstringornullno

HTTP path, for http probes.

timeout_sintegerornulluint32, ≥ 0no

How long to wait before calling it unhealthy.

PluginHealthKind

The kinds of health probe a plugin's service can declare.

One of these:

  • "http"

    Fetch a path on the service's port.

  • "tcp"

    Open a connection to the service's port.

  • "container"

    Trust the container's own reported state.

PluginRecipeCondition

What an earlier step or value must have come to.

One of two shapes, and a condition carrying neither or both is refused when the manifest is read: an earlier step answered a status, or an earlier value holds a text exactly.

FieldTypeRequiredWhat it is
equalsstringornullno

The text it must hold, exactly.

statusintegerornulluint16, ≥ 0, ≤ 65535no

The status that step must have answered.

stepstringornullno

The earlier step whose answer is asked about.

valuestringornullno

The earlier capture or input asked about.

PluginRecipeOn

When a recipe runs.

One of these:

  • "install"

    During an install and an update, after the plugin's proofs and the stack's checks hold and before the install is recorded. What a recipe that says nothing does, because a first-run flow is what a recipe is for.

  • "demand"

    Only when the operator asks for it by name, with approvals of its own.

PluginRecipeRetry

Making a step again until its answer is the one wanted.

FieldTypeRequiredWhat it is
everystringyes

How long it waits between two, written as whole seconds, 6s, and no longer than [LONGEST_WAIT].

timesintegeruint32, ≥ 0yes

How many times it is made again, at most: no more than [MOST_RETRIES].

untilPluginRecipeConditionyes

What the answer must come to for it to stop: a status, or a value it captured.

PluginRequest

What is asked, and where.

Three fields and no more, and the third is the one worth explaining. A service that answers XML unless a caller asks for JSON cannot satisfy a capability whose probe requires a JSON assertion, and until this field there was nowhere to ask: Plex answers text/xml at every path, including the one its health probe uses, unless the request carries Accept: application/json.

It is one media type and not a header map, and the difference is the point. A probe declares who it is asked as, and none on every guarded probe has meant what it says partly because nothing could be presented. A map of headers would make that a convention a reviewer has to hold — any service may name its credential header whatever it likes, so no list of refused names could ever be closed — where one named field keeps it a property of the format. A probe still cannot present anything, because there is nowhere to write it.

FieldTypeRequiredWhat it is
acceptstringornullno

The one representation the answer is asked for, as a media type.

Absent where the service needs no asking, which is most of them.

methodstringyes

The HTTP method.

pathstringyes

The path on the service being asked.

PluginService

What runs.

Declared in the stack manifest's service vocabulary, restricted to these fields. The restriction is the substance of it: the set of fields is the set of things a plugin may ask for, so "what can this plugin reach" is answerable from the format rather than from the instance.

FieldTypeRequiredWhat it is
apiApiornullno

The adapter lemonfiber reaches it through, in the stack manifest's own shape.

Named from a fixed set rather than supplied: a plugin cannot bring an adapter of its own, and naming one is what lets the services that ask for a capability it claims be told about it as they are told about a bundled service declaring the same. Absent, it is operated generically.

bindPluginBindornullno

Which tier the port is published on.

config_pathstringornullno

Where inside the container the one configuration directory is mounted.

/config is a LinuxServer.io convention rather than a standard, and assuming it made a whole class of image uninstallable: a plugin whose image reads its configuration somewhere else would be generated a container mounting a directory the application never reads, passing its health probe and losing everything it had written the moment the container was replaced.

The target is data; the source is still lemonfiber's, and there is still exactly one of it.

criticalityPluginCriticalityyes

How much its absence costs.

digeststringyes

sha256:…. What actually runs.

A tag is not a pin: it is a name the publisher can repoint, so the image reviewed and the image run can differ with nothing in the manifest changing. The digest is what lemonfiber writes into the generated entry.

healthPluginHealthornullno

How to tell the service has actually started.

idstringyes

Unique across the stack and every installed plugin.

imagestringyes

Registry path, without tag or digest.

listensintegerornulluint16, ≥ 0, ≤ 65535no

The port it answers on inside the stack's network, where lemonfiber and the services that ask reach it. Required where api is.

media_typesarray ofstringno

Which media types it handles, in the stack manifest's vocabulary.

namestringyes

Human-facing.

portintegerornulluint16, ≥ 0, ≤ 65535no

Primary UI or API port, absent for a service with no listener.

providesarray ofstringno

The capabilities this service claims.

A core name comes from the published vocabulary and means the contracted thing that vocabulary defines; a plugin's own must be namespaced with the plugin's id and is inert until something asks for it.

tagstringyes

The human-readable version the digest corresponds to. Recorded and shown; never resolved.

takes_databooleanno

Whether it needs the data root mounted.

PluginValueOrigin

Where a value a recipe carries comes from.

Four, and no fifth: a service in this stack and a host outside it each answer a call, and lemonfiber's credential store and the operator each supply a value no call answered. Written by the manifest, and held to where the value really comes from.

One of these:

  • "stack-service"

    The answer of a call to a service in this stack.

  • "external-response"

    The answer of a call to a host outside it.

  • "credential-store"

    A credential lemonfiber already holds for a service.

  • "operator"

    A value the operator supplies when the recipe runs.

Proof

What must hold before a plugin is installed.

FieldTypeRequiredWhat it is
expectExpectyes

What the answer must be.

expectedarray ofPluginExpectedFailureno

Recordings this proof fails on, each with the constraint that fails there and why.

fixturestringornullno

A recorded response to run against where no instance exists.

idstringyes

Unique within the plugin. What a verdict is reported against.

requestPluginRequestyes

Method and path.

servicestringornullno

Which of this plugin's services is asked.

Optional where the plugin declares one, required where it declares more. A proof gates the install of every service the plugin brings, and one that did not say which it was about would be checked against whichever the reader reached first — including its digest, which is what ties a recording to the image it came out of.

titlestringyes

What it establishes, in one line.

whystringyes

Why this is worth asserting. A proof nobody can justify is one nobody will maintain.

Recipe

One named flow of calls.

FieldTypeRequiredWhat it is
idstringyes

Unique within the plugin.

inputarray ofInputno

The values it brings in from outside an answer: a credential lemonfiber holds, or one the operator supplies when it runs.

onPluginRecipeOnno

When it runs: as part of installing the plugin, or when it is asked for.

pairarray ofPairno

Every value this flow could carry to every destination it could reach.

A flow with no pair behind it fails validation before a call is made, which is what makes "what does this plugin tell whom" answerable from the manifest.

steparray ofStepno

The calls, in the order they are made.

titlestringyes

What the flow accomplishes, in one line.

whystringyes

Why it is worth running.

Requires

What the plugin needs of lemonfiber.

Named capabilities and never a version. A plugin written a year ago against a stack it has never met keeps working for exactly as long as the things it actually uses still exist, and when it stops working the message says which thing went — a version number cannot say that, because it conflates "older" with "missing something you needed".

FieldTypeRequiredWhat it is
capabilitiesarray ofstringno

What lemonfiber must offer for this plugin to be installable.

Secret

A value the plugin will hold.

Declared before anything can capture one, because the rule is that a secret captured but not declared fails validation — and a rule that cannot be stated for want of a field is not being enforced.

FieldTypeRequiredWhat it is
idstringyes

What the value is, within the plugin.

ofstringyes

Whose credential it is.

whystringyes

What holding it is for.

Step

One call in a flow.

FieldTypeRequiredWhat it is
callStepCallyes

What is called, and where.

capturearray ofCaptureno

The values taken out of the answer, for a later call to substitute.

expectExpectornullno

What the answer must be for the flow to go on.

idstringyes

Unique within the recipe.

retryPluginRecipeRetryornullno

How often the step is made again before its answer is taken as final.

whenPluginRecipeConditionornullno

What must hold for the step to be made; where it does not, the step is skipped.

StepCall

What one step calls.

headers and body are where an earlier capture is substituted, and they are the whole of it. Without somewhere to put one, a recipe could name a destination and capture a value and had no way to carry the one to the other — which is every first-run flow there is: creating an account and reading back a token is worth nothing if the token cannot then be presented.

They are also what makes the set of flows a recipe could produce computable by reading it: every substitution inside a call is a flow from that value to that call's destination, and a call with nowhere to substitute into produces none.

FieldTypeRequiredWhat it is
bodystringornullno

The body the call carries. A value may substitute an earlier capture.

headersmap ofstringornullno

Headers the call carries. A value may substitute an earlier capture.

methodstringyes

The HTTP method.

pathstringyes

The path on it, written out. Never a substitution.

tostringyes

A service id in this stack, or a DNS name outside it. Never an address, and never a substitution.

Wiring

How the stack's own services reach a plugin's service.

The tier governs, not the plugin. Only a lan service is proxied, because the bundled policy is that an admin surface does not get a hostname — and a plugin that could publish its own address could put an admin surface on the household network without touching anything the web-security checks inspect.

FieldTypeRequiredWhat it is
dashboard_groupstringornullno

Which group on the bundled dashboard it appears under. Defaults to the group the stack uses for its tier.

hostnamestringornullno

The label in front of the operator's domain. A single DNS label — not a name, an address or a port. Defaults to the service's id.

servicestringornullno

Which of this plugin's services it is about.

Optional where the plugin declares one service, because there is nothing to choose between; required where it declares more, because a hostname is a fact about one service and a manifest that left it to be inferred would be inferring which of two the household reaches.

The template every plugin starts from, as its repository holds it at the revision this site pins. Its own comments explain each block; the repository is the plugin template.

plugin.toml, from plugin-template
#:schema https://github.com/lemonfiber/lemonfiber/releases/download/v0.16.0/plugin-manifest.schema.json
# The template plugin. Copy this repository, replace the service, keep the shape.
#
# Contract: https://github.com/lemonfiber/spec/blob/main/20-architecture/contracts/plugin-manifest.md
#
# Everything lemonfiber does on a plugin's behalf comes from this file, and it
# knows nothing about the plugin that is not declared here (ARCH-R83). Nothing in
# it is executable, and nothing in it can be (F3-R1, F3-R6).
#
# The service described here is real, and that is the point. F10-R7 asks for a
# template that validates and proves *unmodified*, and a template whose proofs
# are invented teaches an author to invent proofs. Every recording under
# fixtures/ came off this image at this digest.
schema_version = 1
[plugin]
id = "kavita"
name = "Kavita"
version = "1.0.0"
description = "Reads comics, manga and ebooks in a browser, and remembers where everybody got to"
without_it = "A folder of CBZ and EPUB files, opened one at a time with nothing keeping your place"
upstream = "https://github.com/Kareadita/Kavita"
license = "GPL-3.0-only"
forms = ["library", "full"]
# The two forms that serve a library rather than acquire one. Neither `books`
# nor `auto` is named: those acquire, and this acquires nothing.
[[service]]
id = "kavita"
name = "Kavita"
image = "docker.io/jvmilazz0/kavita"
# What actually runs (F3-R8, ARCH-R95). `tag` is the readable name for this
# digest and is never resolved.
digest = "sha256:b9c671586db2a6a688da3cb4b45f1319cca33b01e6e760c8bf3c19d60101bdf2"
tag = "0.8.7"
port = 5000
# A household service: the point of it is the tablet on the sofa. The tier is
# all this may say — the address is lemonfiber's to assign (ARCH-R88), and so is
# whether it is reachable by name at all (ARCH-R104).
bind = "lan"
# Measured on a warm machine with the image already pulled: it runs its database
# migrations before it opens its listener, and answered in 11s.
health = { kind = "http", path = "/api/health", timeout_s = 90 }
criticality = "enhancing"
takes_data = true
media_types = ["comics", "books"]
# This image has no /config. It keeps its database, its covers and its settings
# under /kavita/config, and a generated container mounting /config would mount a
# directory the application never reads — installing cleanly, answering its
# health probe, and losing everything the moment the container was replaced.
config_path = "/kavita/config"
# What it can do (F4-R1). A core name comes from lemonfiber's published
# vocabulary and is demonstrated by the [[claim]] below; a name of this plugin's
# own is namespaced with the plugin's id (F4-R4) and is inert until something
# asks for it.
provides = [
"media.serve",
"kavita:opds",
]
# ─── The core capability, and the probes that demonstrate it ─────────────────
#
# The vocabulary says what must be shown; this says where to ask it. A claim that
# left a probe unbound, or bound one with an expectation weaker than the probe
# permits, is refused naming the probe (ARCH-R109).
[[claim]]
capability = "media.serve"
[[claim.probe]]
id = "guarded"
request = { method = "GET", path = "/api/library/libraries" }
expect = { status = 401 }
fixture = "fixtures/media-serve-guarded.json"
# `json_array_min = 0` is the assertion "the answer reads as an array", and
# nothing about how long it is. That is deliberate: this service answers with a
# list rather than an object, so none of the key-wise constraints can say anything
# about it — and a probe demanding a *non-empty* list would refuse to install this
# on the machine of somebody who has not copied their books over yet. What a check
# may assert about the operator's own data, a probe may not.
[[claim.probe]]
id = "catalogue"
request = { method = "GET", path = "/api/library/libraries" }
expect = { status = 200, json_array_min = 0 }
fixture = "fixtures/media-serve-catalogue.json"
# A list, and one entry, because wiring is a fact about a service rather than
# about a plugin (ARCH-R127). A template declaring one service names none in its
# entry; add `service = "..."` to each the day this declares two, because a
# hostname left to be inferred is an inference about which one the household
# reaches.
[[wiring]]
# A label in front of the operator's own domain, never an address — and a word
# the household would go there for rather than the name of the software.
hostname = "reader"
dashboard_group = "Library"
[requires]
capabilities = ["service.add", "service.health.http", "doctor.contribute"]
# ─── Proofs ──────────────────────────────────────────────────────────────────
#
# What must hold before this is installed. Every one asserts a body: a status is
# a claim about the network path and not about the service, because Docker
# publishes a port by putting a proxy in front of it and that proxy accepts a
# connection before knowing whether anything inside is listening (ARCH-R105).
[[proof]]
id = "kavita.serves"
title = "Kavita answers on its declared health path"
fixture = "fixtures/api-health.json"
why = """
The health probe the manifest declares is what lifecycle waits on, so the first
proof is that the path in the manifest is a path this image serves. It answers
`Ok` as plain text rather than JSON, so this asserts the content type and the
first bytes — which is what asserting a body means where the body is not JSON.
"""
request = { method = "GET", path = "/api/health" }
expect = { status = 200, content_type = "text/plain", body_starts_with = "Ok" }
[[proof]]
id = "kavita.status-is-not-an-answer"
title = "An unimplemented path answers 200, so a status proves nothing here"
fixture = "fixtures/api-unimplemented-path.json"
why = """
Asked for a path it does not implement, this service returns 200 and its
single-page app. That is not a defect — it is how a client-routed application
serves deep links — but it means the usual shorthand for "is it up" is worthless
against this image.
Recording it is what makes the shape of every other proof here reviewable rather
than folklore, and turns a change in this behaviour into a red proof rather than
a probe that has quietly gone blind.
"""
request = { method = "GET", path = "/api/not-a-real-path" }
expect = { status = 200, content_type = "text/HTML", json_is_absent = true, body_starts_with = "<!doctype html>" }
# ─── Contributions ───────────────────────────────────────────────────────────
#
# A row in a register lemonfiber already runs, never an interpreter of its own
# (F3-R26). The doctor already runs checks independently, bounds each one, keeps
# `unverified` distinct from `pass`, and carries a remedy on anything that does
# not pass. This is another row in that register, attributed to this plugin
# wherever it appears.
[[contribution]]
at = "doctor.check"
id = "kavita:settings-guarded"
title = "Kavita's server settings are not readable by the household network"
category = "network"
timeout_s = 10
fixture = "fixtures/settings-guarded.json"
why = """
This service declares `bind = "lan"` and a hostname, which together put it in
front of every device on the household network under a name they can remember.
What makes that safe is Kavita refusing an unauthenticated read of its own
settings, and nothing else here would notice if it stopped: the health probe
would still pass and the reader would still work.
The `guarded` probe above asks a version of this question once, at install. This
asks it every time the doctor runs, which is when it matters — an upgrade, a
reconfiguration or a reverse proxy in front of it are all ways for a service to
stop refusing what it used to refuse.
"""
request = { method = "GET", path = "/api/settings" }
expect = { status = 401 }
[[contribution]]
at = "doctor.remedy"
id = "kavita:close-the-settings"
for = "kavita:settings-guarded"
action = "Stop Kavita and check what is in front of it before starting it again"
why = """
Kavita's settings are readable without signing in, which means anything on the
household network can read them — and on this network that is every phone,
television and games console in the house.
"""
detail = """
The usual cause is something in front of the service rather than the service
itself: a reverse proxy configured to strip the Authorization header, or a
second route to it that lemonfiber did not write. Kavita itself refuses this
read on a default install.
"""

Read it for its shape rather than as something to install unchanged. Its [requires] names service.add, service.health.http and doctor.contribute. The lemonfiber this site pins offers doctor.contribute and nothing else, so this build refuses the template as it stands. The disagreement is between the template and the binary, and the fix belongs in one of those repositories.