What a plugin is
TrakState ships with no fixed data model, and with no fixed list of features either. Much of what the product can do comes from plugins — the vendor’s own packages travel the same seam yours does.
A plugin is a signed ZIP archive with a manifest.json at its root. Inside it, up to three
things:
| Part | What it is | When you need it |
|---|---|---|
| Frontend | A Module Federation remote the shell loads at runtime | Only if your plugin has screens of its own |
| Backend | Code on the server — one TypeScript module, or a process of your own | Only if something has to happen server-side |
| Declarations | Contributions: routes, commands, settings, page types, connectors | Almost always |
A connector plugin is backend only. It ships no frontend and no stylesheet: the settings form is generated from what the connector type declares. If you find yourself writing UI for a connector, something has gone wrong.
The contract is called @prodlog/sdk
Section titled “The contract is called @prodlog/sdk”A plugin is built against this package and nothing else — never against the host’s source. What is exported there is what the host promises to keep working. What is not exported is not part of the contract, however convenient it looks from the outside.
import { parseManifest, type PluginManifest, type Plugin, type PluginHost } from '@prodlog/sdk';The SDK carries four groups of things:
- Manifest — schema, types, and the checking functions
parseManifest/parseUploadedManifest. - Runtime —
Plugin,PluginHost,CapabilityDefinition,InvocationContext,CapabilityConflictError. - Frontend —
ShellContext,Surface,ScopedStore,PageInstanceand the page-type contract. - Connectors and configuration —
ConnectorType,connectorContribution, and the configuration document behind a tenant template.
What a plugin never gets
Section titled “What a plugin never gets”This is not politeness. It is the load-bearing boundary of the system.
- No database connection. No handle, no credentials. Every query goes through the mediated host surface and therefore runs under the call’s tenant context (row level security).
- No reach into another plugin’s data. Tables are pinned to the plugin’s namespace, and the host checks that before loading.
- No filesystem outside its own working directory.
- No environment variables beyond a short allowlist (
TZ,LANG,LC_ALL,NODE_ENV).DATABASE_URLand anything secret are structurally out of reach. - No tenant switching. The invocation context carries no field with which a plugin could name a different tenant at all.
If something you need is missing, that is a conversation about the contract — not a gap to route around.
Two rights axes, and they are not the same thing
Section titled “Two rights axes, and they are not the same thing”Mixing these up is the most common mistake in a first package.
permissionssays what your code may reach on the host APIs:data:read,data:write,plugin:manage,capability:invoke,audit:read,net:request,net:socket. An operator reads this list on the install screen.rbacPermissionssays what a person may do, inside your own namespace — for exampleacme.tcp:configure.
Both gates must hold. A missing data:write ends in permission_denied even when the
user’s role allows everything — and the other way round.
The version is a range, not a wish
Section titled “The version is a range, not a wish”The SDK version and the host version move in lockstep. Your manifest names the range you built against:
"sdk": "^0.9.0"A host provides its own contract version plus every older one it still fulfils
completely. Host 0.9.0 therefore provides 0.9.0, 0.8.0, 0.7.0 and 0.6.0; a
package on ^0.6.0 keeps running there unchanged. That is a maintained table, not a rule
about 0.x versions: 0.6.0 was itself a breaking release. Read what changed before you jump
a minor.
If the range does not fit, the package is refused with sdk_incompatible and never loads.
From an empty directory to an installed package
Section titled “From an empty directory to an installed package”- Write the manifest and check it with your own compiler using
parseManifest. - Choose a backend kind — in-process or sidecar.
- Build and pack — deterministically, or the digest moves on every build.
- Sign — Ed25519 over the archive bytes.
- Install — upload it, or take it from the operator’s catalogue.
- Enable it — installing is not enabling. A backend starts only when it is switched on.
Each of these steps has its own topic further down this chapter.
A complete example, in the original
Section titled “A complete example, in the original”Four real packages live in the product workspace, and between them they cover every build shape:
| Package | What it is the pattern for |
|---|---|
plugins/tcp |
Sidecar connector: Python backend, a held socket, a connector type as data, its own pack script |
plugins/tenant-user |
Eleven capabilities, both rights axes, a system plugin with no tables of its own |
plugins/board |
A page type, an in-process backend, own tables built from the manifest description |
One honest limit today
Section titled “One honest limit today”@prodlog/sdk is not on a public registry yet. npm install @prodlog/sdk does not work from
outside; today a plugin is built inside the product workspace. This guide moves with that as
soon as it changes.