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.
Every field
Section titled “Every field”PluginManifest
A whole plugin manifest.
| Field | Type | Required | What it is |
|---|---|---|---|
claim | array ofClaim | no | The core capabilities this plugin claims, and the probes each is shown by. |
contribution | array ofContribution | no | The rows it adds at the points lemonfiber publishes. |
override | array ofOverride | no | Every bundled thing it will change. |
plugin | Plugin | yes | Who the plugin is, and where it came from. |
proof | array ofProof | no | What must hold before it is installed. |
recipe | array ofRecipe | no | The ordered calls that configure what it installed. |
requires | Requiresornull | no | What the plugin needs of lemonfiber. |
schema_version | integeruint32, ≥ 0 | yes | The manifest format generation. |
secret | array ofSecret | no | Every value it will hold. |
service | array ofPluginService | no | 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. |
wiring | array ofWiring | no | 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.
| Field | Type | Required | What it is |
|---|---|---|---|
key_source | KeySource | yes | Where the credential comes from. |
kind | ApiKind | yes | Selects the client implementation. |
path | stringornull | no | The file holding the credential, where one applies. |
version | integerornulluint32, ≥ 0 | no | The major version of the service's HTTP API — the Required for the |
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
WebUIAPI."seerr"Seerr.
"bindery"Bindery.
"jellyfin"Jellyfin — a media server whose account lemonfiber creates rather than a key it reads, so it has a
key_sourceofgenerated."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_sourceisgeneratedtoo. 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.
| Field | Type | Required | What it is |
|---|---|---|---|
from | string | yes | Where in the answer it is read from: an expectation key, or |
name | string | yes | What the value is called, for a later call to name. |
origin | PluginValueOrigin | yes | 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.
| Field | Type | Required | What it is |
|---|---|---|---|
capability | string | yes | A core name, which must also appear in the service's |
probe | array ofClaimProbe | no | 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.
| Field | Type | Required | What it is |
|---|---|---|---|
expect | Expect | yes | What the answer must be, within what the probe permits. |
fixture | string | yes | 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. |
id | string | yes | Names a probe the capability declares. |
request | PluginRequest | yes | 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.
| Field | Type | Required | What it is |
|---|---|---|---|
action | stringornull | no | What to do, in the imperative. |
at | string | yes | A point the build publishes. One it does not is refused by name. |
category | stringornull | no | Which family a check is narrowed to. |
detail | stringornull | no | The technical half of a remedy, which must not lead. |
expect | Expectornull | no | What the answer must be. |
expected | array ofPluginExpectedFailure | no | Recordings the check fails on, each with the constraint that fails there and why. |
fixture | stringornull | no | The recorded response the check is proved against. |
for | stringornull | no | The check a remedy is for, which must be one this same plugin declared. |
id | string | yes | Namespaced with the declaring plugin's id, always. |
request | PluginRequestornull | no | 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. |
service | stringornull | no | Which service the finding is about. Defaults to the plugin's own. |
timeout_s | integerornulluint32, ≥ 0 | no | How long a check may run, within the bounds the point declares. |
title | stringornull | no | The one-line summary of what was checked. |
why | stringornull | no | 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.
| Field | Type | Required | What it is |
|---|---|---|---|
body_starts_with | stringornull | no | What the body must begin with, where it is not JSON. |
content_type | stringornull | no | A substring of the content type the answer was served as. |
json | map ofExpectedornull | no | Places the answer must carry, each with the exact value it must hold. |
json_array_min | integerornulluint64, ≥ 0 | no | 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_least | map ofintegerint64ornull | no | Places the answer must carry, each with a number it must not be below. |
json_has_keys | array ofstringornull | no | Places the answer must carry, whatever they hold. |
json_is_absent | booleanornull | no | 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 |
json_types | map ofExpectedKindornull | no | Places the answer must carry, each with the kind of value it must be. |
status | integerornulluint16, ≥ 0, ≤ 65535 | no | 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.
| Field | Type | Required | What it is |
|---|---|---|---|
ask | stringornull | no | What the operator is asked, in one line, where the operator supplies it. |
name | string | yes | What the value is called, for a call to name. |
of | stringornull | no | The service whose credential it is, where the credential store holds it. |
origin | PluginValueOrigin | yes | 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.
| Field | Type | Required | What it is |
|---|---|---|---|
id | string | yes | Which bundled setting is changed. |
why | string | yes | What changing it is for. |
Pair
One value, and the one destination it may reach.
| Field | Type | Required | What it is |
|---|---|---|---|
to | string | yes | Where it may be carried. |
value | string | yes | The value, by the name its capture or its input gives it. |
Plugin
Who the plugin is, and where it came from.
| Field | Type | Required | What it is |
|---|---|---|---|
description | string | yes | What it does for the operator. |
forms | array ofstring | yes | Which forms the service joins. |
id | string | yes | Unique, lowercase. The name it is installed and journalled under. |
license | string | yes | 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. |
name | string | yes | Human-facing. |
upstream | string | yes | Project URL, so the operator can judge the thing rather than the wrapper. |
version | string | yes | Semver. The plugin's own content version, moved by its author. |
without_it | string | yes | 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.
| Field | Type | Required | What it is |
|---|---|---|---|
constraint | PluginConstraint | yes | The key of the expectation that fails on the recording, and one it carries. |
fixture | string | yes | The recording the assertion fails on. It may be the assertion's own |
place | stringornull | no | 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. |
reason | string | yes | Why this recording is one the assertion fails on. Reported with the verdict every time. |
verdict | PluginDeclaredVerdict | yes |
|
PluginHealth
How to tell a plugin's service has started.
| Field | Type | Required | What it is |
|---|---|---|---|
kind | PluginHealthKind | yes | Which kind of probe to use. |
path | stringornull | no | HTTP path, for |
timeout_s | integerornulluint32, ≥ 0 | no | 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.
| Field | Type | Required | What it is |
|---|---|---|---|
equals | stringornull | no | The text it must hold, exactly. |
status | integerornulluint16, ≥ 0, ≤ 65535 | no | The status that step must have answered. |
step | stringornull | no | The earlier step whose answer is asked about. |
value | stringornull | no | 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.
| Field | Type | Required | What it is |
|---|---|---|---|
every | string | yes | How long it waits between two, written as whole seconds, |
times | integeruint32, ≥ 0 | yes | How many times it is made again, at most: no more than [ |
until | PluginRecipeCondition | yes | 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.
| Field | Type | Required | What it is |
|---|---|---|---|
accept | stringornull | no | The one representation the answer is asked for, as a media type. Absent where the service needs no asking, which is most of them. |
method | string | yes | The HTTP method. |
path | string | yes | 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.
| Field | Type | Required | What it is |
|---|---|---|---|
api | Apiornull | no | 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. |
bind | PluginBindornull | no | Which tier the port is published on. |
config_path | stringornull | no | Where inside the container the one configuration directory is mounted.
The target is data; the source is still lemonfiber's, and there is still exactly one of it. |
criticality | PluginCriticality | yes | How much its absence costs. |
digest | string | yes |
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. |
health | PluginHealthornull | no | How to tell the service has actually started. |
id | string | yes | Unique across the stack and every installed plugin. |
image | string | yes | Registry path, without tag or digest. |
listens | integerornulluint16, ≥ 0, ≤ 65535 | no | The port it answers on inside the stack's network, where lemonfiber and the services that ask reach it. Required where |
media_types | array ofstring | no | Which media types it handles, in the stack manifest's vocabulary. |
name | string | yes | Human-facing. |
port | integerornulluint16, ≥ 0, ≤ 65535 | no | Primary UI or API port, absent for a service with no listener. |
provides | array ofstring | no | 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. |
tag | string | yes | The human-readable version the digest corresponds to. Recorded and shown; never resolved. |
takes_data | boolean | no | 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.
| Field | Type | Required | What it is |
|---|---|---|---|
expect | Expect | yes | What the answer must be. |
expected | array ofPluginExpectedFailure | no | Recordings this proof fails on, each with the constraint that fails there and why. |
fixture | stringornull | no | A recorded response to run against where no instance exists. |
id | string | yes | Unique within the plugin. What a verdict is reported against. |
request | PluginRequest | yes | Method and path. |
service | stringornull | no | 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. |
title | string | yes | What it establishes, in one line. |
why | string | yes | Why this is worth asserting. A proof nobody can justify is one nobody will maintain. |
Recipe
One named flow of calls.
| Field | Type | Required | What it is |
|---|---|---|---|
id | string | yes | Unique within the plugin. |
input | array ofInput | no | The values it brings in from outside an answer: a credential lemonfiber holds, or one the operator supplies when it runs. |
on | PluginRecipeOn | no | When it runs: as part of installing the plugin, or when it is asked for. |
pair | array ofPair | no | 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. |
step | array ofStep | no | The calls, in the order they are made. |
title | string | yes | What the flow accomplishes, in one line. |
why | string | yes | 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".
| Field | Type | Required | What it is |
|---|---|---|---|
capabilities | array ofstring | no | 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.
| Field | Type | Required | What it is |
|---|---|---|---|
id | string | yes | What the value is, within the plugin. |
of | string | yes | Whose credential it is. |
why | string | yes | What holding it is for. |
Step
One call in a flow.
| Field | Type | Required | What it is |
|---|---|---|---|
call | StepCall | yes | What is called, and where. |
capture | array ofCapture | no | The values taken out of the answer, for a later call to substitute. |
expect | Expectornull | no | What the answer must be for the flow to go on. |
id | string | yes | Unique within the recipe. |
retry | PluginRecipeRetryornull | no | How often the step is made again before its answer is taken as final. |
when | PluginRecipeConditionornull | no | 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.
| Field | Type | Required | What it is |
|---|---|---|---|
body | stringornull | no | The body the call carries. A value may substitute an earlier capture. |
headers | map ofstringornull | no | Headers the call carries. A value may substitute an earlier capture. |
method | string | yes | The HTTP method. |
path | string | yes | The path on it, written out. Never a substitution. |
to | string | yes | 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.
| Field | Type | Required | What it is |
|---|---|---|---|
dashboard_group | stringornull | no | Which group on the bundled dashboard it appears under. Defaults to the group the stack uses for its tier. |
hostname | stringornull | no | 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. |
service | stringornull | no | 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. |
A whole manifest
Section titled “A whole manifest”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.
#: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 = truemedia_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 firstproof 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 thefirst 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 itssingle-page app. That is not a defect — it is how a client-routed applicationserves deep links — but it means the usual shorthand for "is it up" is worthlessagainst this image.
Recording it is what makes the shape of every other proof here reviewable ratherthan folklore, and turns a change in this behaviour into a red proof rather thana 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 = 10fixture = "fixtures/settings-guarded.json"why = """This service declares `bind = "lan"` and a hostname, which together put it infront 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 ownsettings, and nothing else here would notice if it stopped: the health probewould still pass and the reader would still work.
The `guarded` probe above asks a version of this question once, at install. Thisasks it every time the doctor runs, which is when it matters — an upgrade, areconfiguration or a reverse proxy in front of it are all ways for a service tostop 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 thehousehold 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 serviceitself: a reverse proxy configured to strip the Authorization header, or asecond route to it that lemonfiber did not write. Kavita itself refuses thisread 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.