Skip to content

Plugin bundle format

Verified against v0.21.0 · sdk/abi/bundle-v1.md, internal/plugin/bundle/bundle.go, cmd/parley/plugincmd.go, internal/api/catalog.go, internal/plugin/pin.go, internal/api/plugins.go, internal/plugin/settings.go, internal/api/pluginsettings.go

A plugin ships as one .parley file: a deterministic gzip-compressed tar archive. The full specification, including the byte-level encoding a packer must reproduce, is sdk/abi/bundle-v1.md; golden vectors live beside it in sdk/abi/bundle-v1/.

File Required Notes
manifest.json yes package.json is accepted as a legacy alias, never both
plugin.wasm yes at most 10 MiB
ui.js no the plugin UI script
slots.json no UI slot declarations
MANIFEST.sha256 yes <hex sha256> <name> per file, sorted
MANIFEST.sig when signed Ed25519 public key (32 bytes) then signature (64 bytes)

A bundle’s identity, its digest, is the hex sha256 of MANIFEST.sha256.

The signature is pure Ed25519 over "parley-bundle-v1\n" followed by the MANIFEST.sha256 bytes. The signer is identified by a key id — the first 8 bytes of sha256 of the public key, in hex — which the host computes itself. Any publisher named in the manifest is ignored.

A bundle is refused, before any of it is used, if it contains a file MANIFEST.sha256 does not list, lacks a file it does list, or has a file whose digest does not match; if an entry name has path components, is a symlink or other non-regular entry, is duplicated, or is not one of the names above; if plugin.wasm exceeds 10 MiB or the uncompressed archive exceeds 16 MiB (tar headers and padding included, counted while decompressing), or the entries’ declared sizes add up past it; if anything follows the gzip member; if MANIFEST.sha256 is not in canonical form (sorted, lowercase hex, two spaces, a newline after every line); or if its signature does not verify, is by an untrusted key, or is missing and unsigned bundles are not allowed.

Verified bundles are stored in the database, so every replica serves the same bytes. An admin of the default org uploads a bundle on the Plugin catalog page (/catalog), one or several files at a time, each reported on its own, or with POST /api/catalog/bundles (one bundle per request); nobody else may, including an admin of another org. Every org member can browse the catalog: each plugin shows once what its latest version would be permitted to do, and each version row its signer, digest, publish date and what it adds or drops compared with the version before it.

A bundle is accepted only if it is signed by a key in PLUGIN_TRUSTED_KEYS, or is unsigned and PLUGIN_ALLOW_UNSIGNED=true. The first bundle stored for a name and version holds it: a different bundle for the same name and version is refused, so a new release needs a new version. Stored bundles are never deleted.

An org admin picks a version on their org’s Plugins page, or follows the Install in <org> link beside it on the catalog page, and grants what the server says it may do before anything is installed.

An org admin installs a catalog version by its digest and key id, and the install is pinned to that row: the host loads it by digest, never by name and version, so the install runs exactly those bytes, and trust is still re-checked on every call. The grants, session kinds and consent copy come from the verified bundle’s own manifest. A catalog bundle is the only way to install or upgrade; there is no upload of a bare package.json.

At boot, after the PLUGIN_DIR import, every install with no pin, or a pin that no longer matches its version, is pinned to the trusted stored bundle for its name and version (signed preferred), under its own advisory lock. An install with no match is never disabled: it keeps running from loose files and the plugins page marks it not in catalog.

An install can move to any trusted catalog version of the same plugin, older or newer. Going to an older version is a rollback, confirmed in two steps and audited with the version and bundle it moved from and to; it is the only way back, since installing an older version directly is refused. A move that asks for wider grants than the ones in force waits for approval like any widening upgrade. Every bundle an install is pinned to is remembered and shown as its history. Stored bundles are never deleted; retention and garbage collection of unreferenced bundles are not implemented yet.

GET /api/catalog/loaded, for default-org admins, names the bundles the answering replica has compiled, by digest and key id, to confirm every replica runs the same bytes.

A pin is authoritative only while its bundle carries the install’s version. An older binary during a rolling upgrade can change an install’s version without moving its pin. That install then runs only the trusted stored bundle for its new version, and is re-pinned to it; with none stored it fails closed. An install that has ever been pinned never runs a loose PLUGIN_DIR file. Avoid plugin admin actions while a rollout is in progress.

plugin_installs and plugin_install_history hold foreign keys to plugin_bundles, so any future garbage collection has to leave every bundle an install is pinned to, staged for, or ran (or clear that history first).

A manifest may declare settings: a flat JSON Schema subset an org admin fills in on the install’s card. The host validates it with its own code, and a schema outside the subset is refused at catalog upload (400) and at install.

{
"type": "object",
"properties": {
"channel": { "type": "string", "title": "Channel", "pattern": "^#[a-z]+$", "default": "#general" },
"size": { "type": "integer", "minimum": 1, "maximum": 10 },
"mode": { "type": "string", "enum": ["fast", "slow"] },
"token": { "type": "string", "title": "API token", "format": "secret" }
},
"required": ["mode"],
"additionalProperties": false
}
  • type is object; additionalProperties is false or left out. Nothing nests: each property is string, number, integer or boolean.
  • A property may carry title, description, default, enum, pattern (strings, Go RE2 syntax), minLength/maxLength (strings, in characters), minimum/maximum (numbers), and format: "secret" (strings). Any other keyword is refused, so the host never ignores a rule the admin was shown.
  • At most 32 properties, each named with lowercase letters, digits and underscores, starting with a letter, at most 40 characters.

The plugin reads the non-secret values, with defaults applied, through parley_settings_get. It needs no grant, it is refused while the install is disabled, and it re-reads the database on every call.

Secrets. A format: "secret" field is never stored with the other values. It is an implicit secrets grant scoped to the field’s name, so it appears on the consent screen, and a version that adds one is a widening upgrade that waits for approval. It is stored encrypted like any plugin secret and read only through parley_secret_get with the field name. The admin API never returns its value: only whether it is set, and whether a configured key can open it.

Upgrades and rollbacks. Moving an install to another version re-checks the saved values against that version’s schema, at the request and again at approval. If any saved value does not fit, the move is refused with 409 naming the fields; change or clear them, then try again. Nothing is dropped silently. A field the new version newly requires is not a refusal, since it could not be filled in before; it is enforced at the next save.

A saved value for a field the target version does not declare counts as not fitting, and that includes moving to a version with no settings at all: the move is refused until the admin clears those fields. An approval re-checks only when the pending upgrade names a stored bundle. An upgrade staged without one, from before the catalog, has no schema to check against, and its approval skips the check.

Terminal window
parley plugin keygen [path-prefix] # writes <prefix>.key (0600) and <prefix>.pub
parley plugin verify -key <base64-public-key> bundle.parley

keygen refuses to overwrite either file and prints the key id and the base64 public key to pass to verify -key. verify accepts -key more than once and -allow-unsigned; it prints the digest and key id, or the reason for refusal, and exits non-zero on refusal. Packing and signing are done by the plugin SDK, not the server binary.