Skip to content

Packing, signing, shipping

A plugin ships as one archive that carries its own signature. The signature says who, never what.

manifest.json <- exactly one, at the root
dist/ <- only if you declare a frontend; the entry you name lives here
runtime/ <- a sidecar's tree, whatever shape you like

A backend-only package ships no dist/, and that is correct. If such a package is refused with layout_invalid, the other side is on a host older than SDK 0.8.0.

Limit Value
Package, packed 50 MB
Package, unpacked, total 200 MB
One file 50 MB
Entries 2000
Path depth 32 segments
manifest.json 256 KB

The entry count is the one that bites a Python tree: many small files, not a few big ones.

Also refused: an entry that resolves outside the target directory on unpacking, is a link, or collides with another after normalisation (unsafe_entry), and an archive with a suspicious compression ratio (resource_limit).

A zip call is not enough. Copy plugins/tcp/scripts/pack.mjs and adjust what it collects. What that script does that zip does not:

  • Sorts entries by their UTF-8 bytes. Not Array.sort() — that orders by UTF-16 code units and disagrees above U+FFFF. Everything else about your package can be right, and a filename with an emoji in it will still produce a different digest than expected.
  • Reads raw bytes, never strings. A packer that decodes and re-encodes a .py produces a package that installs and then dies at startup.
  • Fixed timestamps. Same input, same bytes — otherwise the digest moves on every build, and verify-then-execute means nothing.
  • Leaves out tests and __pycache__. A .pyc carries an absolute source path and a timestamp; packing one makes the archive machine-dependent.
node scripts/pack.mjs # version from package.json
node scripts/pack.mjs --version 0.2.0
node scripts/pack.mjs --tag acme.tcp-v0.2.0 # the release path

Version resolution is --tag before --version before package.json. A version is never typed into the script.

Line endings. If your tooling writes CRLF on Windows, your package differs byte for byte from one built on Linux. Both work, but they are not the same artifact and their digests differ. Keep .gitattributes normalising to LF and build from a clean checkout.

A package is bytes plus an Ed25519 signature over their SHA-256 digest.

node dist/cli/sign-package-cli.js --package build/acme.tcp-0.1.0.zip --key your-key.pem

Inside the workspace during development:

tsx packages/sdk/src/cli/sign-package-cli.ts --package build/acme.tcp-0.1.0.zip --key your-key.pem

Flags: --package and --key are required, --out overrides the output path, --detached selects the older shape.

The tool never reads a key from an environment variable and never writes it anywhere. Only paths and the digest fingerprint — a public value — reach the output.

  • Embedded (the default). The signature is placed into the archive as the fixed entry META-INF/signature; out comes one file, <id>-<version>.tspkg. This is the shape an operator uploads.
  • Detached (--detached). A standalone <package>.sig next to the unchanged archive. Not deprecated: the operator’s release shelf still holds pairs of <id>-<version>.zip and <id>-<version>.zip.sig.

The server accepts both by content, never by file extension or by which flag produced it.

An already-embedded package cannot be re-signed — sign the pre-embed archive instead.

Which key — this matters more than the command

Section titled “Which key — this matters more than the command”
  • A package signed with the operator’s key runs anywhere.
  • A package signed with your own key runs only where the operator has allowed tenant-signed packages — and for a backend that is a separate setting again.

If your package carries backend code and is signed with your own key, it is refused today with signature_scope_denied unless the instance runs the isolated backend substrate. A frontend-only package signed with your key installs normally. Ask the operator before you build a plan on it.

A tenant admin registers their own keys under Settings → Plugins → Signing keys: the public half (public.pem) goes there. The private half stays with you and is never uploaded.

Two ways in, and they are for different people.

Upload — the plugin manager has an upload dialog. Drop the .tspkg in. This is how you test, and how a customer installs something you built for them alone.

Catalogue — the operator’s release shelf. A tag in the product repository builds, signs and publishes a release; a script places it on the shelf; it then appears in every tenant’s catalogue to install with one click. The catalogue offers only packages verified against the manufacturer key — never a tenant-signed one.

Installing is not enabling. A package sits installed and inert until somebody switches it on for a tenant — and that is when a backend starts.

From the catalogue — the normal way. When the catalogue holds a newer version of an installed plugin, the plugin list under Settings → Plugins shows the step as an arrow (0.1.0 → 0.2.0), and the row menu offers Update to 0.2.0…. It names the step, asks once, and fetches the new version from the shelf — no file, no signature. The plugin keeps its data and configuration. Where there is nothing newer, the item reads Up to date; for a plugin the catalogue does not carry, Not in the catalogue. The Catalogue tab offers the same update on its own row.

Never remove a plugin to update it. Removing can delete its data — that is what removal is for. An update never needs it.

Upload package… in the same row menu is the second way, for versions the catalogue does not carry — your own plugins, or a package signed for one customer. It is also the way for an update the one-click path refuses as destructive: the catalogue update never confirms a data-destroying change on your behalf, and the upload path shows the plan for you to confirm.

An update must carry a strictly higher version; there are no downgrades and no backward migrations. The same rule applies when the active version stopped verifying against the current trust anchor: bump the patch version, sign again, update — the version is what tells two uploads apart, not the signature.

Before anything is applied the system shows a plan and classifies every change: harmless, checked, destructive, forbidden. Whatever costs rows is counted and has to be confirmed explicitly.

Removal has three separate intents: remove the plugin completely (tables, registry entry, files), delete only this tenant’s data, or remove only the registry entry and leave the tables standing.

If the new version’s backend does not start, the update is not applied and the current version stays active (backend_unstartable).

Install, enable, then watch the server log. A sidecar that came up says so:

sidecar ready · runtimeVersion 3.12.10 · routes 5

If it does not:

What you see What it means
entry_digest_mismatch The tree changed between packing and starting. Usually the interpreter wrote bytecode into it — start Python with -B.
runtime_missing / runtime_too_old The interpreter your manifest requires is not on the machine, or is older.
runtime_unsupported_language Your requires.language is not one the host can start.
entry_outside_package Your entry path leaves the package. Also fires for a link pointing outward.
signature_scope_denied The signature is fine. The operator’s policy says a key like yours may not run this.
permission_denied on a write A permission is missing — check the capability’s own gate, not just capability:invoke.
backend_entry_missing The entry file named in the manifest is not in the archive.
start_timeout Your backend did not report ready within 10 s.
  • plugins/tcp — a manifest declaring a sidecar and a connector type, a Python backend, a pack script, tests that read the manifest from disk, and scripts/peer.py as the machine on the other end of the cable.
  • docs/design/PRODLOG-179-sidecar-kanal.md — the frames on the pipes, byte for byte.
  • docs/design/PRODLOG-179-konnektor-plugin-vertrag.md — a connector’s five routes and two events.