Files
Termix/packages/plugin-sdk/ARCHITECTURE.md
T

3288 lines
206 KiB
Markdown

# Termix plugin architecture
This is the contract between Termix core and a plugin. It is the reference for
anyone writing a plugin and the specification the runtime in
`src/backend/plugins/` implements.
Status: the redesign lands across several steps. Everything marked **A1** is
built today. Later markers name the step that delivers the rest. Phase B
converts one feature per step by following
[Converting a feature into a plugin](#converting-a-feature-into-a-plugin).
---
## The shape of it
### 1. One tier
Every plugin, official or community, runs in-process in the Termix server and
in the browser. There is no first-party allowlist, no worker tier, and no
special case keyed on a plugin id anywhere in core.
This is not a sandbox. See [What this protects](#what-this-protects-and-what-it-does-not).
### 2. SDK only
A plugin imports `@termix/plugin-sdk`, its own files and its own npm
dependencies. Entry points:
| Entry | Contents |
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `@termix/plugin-sdk/backend` | `PluginContext`, `definePlugin()`, `PluginCapabilityError` |
| `@termix/plugin-sdk/db` | `defineTable()`, the column builders, the legacy-table map |
| `@termix/plugin-sdk/ddl` | The per-dialect DDL emitter, shared with the CLI |
| `@termix/plugin-sdk/table-builder` | `buildTable()`, a definition as a queryable Drizzle table |
| `@termix/plugin-sdk/frontend` | The `app` object types, the hooks, `invokeAction()` |
| `@termix/plugin-sdk/ui` | Core's shared components, a fixed public list (see UI) |
| `@termix/plugin-sdk/manifest` | Manifest types, validation, the JSON schema |
| `@termix/plugin-sdk/capabilities` | The capability catalog |
| `@termix/plugin-sdk/settings` | Settings field types and the shared value validator |
| `@termix/plugin-sdk/host-commands` | Platform detection, package manager commands, sudo elevation - pure helpers over an ssh2 `Client` a plugin got from `ctx.ssh` |
| `@termix/plugin-sdk/ssh-certs` | `applyCertificateAuth()`: OpenSSH certificate auth on an ssh2 client, for plugins that issue short-lived certificates |
| `@termix/plugin-sdk/testing` | Test helpers: `createMockCtx()`, `createTestDb()` and more |
Plugins never import from `src/` or `@/`, and core never imports from
`plugins/`. Anything a plugin needs from core becomes a typed, documented SDK
API. Enforced by lint, with no exceptions:
- `eslint.config.mjs` runs a local rule, `termix-plugins/stay-inside`, on
every file under `plugins/*/src` and `plugins/*/tests`. It refuses `@/...`
and any relative import that resolves outside `plugins/<id>/`, which covers
core (`src/`) and every other plugin.
- The same config refuses a `src/` file importing `plugins/*/src` or
`plugins/*/dist`.
- `scripts/check-core-plugin-ids.cjs` (see [UI](#8-ui)) refuses core naming a
plugin at all.
The SDK holds the contract: types, the catalog, and pure helpers. The ctx
implementation lives in core (`src/backend/plugins/ctx.ts`) and is injected at
activation, so a plugin cannot reach around it.
### 3. Capabilities are what a plugin's code may do
Declared in the manifest's `capabilities` array using `domain:action` names
from one catalog in the SDK. Every privileged SDK call checks the capability
and writes an audit line naming the plugin and the acting user.
Two things must both be true, and a grant for something the manifest never
declared is ignored rather than honoured:
1. the manifest declares it, and
2. it is granted in `plugin_permission_grants`.
That means widening a plugin's reach always requires a new manifest the user
can see. In 2.9.0 bundled plugins are granted everything they declare
automatically, with `source: "bundled"`.
Adding a capability is one catalog entry (id, risk, i18n title and consequence
text) plus the SDK method that checks it.
### 4. User permissions are what a user may do
These stay in core RBAC and keep their dotted spelling (`hosts.view`), so they
can never be confused with colon-separated capabilities.
**A5 delivered this.** A plugin declares short names in
`contributes.permissions`, and core registers each as `<pluginId>.<name>`. The
plugin never writes the prefix, so it cannot claim a core group or another
plugin's namespace: `parseManifest` refuses a name starting with `hosts`,
`credentials`, `admin` or the plugin's own id, and the runtime
re-checks against every namespace already in the catalog, because the validator
cannot know which other plugins exist.
The ids are unchanged by the move. `ai` + `use` is `ai.use`, which is exactly
what the core catalog held before, so no role had to be migrated when `ai.*` and
`automations.*` left `PERMISSION_CATALOG` and `SYSTEM_ROLE_DEFAULTS`.
**A permission outlives its plugin.** Core records every id it has ever
registered in `rbac_known_permissions`, loaded at boot before any request is
served and backfilled from what roles already hold. Disabling a plugin now only
marks its group `enabled: false`: the group stays in the catalog so the role
editor can grey it, and `PUT /rbac/roles/:id` keeps accepting it. Previously the
group was unregistered outright, so an admin could not save _any_ change to a
role that still held one of these while the plugin was off. A permission nobody
has ever registered is still rejected.
**Defaults apply once.** `defaultRoles` names the seeded system roles (`admin`,
`user`) and nothing else. Each application is recorded in
`rbac_applied_defaults`, a core table with no foreign key to `plugins`, so an
admin who revokes a default does not get it handed back on the next boot or by
reinstalling the plugin. That ledger used to live in `plugin_storage`, which
cascades with the plugin row.
Both tables are deliberately unlinked from `plugins`: the whole point is
surviving the plugin being gone.
`ctx.rbac` is the plugin-side view: `has(permission)` for the current actor,
`hasFor(userId, permission)`, and `require(permission)` as route middleware. All
three resolve a bare short name against this plugin's prefix and take a full id
belonging to a core group or another plugin as given, which is what makes a
cross-plugin check expressible. `require` additionally refuses anything the
plugin does not declare itself, at registration time, and denies with the same
`401`/`403` bodies `requirePermission()` sends.
### 5. Data
Plugins own their tables. A plugin declares them with `defineTable()` from
`@termix/plugin-sdk/db` and ships migrations for `sqlite`, `postgres` and
`mysql` under `migrations/<dialect>/NNNN_name.sql`. Table names are prefixed
`p_<id with - as _>_`, which `defineTable` adds. `ctx.kv` is for small
key/value state, and `ctx.files.dataDir()` (**B13**, needs `files:own`) is a
per-plugin folder, `<DATA_DIR>/plugin-data/<id>`, created on first call, for
state too large for `ctx.kv` (session recordings, uploaded files); a plugin
lays out its own subdirectories underneath it. It is kept apart from
`<DATA_DIR>/plugins/`, where the loader looks for user plugins (**D4**). Core `schema.ts` ends up holding only
core tables.
**One table object, three DDL emitters.** The sqlite-core definitions already
encode correctly on every engine at query time, which is why core's own 44
repositories use `schema.ts` on all three. Only DDL genuinely differs, and
that mapping lives in `@termix/plugin-sdk/ddl` so `termix-plugin migrations`
and the server's runner cannot drift into disagreeing.
**MySQL cannot index TEXT.** Any column carrying a key becomes
`varchar(255)`, and `defineTable` refuses an index on `text()` or `json()`
outright rather than silently truncating it.
**Migrations are applied at activation**, before `activate()` runs, so a
plugin's first query never meets a missing table. Applied migrations are
recorded in `plugin_migrations` with a checksum, which makes them immutable:
editing one that already ran blocks that plugin rather than leaving two
installs with quietly different schemas. A migration that throws marks its
plugin failed and core keeps booting.
**Adoption** is how a feature keeps its rows when it moves out of core. The
legacy table is renamed into the plugin's namespace
(`user_workspaces` -> `p_workspaces_workspaces`) rather than copied, so nothing
is duplicated and no row is migrated one at a time. `LEGACY_TABLE_OWNERS` maps
each legacy table to the one plugin allowed to adopt it, enforced by
`adoptLegacyTable`, by the CLI at build time and by the runner at activation,
because a plugin installed from a tarball never ran the CLI.
A plugin marks a definition with `adoptLegacyTable("user_workspaces",
defineTable(...))` and `termix-plugin migrations` writes the adoption for all
three dialects. A plain SQL file cannot ask whether the legacy table exists, so
the migration creates it when it is missing (a fresh install), creates the
definition's indexes on it with `IF NOT EXISTS`, and then renames it. Both
paths end with the same table. Two rules follow:
- **Keep the legacy column and index names** in the definition. The rename
carries the old indexes across, and a matching name is what makes
`IF NOT EXISTS` skip them instead of adding a second copy.
- **MySQL gets no index statements** in an adoption, because it has no
`CREATE INDEX IF NOT EXISTS`. That is safe for every legacy table core ever
shipped to MySQL, since core's drizzle migrations already created its indexes.
A MySQL `TEXT` column cannot take a literal default, so the emitter writes one
as an expression (`DEFAULT ('{}')`), the way it already did for
`CURRENT_TIMESTAMP`.
**Write through `ctx.db.client()`, then call `ctx.db.persist()`.** On SQLite the
database lives in memory and reaches its encrypted file only when something
asks, which core's repositories do after every write. A plugin write that skips
`persist()` can be lost on restart. It is a no-op on Postgres and MySQL, needs
`db:own`, and is not audited, because it follows a `client()` call that was.
`ctx.db.dialect` says which engine is running. The table object encodes the
same on all three, but MySQL has no `RETURNING`, so portable code reads a new
row back by a key it chose (a sync id) rather than relying on `.returning()`.
**Anything DDL-only in `db/index.ts` is SQLite-only.** `alert_rules`,
`alert_rule_channels` and `alert_firings` were created only there and never
declared in `schema.ts`, so they never existed on Postgres or MySQL at all.
Automations replaced them and A3 drops them. A core table needs all three
artifacts in lockstep: the `schema.ts` declaration, the `index.ts` DDL, and the
regenerated `schema.pg.ts`/`schema.mysql.ts` plus drizzle migrations.
**What stays in core.** After 2.9.0, `schema.ts`, the SQLite DDL in
`db/index.ts`, `performance-indexes.ts`, the repository factory and the
drizzle migrations hold only these 38 tables. Anything else is a plugin's,
under `p_<id>_`.
| Area | Tables |
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Accounts | `users`, `sessions`, `trusted_devices`, `api_keys`, `user_external_identities`, `user_second_factors` |
| Preferences | `settings`, `user_preferences`, `ui_preferences`, `host_sidebar_preferences`, `credential_sidebar_preferences`, `user_open_tabs` |
| Hosts | `ssh_data`, `ssh_folders`, `recent_activity` |
| Credentials | `ssh_credentials`, `ssh_credential_usage` |
| Sharing and RBAC | `roles`, `user_roles`, `host_access`, `folder_access`, `credential_access`, `shared_host_secrets`, `shared_host_auth_overrides`, `shared_credential_secrets`, `rbac_known_permissions`, `rbac_applied_defaults` |
| Audit | `audit_logs` |
| Sync | `sync_records`, `sync_conflicts`, `sync_link` |
| Plugin runtime | `plugins`, `plugin_permission_grants`, `plugin_registries`, `plugin_install_counts`, `plugin_storage`, `plugin_settings`, `plugin_migrations` |
A few core tables still carry columns a plugin reads through its own host or
user settings after the upgrade (the moved `ssh_data` feature columns, the
2.8 OIDC columns on `users`, `sessions.oidc_sub`/`oidc_sid`). They are
dropped in 3.0.0; see [Moving a host column into a plugin](#moving-a-host-column-into-a-plugin).
**Exports and imports follow the plugins.** The per-user SQLite export and
the JSON user export include every row the user owns in a plugin table: any
table a running plugin declared with a `refUser()` column
(`src/backend/plugins/user-data.ts`). Encrypted columns stay behind, since
they are sealed with this server's keys. An import reads each table by its
`p_` name, or by the legacy name it adopted for a file written before 2.9.
**Sync entities are registered, not hardcoded.** `ctx.sync.registerEntity`
adds an entity to sync between a linked desktop and its server; core
registers its own the same way in `src/backend/sync/entities.ts`. The wire
names are part of the protocol: records on both sides match on them, so never
rename one.
**How sync works.** A desktop links to one server account (Sync in the rail)
and keeps an offline copy of that account's data. Nothing records a change as
it happens. On both sides a reconciler hashes every registered row the user
owns and compares it with `sync_records`:
- On a server, a new, changed or missing row bumps the record's `revision`
and gives it the next `seq`. `GET /sync/v2/changes?cursor=` pages through
records after a cursor, so a desktop never compares timestamps from two
clocks.
- On a desktop, a row whose hash differs from the one recorded at the last
sync is a local edit to push. `POST /sync/v2/push` carries the revision the
desktop last saw; if the server moved on, the op is a conflict, the
server's version wins and the local one is kept in `sync_conflicts` for the
user to restore.
- Every op gets its own result, so one refused row never holds up the rest.
A refused row is not sent again until it changes.
- `GET /sync/v2/events` is a server-sent event stream that tells a linked
desktop to pull now.
Because deletes are found by reconciliation, a plugin needs nothing beyond
the registration: a registered row that is gone is a delete. There is no
tombstone table any more.
**What a registration says.**
- `references` translate local ids to syncIds on the way out and back. A
`field` is a column (`credentialId` with `syncField: "credentialSyncId"`)
or a path into a JSON column (`jumpHosts[].hostId`,
`layout.panes[].hostId`); `idType: "string"` keeps an id stored as a
string. A syncId with no row on the receiving side keeps the value the
column already has, and a row written before its target is written again
once the target exists.
- `encryptedFields` are decrypted with the owner's data key for the wire and
encrypted again on arrival. A field that cannot be decrypted keeps the row
out of the feed; it is never sent empty.
- `readOnlyFields` stay on their device: never sent, never overwritten, not
part of the hash.
- `shouldSync(row)` leaves rows out in both directions, for state that
belongs to one install, such as the workspaces plugin's "Last Session" row.
- `permissions` (`create`, `update`, `delete`) are checked on the server
before a pushed change is written.
- `serialize` and `deserialize` run after `references`, for anything they
cannot express. `afterWrite` runs after a synced row is written and
`remove` replaces the plain delete, for rows with side effects of their
own.
- `answersTo` names a plugin entity by a name core uses without knowing the
plugin: core's host quick actions point at `commandSnippet`, which the
snippets plugin's `snippets` entity answers to.
The manifest lists the wire names under `contributes.syncEntities`, so a
server can name them while the plugin is off and a desktop can list them in
its sync settings.
**Plugins on a linked desktop.** The manifest's `desktop` field says how a
linked desktop treats a plugin. `mirror` (the default) follows the server: a
plugin installed there is downloaded from `/sync/v2/plugins/<id>/package`,
verified like any `.tmxplug`, installed, and switched on or off with the
server, with the capabilities an admin granted it there. `local` is decided
on each desktop (serial ports), and `server` never runs on a linked desktop
(ACME, sign-in methods, automations). While linked, the admin toggle for a
mirrored plugin answers 409.
### 6. HTTP and WebSockets
Plugin routes live under `/plugin-api/<id>/` and sockets under
`/plugin-ws/<id>/<path>`, served by the main backend with core auth. No plugin
gets its own port or nginx block.
**A4 delivered this.** A plugin calls `ctx.http.router(options)` and gets an
Express Router core mounts at `/plugin-api/<id>/`, or `ctx.ws.route(path,
handler)` for a socket. Core runs the same middleware in front of every plugin:
1. **Auth**, unless the path is listed in `options.public`. A public path is
audited when the router is registered and logged on every request, because
"this plugin opened a hole in auth" should be findable later. **D2** lists
a running plugin's public HTTP paths and public socket paths as
`publicRoutes` on `GET /plugins` (admins only), and audits a public
socket when it is registered. It matches the
full path, with `:param` segments allowed, so declaring `/webhook/:token`
public cannot open `/webhook/:token/anything`. A trailing `/*` is the one
deliberate prefix (**C3**): `/chooser/:id/*` opens that path and everything
below it, for a page the plugin proxies whose assets it cannot list.
2. **The actor**, from the user core authenticated, never from the request body.
3. **An enabled check** returning 503 while the plugin is disabled. That is a
different fact from 404 ("no such plugin") and a caller can act on it.
A disabled plugin has no router left, so the dispatcher answers 503 for any
installed id and 404 only for an unknown one; sockets do the same on the
upgrade (**D2**).
4. **Body limits**, defaulting to the 2mb core itself accepts. `database.ts`
skips its own global parser for `/plugin-api` so a plugin's `bodyLimit`
actually applies; `rawBody` turns the parsers off for a router that parses
its own (multer, a raw body for signature checks).
5. **An error wrapper** that logs against the plugin, never leaks a stack, and
feeds the error budget. A 4xx (a body that does not parse, one over the
limit) answers its own status and does not count, and nothing an
unauthenticated request causes counts either, so five junk requests to a
public route cannot switch a plugin off (**D2**). A public socket's guest
runs with no actor at all, the same as a public HTTP route.
`ctx.rbac.require(permission)` adds a per-route RBAC gate, and a plugin may only
require a permission it declares itself.
Both surfaces need `network:serve`. The declaration is checked when the router
or route is created; the **grant** is checked per request and per upgrade,
because it lives in the database and `router()` has to be synchronous. A revoked
grant therefore takes effect on the next request without a restart: HTTP answers
403, an upgrade is refused 403.
Sockets are served from the main server's `upgrade` event, on both the HTTP
server and the direct HTTPS one. Auth is the same `utils/ws-auth.ts` path every
other Termix socket uses, and a token still awaiting TOTP is refused exactly as
it is for HTTP. Binary frames and backpressure are untouched. Everything a
plugin registered is disposed on deactivate, and live sockets are **closed**,
not merely unrouted: a terminal session must not outlive the plugin that owns it.
`ctx.ws.upgrade(path, handler)` hands over the raw upgrade, after core has
authenticated it, for a library that insists on owning its own WebSocketServer.
guacamole-lite is why it exists. Three routes are public because they
authenticate themselves in a way core cannot: the terminal (a share-link guest
arrives with a share token), the Docker console (it refuses a socket without a signed-in user holding
`docker.use`, and re-checks the data key per message), and the
Guacamole display (a single-use encrypted connection token in the query, minted
by an authenticated route).
**B15** moved the Docker console off `ctx.ws.upgrade` and its own
`WebSocketServer` onto `ctx.ws.route` with `public` and `optionalAuth`: core
resolves the caller's token, the handler closes a socket without a user or
without `docker.use`, and it checks `isDataUnlocked()` on every connect
message. Nothing is left at module scope, so disable, enable while a console
is open closes it and the next enable serves new ones.
**B9** added `optionalAuth` for a public route: core still verifies a token
when the client sent one and hands the handler that user, else an empty id.
The terminal uses it, because one socket serves both signed-in users and
share-link or room guests, who arrive with a share token instead. The route is
public so a guest can reach it at all; guest auth itself stays in the plugin's
handler (noted for D2). Every route connection now also carries `clientIp`,
`requestOrigin` and `isDataUnlocked()`, the data-key check a long-lived socket
repeats per message so an expired session stops being served.
**Old URLs outside the namespace.** A few 2.8 URLs are called by something
outside Termix: identity provider callbacks, the Vault OIDC callback, the
Termix ID resolver, back-channel logout. A plugin lists them in
`contributes.http.legacyRedirects` as `{ from, to, status? }`. Core redirects
a request at `from` (or under it) to `/plugin-api/<id><to>` with the rest of
the path and the query kept, 307 by default or 308 for a permanent move.
`from` may be any absolute path outside the plugin framework's own prefixes,
and core routes win a clash. It is a redirect rather than a forward because a
cookie the plugin set is scoped to its own path. `contributes.http.legacyPaths`
is the other form, for URLs already under `/<plugin id>/` (automation
webhooks), which the plugin's router serves in place. 2.8 clients' login
routes (`/users/<method>/authorize`, `/users/<method>/login`) stay in core as
generic aliases of the login pipeline.
The nginx configs keep one small group of blocks for those public 2.8 URLs
(`/vault/oidc/callback`, `/termix-id/u/`, `/automations/webhook/`); the rest
already fall under core's `/users` and `/host` blocks. A new plugin never
needs an nginx change: everything it serves is under `/plugin-api/`,
`/plugin-ws/` or `/plugin-assets/`.
Socket and ssh2 event listeners fire outside the upgrade's async context, so a
plugin that calls privileged ctx members from them binds them with Node's
`AsyncResource.bind` while the actor is still set; the terminal does this for
every listener it registers.
### 7. Settings
Plugins declare settings fields in the manifest with scope `admin`, `user` or
`host`. Core renders them from the schema, stores values in `plugin_settings`,
and encrypts secret fields.
**A6 delivered this.** A field is `{ key, type, labelKey, ... }`, with types
`boolean`, `string`, `number`, `select`, `multiselect`, `secret`, `textarea`,
`json` and `custom`. `requires` names a boolean in the same scope that gates
it, `group` is a section heading, and `permission` is who may write it: admin
fields fall back to `admin.plugins.manage`, and a short name resolves against
the plugin's own permissions exactly as `ctx.rbac` does.
`hidden: true` (**B14**) keeps a field out of the generic form: it is stored,
validated and encrypted like any other, but the plugin edits it in its own
UI. Remote desktop's host options are hidden fields, edited in its RDP, VNC
and Telnet host editor tabs.
**Where fields render.** In 2.9.0 the plugin system is invisible to users:
there is no plugin list, enable switch or capability dialog, and nothing in
the UI says "plugin". That UI arrives with the store in 3.0.0. Settings show
up as features instead:
- `user` fields render as an accordion section named after the plugin in the
User Profile panel, and `admin` fields do the same in the Admin Settings
panel (`src/ui/settings/FeatureSettingsSections.tsx`). `group` becomes a
card inside that section.
- `host` fields get a host editor tab of their own, named after the plugin
(`src/ui/settings/host-feature-tabs.tsx`). `editorGroup` (`"top"` or
`"ssh"`, default `"top"`) and `editorOrder` place it among the other tabs.
A plugin that registers its own section with `registerHostEditorSection`
gets no generated tab, so it must draw any non-hidden host field itself.
`type: "custom"` names a component the frontend registered, for the few things
a schema cannot express (a device browser, a provider list). It is deliberately
narrow: a plugin supplies data everywhere else, so it cannot ship its own form
styling and drift from the rest of the app, and a page disappears cleanly when
its plugin does.
**One service, two callers.** `src/backend/plugins/settings.ts` is behind both
`ctx.settings` and the HTTP routes, so validation, encryption and redaction are
decided once. Two implementations would drift, and the one that drifted would
be the one writing to the database.
**The manifest is the schema.** A read is driven by the declared fields, not by
the stored rows: an unwritten field comes back with its `default`, and a row
for a field the plugin has since dropped is ignored rather than handed to the
UI. A write to a key the manifest never declared is refused, which is what
stops a PUT from filling a plugin's namespace with arbitrary keys.
**Secrets never leave the server.** A `secret` field is encrypted with
`encryptSystemSecret` (the installation key, not a user DEK: these have to be
readable before any user is unlocked) and every HTTP read replaces it with
`{ set: boolean }`. Sending that marker back is a no-op, so saving a form
cannot clear a key it was never shown. Clearing one writes null rather than an
encrypted empty string, so it reads back as unset.
**Storage.** `plugin_settings(plugin_id, scope, scope_id, key, value,
encrypted, updated_at)`, unique on the first four. `scope_id` is null for
admin, a user id for user and a host id as text for host. It is polymorphic, so
it carries no foreign key and the engine cannot cascade it: `UserRepository`
and `HostRepository` delete these rows explicitly, with tests that fail if
those calls ever go. The foreign key to `plugins` is real, so uninstalling
leaves nothing.
**Routes**, all with core auth: `GET|PUT /plugins/:id/settings/admin`
(`admin.plugins.manage`, or the field's own permission), `/settings/user`
(always the caller, never a user id from the body) and
`/settings/host/:hostId` (needs edit access to that host). A rejected PUT
returns `400 { errors: { <key>: message } }` per field, so a form shows every
problem at once.
**Host payloads carry them.** Host list and get responses include a
`pluginSettings` map, enabled plugins only and secrets redacted, built in one
query for the whole list rather than one request per plugin per host. It is
attached after the sanitizers, because the connect-level projection of a shared
host reduces it to an allowlist.
`ctx.settings` is `get/set`, `getUser/setUser`, `getHost/setHost`, `getAll`,
`onChange` (disposed with the plugin), `onValidate`, `listHostValues` and
`readCore`. **Reading and writing a
plugin's own settings needs no capability**: the manifest already declares
every field, and a plugin that had to ask permission to read its own
configuration would be useless. Only `readCore`, which reaches outside the
plugin's namespace, is gated on `settings:read-core`, and it serves a short
allowlist (`CORE_SETTINGS_ALLOWLIST`) rather than the whole settings table.
**D0** added three pieces:
- `onValidate(scope, validator)` checks a save from the profile or admin
panel or the host editor before anything is written. The validator gets every value in
the save (and the host id for host scope) and returns field key to message
for what is wrong. ssh-terminal checks that its image paths are absolute,
step-ca that its CA URL is https and its fingerprint a SHA-256 digest.
- `listHostValues(key)` lists every host that saved a value for one of the
plugin's host fields, across all users, with the host's owner, for a
background job that must know which hosts to act on and as whom before any
request exists (proxmox's auto-sync). It needs `hosts:read` and refuses a
secret field.
- Host fields take `enableDefault` (on the host section), `defaultFrom`,
`shareRead` and `ownerOnly`; see the manifest reference. A host settings
save also changes the host's sync hash, so sync carries it.
### 8. UI
A plugin frontend is a built ESM bundle, `dist/frontend.js`, exporting
`activate(app)` and optionally `deactivate()`. The shell has no plugin ids and
no imports from `plugins/`; everything a plugin shows goes through the `app`
object. `scripts/check-core-plugin-ids.cjs` (run by lint) scans all of `src/`
and fails on any plugin id core spells: a bare literal (`"docker"`), a plugin
route (`"/plugin-api/docker/..."`), or an action, slot or permission id that
starts with one (`"docker.open"`). The shell (`src/ui`) also may not spell a
view a plugin owns (a tab, panel or dashboard card id). There is no
allowlist. Three things are exempt, by rule rather than by entry:
- tests and locales;
- `src/backend/upgrade/`, the one-time 2.8 to 2.9 data moves, which have to
name the plugin each piece of data moves to, the same way
`LEGACY_TABLE_OWNERS` names the plugin that adopts each legacy table;
- `"totp"` inside `src/backend/hosts/connect/`, where it is the SSH
keyboard-interactive one-time-code prompt kind, not the login plugin.
Where core needs something from whichever plugin provides it, core names the
contract and a plugin fills it: a slot (`dashboard.counters`,
`admin.userTabs`), an action (`host.openFiles`, `snippet.list`,
`snippet.resolveForTerminal`) or a component id (`credentials.secretHint`,
`credentials.secretManager`). With no provider, the slot renders nothing and
the action answers `undefined`.
#### The loader
`src/ui/plugin-host/loader.ts` runs after login and again whenever plugin
state may have changed: the `termix:plugins-changed` window event (the admin
toggle fires it) and window focus, throttled to 30 seconds. There is no push
channel yet. Each pass:
1. Fetches `GET /plugins` (`frontend`, `css`, `assetVersion`, `locales`,
`dependencies` and `contributes` per plugin).
2. Loads every plugin's locales into the i18next namespace `<id>`, enabled or
not, because the role editor and the plugin list need disabled plugins'
titles.
3. Orders enabled plugins by dependencies. A plugin whose hard dependency is
missing or off is skipped.
4. For each plugin with a frontend: injects `frontend.css`, imports
`/plugin-assets/<id>/frontend.js?v=<assetVersion>` and calls `activate(app)`.
In dev the workspace plugins are imported from
`plugins/<id>/src/frontend/index.tsx` through Vite, so HMR works.
5. Deactivates plugins that went away: runs `deactivate()` then every
disposer. No reload.
A plugin that throws while importing or activating is marked failed, its
partial registrations are disposed, and the rest of the app carries on. The
same `assetVersion` is not retried until it changes.
Anonymous shared-session and collab pages have no session. They load only
plugins whose manifest sets `contributes.guest: true`, from the public
`GET /plugins/public`, and `app.guest` is true there.
**B12** made those pages generic. A guest plugin lists the `?view=` names it
serves anonymously in `contributes.guestViews` (which needs `guest: true`),
and `/plugins/public` returns them. `src/main.tsx` asks for that list when a
URL carries `?view=`: a guest view starts the guest runtime and renders the
tab type whose `standalone`/`standaloneViews` claims it, anything else goes
through the signed-in full-screen gate as before. The shell no longer knows
`?view=shared` or `?view=collab-guest`; session-sharing declares both.
The login screen runs a smaller pass before anyone signs in: it activates only
enabled plugins that contribute `loginMethods` or `secondFactors`, listed by
the public `GET /plugins/public-manifest`. They stay active after login, and
the full pass does not load the same bundle twice. See Auth below.
#### Shared modules and the import map
A bundle keeps `react`, `react-dom`, `react-dom/client`, `react/jsx-runtime`,
`i18next`, `react-i18next`, `sonner`, `@termix/plugin-sdk/frontend` and
`@termix/plugin-sdk/ui` as bare imports. Core's
Vite build (`scripts/vite-plugin-termix-plugins.mjs`) emits one entry per
shared module, writes a stable unhashed shim for each at
`dist/shared/<name>.js`, and injects an import map into `index.html` mapping
the bare names to those shims. In dev the map points at Vite's virtual
modules instead. The map is inline, so both nginx configs allow it by hash in
`script-src`; `scripts/check-importmap-csp.cjs` (run by lint) fails when the
hash is out of date and `--write` updates it. The map changes only when the
list of shared modules does.
Electron loads `index.html` from `file://`. The map uses relative URLs, and
plugin bundles come from the embedded backend
(`http://localhost:30001/plugin-assets/...`), which is why `/plugin-assets`
sends `Access-Control-Allow-Origin: *`.
#### Serving bundles
`GET /plugin-assets/<id>/<path>` serves the directory holding a plugin's
frontend bundle and its `locales/` directory, nothing else: never
`backend.js`, never a path outside those two, only `.js`, `.css`, `.json` and
`.map`. It is unauthenticated like `/assets`, since `import()` cannot send the
bearer header and bundles are not secret. With `?v=` the response is cached
as immutable; without it, `no-cache`. nginx has a `^~ /plugin-assets/` block.
#### The app object
Every `register*` call returns a disposer and is also disposed automatically
when the plugin deactivates. Every registered component is wrapped in the
plugin's scope (so the hooks know which plugin they belong to), an error
boundary and Suspense.
| Member | What it does |
| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `registerRailItem` | A rail button, hidden from users without its `permission`. Hideable items show in Appearance > Sidebar > Navigation. The id must be a declared view. `placement: "footer"` puts it above the profile, and `useBadge` is a hook whose number shows on the icon (**E1**) |
| `registerPanel` | A rail panel, id in `contributes.panels` or `contributes.tabs` |
| `registerTab` | A tab type, id in `contributes.tabs`. Options cover persistence, layouts, singletons and host needs |
| `registerHostEditorSection` | A host editor tab in the top strip or the SSH group, with `form`, `setField` and `updateForm` |
| `registerHostAction` | A connect or open action on a host: sidebar row, palette, dashboard, default connect |
| `registerHostProtocol` | A connection protocol next to SSH (**B14**): General tab switch, Hosts panel filter and grouping, Quick Connect, the host's primary type and port |
| `registerHostBadge`, `registerHostContextMenuItem` | Host row badges and context menu entries |
| `registerPaletteEntry` | A global or per-host command palette entry |
| `registerDashboardCard` | A dashboard card, id in `contributes.dashboardCards` |
| `registerHomepageWidget` | A homepage widget, with an optional edit form |
| `registerSettingsComponent` | A component a `type: "custom"` settings field names |
| `registerAction`, `declareActionSlot` | Frontend actions and the slots a plugin owns |
| `registerSlotContribution`, `invokeAction` | Fill a slot with a `button` or a `component`, with an optional `when`; call an action and get its result |
| `registerSshAuthEditor` | An SSH auth method's editor in the host editor |
| `registerLoginMethod`, `registerSecondFactorUI` | Login screen UI for a method (`placement: "inline"` beside the password form) and a second factor challenge, each with an optional `enrollment` |
| `api`, `wsUrl(path)` | axios on `/plugin-api/<id>/` and the plugin's WebSocket URL |
| `apiFor(origin)` | The same client for a resolved connection origin: `"remote"` reaches the desktop app's connected server (**B14**) |
| `fetch(path, init)` | A raw `fetch` on `/plugin-api/<id>/<path>` with core's auth, for a streamed response (SSE) axios cannot read (**B18**) |
| `t`, `hasPermission` | The plugin's strings and a permission check, for code outside a component (a toast from `activate`) |
| `tabs.open`, `getLayout`, `applyLayout`, `onChange` | Tab control, used by workspaces; `tabs.openRailView` (**B12**) opens a rail view |
| `guest`, `info`, `onDispose` | Guest mode flag, plugin info, extra cleanup |
| `desktop` | `available`, `remoteServerUrl()` and `onRemoteServerChange`: whether the frontend runs in the desktop app and which server it is linked to (**C6**) |
| `registerComponent` | A component other plugins and core render by id with `PluginComponent` |
| `onSettingsChanged` | Called when one of the plugin's settings changes |
A few contribution fields core reads generically:
| Field | Effect |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `RailItemContribution.simplePreset` | The item stays visible in the Simple interface preset, which hides every other plugin rail item |
| `HostActionContribution.quickConnect` | The action is offered as a button in Quick Connect, opening its `tabType` for an address that is never saved |
| `PluginSshAuthProvider.quickConnect` | (backend) The auth type works for an unsaved host, so Quick Connect lists it and renders the plugin's `SshAuthEditor` for its fields |
| `SshAuthTypeInfo.quickConnect` | What `useSshAuthTypes()` reports for the above |
Core calls outside a component (`@termix/plugin-sdk/frontend`): `logActivity`,
`getHostPassword`, `patchOpenTab`, `getCustomKeybindings`, `setHostAutoTmux`,
`getClientPreference`, `listHosts` (every host the user can see, fetched now)
and `listCredentials` (the user's credentials without secrets, for a picker).
Hooks: `useTranslation` (the plugin's namespace), `usePermission` (a short
name resolves to `<id>.<name>`), `useSettings`, `useHost`, `useHosts`,
`useCurrentUser`, `useTheme`, `useToast`, `usePluginApi`, `useTabs`,
`useSshAuthTypes` (every SSH auth type the server knows, with its plugin),
`useSlotContributions` (the visible contributions to a slot another plugin
owns, with their metadata, for a slot owner that builds a catalog from what
was contributed rather than just rendering it in place - host-metrics uses it
to list manager cards plugins added to `host-metrics.managers` next to its
own). The SDK has no runtime dependencies: the hooks delegate to a host
bridge core installs.
Slots and actions core names for plugins to fill: `dashboard.counters`
(component slot in the dashboard's counters card; tunnels shows its active
tunnel count there), `admin.userTabs` (component slot, one tab per
contribution in the admin "manage user" panel, rendered with `{ user }` and
replaced by core's locked notice while the user's data is locked; snippets
adds its tab), the `host.openFiles` action (the tab bar's file manager
button), the `snippet.list` and `snippet.resolveForTerminal` actions (core's
snippet pickers and the command palette's run flow) and the
`credentials.secretHint` / `credentials.secretManager` components (the
credential and host editors' secret reference UI). **D1** added these in
place of `tunnels.statuses`, `tunnels.open`, `files.openHost`,
`snippets.resolveForTerminal` and the `secret-sources.*` component ids, which
core used to call by the plugin's name.
Other slots: `terminal.toolbar`, `terminal.toolbarStatus`,
`terminal.sidePanel`, `terminal.overlay` (declared by ssh-terminal; **B9**
renamed `terminal.dock` to `terminal.sidePanel` and added the status slot,
where host-metrics puts its CPU, memory and disk bars; **C3** gave overlay
components `send(type, data)` for the session socket and `connectPayload()`,
the data an `<interaction>_auth_completed` needs to reconnect, so a sign-in
flow such as OPKSSH's lives entirely in its plugin), `onboarding.steps`, `onboarding.features`,
`onboarding.workflow`, `hosts.importMenu`, `hosts.panel`, `proxmox.hostEditor`,
`shell.overlay`, `dashboard.hostMetrics`, `homepage.hostMetrics`,
`credentials.badges` and `dashboard.secondaryView`. **C6** added
`credentials.badges`, a component slot after the key/password badge on each
saved credential row (`{ credentialId }`); termix-identity shows its "ID"
badge there, so the credential list no longer fetches Termix ID data. **B16** added the middle two, component slots in
the dashboard's host status card (`{ hostId, online }`, one per host row) and
the homepage's host status widget (`{ hostId, shownMetrics, online }`): core
draws the status and a plugin draws the numbers, so core no longer calls the
metrics API. **B19** added `dashboard.secondaryView` (component slot, one
contribution rendered at a time, `titleKey` names its tab): the dashboard's
"Dashboard / <plugin>" toggle renders whatever is contributed instead of
knowing about the homepage plugin by name, so the toggle disappears with no
contributor and would show a second plugin's view the same way if one ever
claimed it too. **B12** added `shell.overlay`, a component slot the
shell renders once at its root for always-mounted plugin UI: session-sharing
keeps its share dialog and its room-invite watcher there.
`session.remoteDisplay` moved to session-sharing, which declares it and draws
whatever remote desktop contributes to it in rooms and share links.
`remote-desktop.toolbar` (button slot, **B12**) is remote desktop's own,
invoked with `{ hostId, sessionId, protocol, tabInstanceId }`.
`host-metrics.managers` (component slot,
**B3**) is host-metrics's own: tailscale contributes its manager card there
instead of host-metrics knowing tailscale exists, and host-metrics works with
or without tailscale enabled. Cross-plugin frontend calls go through actions:
`automations.list`, `fleets.list`, `session.remoteDisplay.token`.
**B16** added `host-metrics.disk` (the file manager's disk usage bar) and
`snippets.get` / `snippets.execute` (host-metrics's quick actions); each
answers undefined while its plugin is off.
**B9** moved the terminal itself into ssh-terminal, which adds
`terminal.open(host, { path?, joinSharedSessionId?, joinShareId?, label? })`
(the file manager's "open terminal here", the connections panel's "join a
shared session") and replaces `ShellApi.openTerminalTab`, which the shell no
longer has.
`ShellApi.openHostEditor(draft?)` opens the host editor for a new host with
a `HostDraft` (name, ip, port, username, authType) filled in, which the user
reviews and saves (tailscale's "Add host" on a device). `saveQuickConnect`
saves only what core's own quick connect payload carries, so the terminal
offers it only on a host with `quickConnectSavable`, which core sets for the
password, key, credential, none and agent auth types.
**Components by id.** `app.registerComponent(id, component)` offers a
component to other plugins and core, which render it with `PluginComponent`
from `@termix/plugin-sdk/ui` (a fallback while the owner is off) or
`usePluginComponent(id)`. ssh-terminal registers `terminal.view`, the
embeddable terminal the collab room, the homepage SSH widget, the tmux
monitor and the file manager's window render instead of importing it; its
handle comes back through a `handleRef` prop, since a registered component
cannot take a React ref.
**Session tabs.** `TabOptions` gained `commandTarget` (history, macros and SSH
tools act on the last one focused; `PanelProps.targetTab` hands a panel that
tab), `ownBackground` (the shell leaves the frame transparent) and
`multiInstance` (each open is a new tab). `TabHandle` documents what a session
tab puts on `handleRef`: focus, reconnect, disconnect and, for sharing,
`getShareTarget()` for sharing. These replaced
every check of the `terminal` and `local-terminal` tab types in the shell.
**B2** added a live-terminal-session surface, for a plugin that needs to push
resolved text into an open SSH session rather than just fill a slot with a
button: ssh-terminal registers `terminal.listSessions()` (open sessions with
their host info), `terminal.sendToActive(text, { run })` and
`terminal.sendToSession(sessionId, text, { run })`, backed by a small registry
its terminal tab wrapper populates from the core terminal ref it already
holds. The snippets plugin's run/paste flow is the first caller.
**Host protocols (B14).** `app.registerHostProtocol({ id, settingKey,
portKey, defaultPort, titleKey, descriptionKey, icon, quickConnect })` adds a
protocol next to SSH. Its switch and port are the plugin's own host settings,
so core reads them from `host.pluginSettings[<plugin>]` and never knows the
protocol by name: the General tab draws the switch and saves it into the
plugin's settings, the Hosts panel filters and groups by it, HostItem counts
it, Quick Connect offers it (with a domain field when asked), and a host with
SSH off takes the first protocol switched on as its `connectionType` and its
port. `hostProtocols(host)` from `@termix/plugin-sdk/frontend` lists what a
host has on (`"ssh"` included), for a plugin such as session sharing that
offers every protocol without knowing who owns it. Remote desktop registers
`rdp`, `vnc` and `telnet`.
#### View ownership
Who owns a tab, panel or card comes from manifests, so it is known even for a
disabled plugin whose code never loaded. A saved tab or dashboard card whose
plugin is disabled, failed, still loading or not installed renders "This
needs the <name> plugin" (`PluginViewPlaceholder`) and is never dropped from
the saved layout. Tab restore waits for the first plugin pass, so a restored
tab does not race its plugin.
The same holds when a saved layout is applied (a workspace): a tab type no
running plugin has registered is reopened as that placeholder rather than
skipped, and it stays in the next snapshot (`isUnregisteredPluginTabType` in
`src/ui/shell/shell-layout.ts`). Only a tab whose host was deleted is skipped.
#### The `ui` entry
`@termix/plugin-sdk/ui` is public API, implemented by
`src/ui/plugin-host/sdk-ui.ts`. Adding to it is a contract change; removing
from it is a breaking one. Today it exports: alert, alert-dialog, badge,
button, card, checkbox, dialog, dropdown-menu, input, label, password-input,
popover, scroll-area, select, select2, separator, skeleton, switch, textarea,
tooltip, sheet, section-card, metric-card, charts, the card grid,
`ConnectionScreen` and the connection status helpers,
`SnippetVariablesDialog`, `FullScreenAppWrapper`, the connection log context,
`TOTPDialog` (its `MFAPromptMode` type lives in
`@termix/plugin-sdk/frontend` since **C1**, so the shell spells no `"totp"`),
`SSHAuthDialog`, `BrowserSignInDialog`, `PassphraseDialog`,
`HostKeyVerificationDialog`,
`useTabs`/`useTabsSafe`, `ActionSlot`, `ComponentSlot`, `PluginComponent` and
`FOLDER_COLORS` (the colour swatches folders and workspaces pick from).
**B10** added `popover`, `scroll-area` and `skeleton` for the tmux-monitor
plugin's session tree and its new-session/kill popovers. **C3** removed
`OPKSSHDialog` (the opkssh plugin draws its own) and replaced `WarpgateDialog`
with `BrowserSignInDialog` (`label`, `url`, `code`), the one dialog every
transport shows for a keyboard-interactive browser round.
**B9** added what a terminal-like surface needs from the shell: `cn`,
`useConfirmation`, `useIsMobile`, `runAdaptivePolling`; the terminal look
(`TERMINAL_THEMES`, `TERMINAL_FONTS`, `DEFAULT_TERMINAL_CONFIG`,
`resolveTermixThemeColors`, `ensureTerminalFontsLoaded`, the font zoom limits,
the clipboard helpers and `useAppTheme`, the full theme id rather than light or
dark), shared by the SSH and local terminals, the docker console and serial
from `src/ui/lib/terminal-look/`; keyboard handling shared with the shell
(`findMatchingKeybinding`, `globalShortcutHandler`, `isTabJumpHotkey`); the
connection helpers (`isElectron`, `resolveConnectionOrigin`, `pluginWsUrl`,
`hydrateLocalSharedHostAuth`, `useConnectionDefaults`). **D0** moved the core
calls a plugin makes for the signed-in user onto the typed host bridge, as
`@termix/plugin-sdk/frontend` functions: `logActivity`, `getHostPassword`,
`patchOpenTab`, `getCustomKeybindings`, `setHostAutoTmux` and
`getClientPreference`.
**D1** added what the last plugins reaching into core used: `Kbd`,
`KbdKey` and `KbdSeparator`; `ConnectionLogPanel` and the `LogEntry`
type; `createFrontendLogger(name)`, a console logger in core's format; the
adaptive resource budget (`getAdaptiveResourceBudget`,
`markAdaptiveResourceUsed`, `runAdaptiveBackgroundTask`) so a plugin's
background work shares one budget with the shell; `getDeviceId`;
`createQuickConnectHost` and `isQuickConnectHost`; and the desktop app's
local file bridge types (`LocalFileEntry`, `LocalDirectoryListing`,
`LocalWalkResult`, `LocalTransferProgress` and the rest), for a plugin that
drives `window.electronAPI.localFs` itself.
**B16** added `LineChart`, `useAdaptivePolling`, `useAreaPreferences` (the
Appearance density and chart options), and the homepage widget pieces
`WidgetTitle` and `runVisibleInterval` (moved to `src/ui/lib/` in **B19**,
once a plugin rather than a core feature became their biggest caller), for
host-metrics's cards and its metrics chart widget.
**D0** gave plugins their own Appearance area. A manifest's
`contributes.uiPresets` names a value per interface preset (simple, balanced,
advanced), and `usePluginUiPreferences()` returns the one for the user's
level with their changes on top, plus a `set` that saves a change. Changes
are stored in the UI preferences under `plugin:<id>` and listed, with the
plugin's name, where Appearance lets a user revert them. Docker (list or
detail view, card or table layout) and host-metrics (column count) use it;
version 2 of the UI preferences moved their 2.8 `docker` and `hostMetrics`
overrides across. The terminal toolbar density and the file manager's view
mode are still preset-seeded localStorage keys core writes, as in 2.8.
**D0** also added:
- `usePluginApiFor(origin)`: the plugin's client for a resolved connection
origin, as `app.apiFor` but usable in a component. The terminal toolbar's
metrics bars use it for a host on the desktop app's remote server, and
tunnels merges the remote server's statuses through `app.apiFor("remote")`.
- `app.onSettingsChanged(listener)`: fires after the profile panel, admin panel or host
editor saved one of the plugin's settings, instead of polling on focus.
- A `tab.menu` slot: entries in a tab's right-click menu, invoked with the
tab's surface handle; `when` sees `{ tab, handle }`. Session sharing adds its
share entry there, for a terminal whose toolbar is hidden.
- The terminal toolbar lists every plugin's `open` host actions for its host
(tmux monitor, docker, tunnels, host metrics) as quick links, so no plugin
has to contribute a toolbar button to be reachable from a terminal. The frontend SDK gained `useHostStatus(hostId)`: core's
status for a host, from the shell's own polling, or null outside the shell.
**B14** added `buildOriginWsUrl`, `getBasePath`, `resolveRemoteHostId` and the
`ConnectionStage` type, for remote desktop's display socket and its remote
host lookup. **B17** added a channel dialog and API for core's notification
channels, which **E1** removed again when channels moved to the alerts
plugin: a plugin that wants its user to manage channels invokes
`alerts.openChannels`. `useConnectionDefaults` now carries terminal defaults only; the
RDP half became remote desktop's user settings. Publishing its
`.d.ts` for plugins outside this repo is a follow-up for the repo split.
**B19** added `PluginViewPlaceholder`, for the homepage plugin's widget
shell to render in place of a widget type whose owning plugin (docker,
tunnels, host-metrics, file-manager) is off, and moved `WidgetTitle` and
`runVisibleInterval` here from the deleted homepage feature directory
(above), unchanged for their existing callers.
#### Strings
A plugin's strings live in `plugins/<id>/locales/en.json` and load into the
`<id>` namespace, with core's `translation` as the fallback, so a plugin can
still use `common.*`. Manifest keys (`titleKey`, `labelKey`) resolve in the
plugin namespace; `pluginKey(id, key)` in core returns `<id>:<key>`.
Translations go to `plugins/<id>/locales/translated/<xx_YY>.json`, which is
where Crowdin writes them. Only `en.json` is edited by hand.
#### Type checking
`npm run type-check` covers every plugin through two root configs:
`tsconfig.plugins.frontend.json` (plugin frontends and their tests, bundler
resolution; it extends the app config because `@termix/plugin-sdk/ui` is
core's own `sdk-ui.ts`) and `tsconfig.plugins.backend.json` (plugin backends,
`nodenext`, the SDK's built `.d.ts`). Neither gives a plugin a path into
core; the `@/` alias the frontend config inherits exists for `sdk-ui.ts`
itself, and lint refuses it in plugin code.
### 9. Cross-plugin use
Through `ctx.services` (typed, versioned, RBAC-checked per call), `ctx.events`,
and frontend actions and slots. Dependencies are declared in the manifest:
`dependencies` are hard, `optionalDependencies` are soft. The loader activates
in dependency order. A plugin must keep working when an optional dependency is
missing.
A service call runs as the user its permission was checked for, so the
provider reads that user from `ctx.currentActor()` and takes no user id from
the caller. The workspaces plugin provides `workspaces.saved` (`list()`), which
the AI assistant's `list_workspaces` tool reaches through an optional entry in
its `requires`, answering `unavailable: true` while workspaces is off.
**B7**'s `tunnels.access` is the first service with a hard consumer:
web-endpoint lists tunnels in `dependencies` and a non-optional `requires`,
because its tunnel endpoints cannot work without it, while automations keeps
it optional for its one tunnel step. `forward()` resolves only once the
forward is listening and the target answered a probe, so a caller never has
to poll the plugin's state. A plugin can also listen for another plugin's
`plugin.<id>.*` events without any capability: automations turns
`plugin.tunnels.tunnel_disconnected` into its `tunnel_disconnected` trigger.
**B8** added a second, narrower use of `ctx.registry`: a core route that
touches every plugin's host-scope settings generically (without importing any
one of them) consumes `ctx.registry.provide("<pluginId>.hostImportNormalizer",
fn)`. `host-bulk-routes.ts`'s Termix-JSON import path is the first caller,
through `applyPluginHostImportSettings` in `host-plugin-settings.ts`: for
every enabled plugin that declares `contributes.settings.host`, it looks up
`"<id>.hostImportNormalizer"` and, if the plugin registered one, calls it with
the raw imported row and writes back whatever it returns (or nothing, for
`null`). This is not part of the SDK's typed `ctx` surface - it is a plain
`ctx.registry` convention, just with a name core's own code knows to look
for. web-endpoint (`src/backend/host-import.ts`) is the first plugin to
register one; remote desktop's (**B14**) also reads a Termix export's
`pluginSettings` and the flat fields hosts had before 2.9.0. Host exports
carry every plugin's host settings (secrets redacted) under `pluginSettings`,
which is what makes the round trip work without core naming a plugin.
**D0** finished the generic host settings paths, all keyed off manifests:
- Import writes an export's `pluginSettings` (non-secret declared fields,
validated like any save) before the plugin's normalizer runs. The raw
SQLite export carries host-scope `plugin_settings` rows in its own table,
and its import reads them back, plus the 2.8 columns through the
normalizers.
- The Hosts panel's feature filter and the bulk enable and disable menu list
every plugin whose host section has an `enableKey`; the bulk route takes
`pluginEnable: { [pluginId]: boolean }`.
- Remote sync carries a host's non-secret plugin values. A value that is a
local row id (vault's `profileId`) is translated with
`ctx.registry.provide("<id>.hostSettingsSync", { exportValue, importValue })`.
- `ctx.registry.provide("<id>.hostPayloadLegacy", fn)` puts fields back on
the host payload in their 2.8 shape for clients outside Termix.
host-metrics uses it for the `statsConfig` Termix-Mobile reads; remove it
once the mobile app reads `pluginSettings` and `GET /host/status`.
- A host field's `shareRead` hides it from shared recipients below that
level, and `ownerOnly` keeps a shared editor from changing it. Remote
desktop's `guacamoleConfig` (which can hold a gateway password) is
`shareRead: "edit"`, vault's `profileId` is `ownerOnly`.
- The bulk import accepts any auth type core or a plugin registers or
declares in `contributes.auth.sshAuthTypes`.
**B9**'s terminal provides `sessions.live` (live SSH sessions: look up, end,
remove a share's participants, hand a room share's control over, subscribe to
output, write) and `terminal.history` (the actor's command history, which the
AI assistant reads). It consumes three optional services that later steps
provide: `tmux.sessions` (B10; without it the terminal skips tmux attach),
`sessions.sharing` (B12; member joins and collab room events) and
`recordings.writer` (B13; without it nothing is recorded). The terminal keeps
the asciicast formatting and the 300 ms batched flush (issue #1049) and hands
the provider one `append` per batch. Service names need a dot, so "tmux" and
"recordings" became `tmux.sessions` and `recordings.writer`.
**B13** is the session-recording plugin, which provides `recordings.writer`.
It adopts `session_recordings`, keeping `userId` a plain column rather than
`refUser()` (a recording is evidence about the host as much as the person,
so it outlives the account) and clearing it on the `user.deleted` event
instead of cascading. `open(meta)` returns null when the host's
`enableSessionRecording` setting is off; otherwise a `RecordingSink` with
`append(chunk)` (one call per already-batched chunk, so it never writes
per-chunk itself), `persist(summary)` and `discard()` for a session that
recorded nothing. `createFinished(input)` is the second half of the
contract, for a caller that already wrote its own recording to disk and only
needs the database row: remote-desktop's guacd recordings use it instead of
importing the old repository directly. **B14** added `enabledFor(hostId)`
(version 1.1.0), because guacd has to be told to record before the session
exists: remote desktop records an RDP or Telnet session only when the plugin
is running and the host's switch is on, and writes the row as the session's
user. Recording files live
under `ctx.files.dataDir()/session_logs/<user>/<session>.cast`. **D4**:
`createFinished` moves a file written elsewhere (guacd's, in remote desktop's
recordings folder) to `ctx.files.dataDir()/session_recordings/<folder>/`,
since playback and retention only read inside the data folder. Retention
(`retentionDays`, an admin setting) runs a sweep at boot and every 24 hours.
**B15**'s docker plugin provides `docker.containers` (`listContainers`,
`action`) and `docker.events` (`subscribe(hostId, listener)`), both gated on
`docker.use`. The service name needs a dot, so the plain `docker` the step
was sketched with became `docker.containers`. `docker.events` polls each
subscribed host about once a minute as the subscribing user and diffs the
snapshots; a subscription is idempotent, which is how automations picks it
back up after docker is disabled and enabled again (it re-subscribes every
watched host each minute). Automations uses both as optional services: its
Docker step goes through `action`, and its `docker_event` trigger through
`subscribe`.
A service call runs a permission check for its actor, so a share-link guest,
who has no user, cannot make one. Guest link resolution is therefore
published on `ctx.registry` as `sessions.sharing.guests` instead: the token is
the authority there.
**Core consuming a plugin service.** The open tabs route is still core and
lists the caller's own terminal sessions through
`getServiceImplementation(service, range, name?)` in `service-registry.ts`
(`hosts/live-terminal-sessions.ts` wraps it). Core already authorized the
request, so this skips the per-call check and returns the same object
plugins get, or undefined while no compatible provider runs.
**Named providers (B12).** A service several plugins provide side by side is
keyed by a provider name. The manifest lists the names in
`provides[].names`, `ctx.services.provide(service, impl, { name })` refuses
any other, `ctx.services.get(service, { provider })` reaches one, and
`ctx.services.providers(service)` lists the running ones. A requirement is
satisfied by any compatible provider. `sessions.live` is the first: keyed by
session type, ssh-terminal provides `ssh` and remote desktop provides `rdp`,
`vnc` and `telnet` (**B14** delivered them), with
`createViewerToken(sessionId, readOnly)` for a viewer. A remote desktop
session id is guacd's own connection id, and `ownerEndSession` closes it and
every viewer joined to it. A consumer with no actor, such as a public guest route, calls it
through `ctx.asUser(<share owner>)`.
**Session sharing (B12)** is the session-sharing plugin. It provides
`sessions.sharing` (member joins, room events) and `sessions.sharing.guests`,
adopts `session_shares`, `session_share_participants`, `collab_rooms` and
`collab_room_members`, and reaches sessions only through `sessions.live`. Its
two public routes, `/resolve/:linkToken` and `/guest/:token`, check the token
themselves and are rate limited per IP. The share button is a contribution to
`terminal.toolbar` and `remote-desktop.toolbar`, and (**D0**) to the tab
bar's `tab.menu` slot, which reads the terminal handle's `getShareTarget`. The
`shareable`, `canShare` and `openShareModal` members of `TabOptions` are gone. Core's active connections list gets the
sessions shared with a user from the `sessions.sharedWithMe` action.
**Automations (B17)** imports nothing from core. It reaches snippets
(`snippets.access`, a hard dependency), fleets (`fleets.access`), tunnels
(`tunnels.access`), Docker (`docker.containers`, `docker.events`), host metrics
(`host-metrics.viewers`) and wake-on-lan (`wake-on-lan.send` v1,
`wake(hostId)`, which **B20** provides) as services, alerts through
`ctx.notify` (a notify step goes to the owner's inbox plus the channels it
names, and a failed run sends the owner an `automations.run_failed` alert, E1),
the HTTP step through `ctx.fetch`, hosts through `ctx.hosts` and
`ctx.ssh.resolveHost`, and commands through `ctx.ssh.withConnection`. Every run
executes inside `ctx.asUser(<owner>)`, so each of those calls is the owner's.
The scheduler tick is `ctx.schedule.every`, and triggers listen through
`ctx.events` to `host.status`, `host.login`, `internal.event`,
`plugin.host-metrics.snapshot`, `plugin.host-metrics.health-check`,
`plugin.tunnels.tunnel_disconnected` and its own
`plugin.automations.automation_failed`, which replaced the core
`automation_failed` internal event.
A definition's trigger and steps say which of those optional plugins it needs
(`requirements.ts`). An automation whose provider is not running is skipped:
the run is recorded as `skipped` with "Needs the <plugin> plugin", the reason
is logged, and the engine carries on with the next one. The editor asks
`GET /editor-options` for the running providers and only offers the triggers
and steps they allow, and the list marks such an automation "Needs <plugin>".
It provides `automations.access` v1 (`list`, `get`, `create`, `run`, gated
on `automations.view`, with `create` and `run` also checking their own
permissions) for the AI assistant, which lists automations as an optional
dependency. It adopts `automations` and the five `automation_*` tables; the
migrations keep the legacy links between them by hand. Its channel links
lost their foreign key to `notification_channels` in its 0002 migration (E1),
since channel ids now point into the alerts plugin's table. Core's `hosts/automation-events.ts` became `hosts/internal-events.ts`
(`emitInternalEvent`), since it never was specific to automations. Webhooks
live at `/plugin-api/automations/webhook/<token>`; the 2.8 path
`/automations/webhook/<token>` is rewritten to it in both nginx configs,
because external systems hold that URL.
**AI assistant (B18)** imports nothing from core. It adopts `ai_providers`,
`ai_conversations`, `ai_messages` and `ai_proposals` (the migrations keep the
conversation foreign keys by hand), keeps provider keys in `ctx.secrets`,
reaches providers through `ctx.fetch` with the admin's private endpoint list
as `allowPrivateHosts`, and runs approved commands through
`ctx.ssh.withConnection`. Hosts come from `ctx.hosts` (proposals use
`create`, `update` and `delete`, after checking the approving user's core
`hosts.*` permission), channels from `ctx.notify.channels()`, and everything
else from optional services: `snippets.access`, `fleets.access`,
`automations.access`, `workspaces.saved`, `network-topology.graph`,
`terminal.history` and `homepage.items` v1 (`list()`, which the homepage
plugin provides, B19). A tool names the service it needs, and a tool
whose service has no running provider is not offered to the model at all. B18
made the write methods of `snippets.access` and `fleets.access` check the
same permission their routes do (`snippets.create`/`edit`/`delete`,
`fleets.manage`), since a proposal runs as the approving user. Its settings
are the admin `globallyEnabled` and `privateEndpoints`, the user `enabled`
and `allowReadOnlyCommands` plus a custom `providers` field, and the host
`enableAiAssistant`, which decides whether its `terminal.toolbar` button and
`terminal.sidePanel` appear. `ai-settings-migration.ts` moves the 2.8 values
and keys. The shell knows nothing about it: availability is the plugin being
enabled, `ai.use`, and those settings.
**Homepage (B19)** is the widget canvas and the dashboard's service links.
It imports nothing from core. It adopts `homepage_items`, `homepage_layouts`
and `dashboard_service_links`, keeping the legacy sync wire names
(`homepageItems`, `dashboardServiceLinks`). Its outbound routes (favicon, RSS,
ping, proxy widgets) all go through `ctx.fetch`, so they keep the SSRF
guard every other outbound call gets. It provides `homepage.items` v1
(`list()`), which the AI assistant reads as an optional service. Core's own
uptime, recent-activity and database-health routes are unrelated to the
canvas and stayed on the main server (`database/routes/dashboard-routes.ts`,
mounted at `/dashboard`); only the widget canvas and the service-links table
left with this plugin. The two ports it used to run on (30006 dashboard,
30012 homepage) are gone.
Both dashboard cards (`service_links`, `homepage_preview`) and the
`?view=homepage` full-screen link are the plugin's own, registered through
`registerDashboardCard`/`registerTab`. `registerDashboardCard` gained
`defaultPanel` (`"main"` or `"side"`), so a preset that lays out cards for a
new user reads a plugin's own card's height and panel from the registry
(`service_links` asks for the side column) instead of a core file having to
spell every plugin's card id and its layout by hand. The dashboard's "Dashboard / Homepage"
toggle is generic: it renders whatever a plugin contributes to a new
`dashboard.secondaryView` action slot (component kind, `titleKey` for the tab
label), so core carries no homepage-specific header or copy-link button of
its own. A widget any plugin registers with `app.registerHomepageWidget`
still goes into a registry core owns (`src/ui/plugin-host/homepage-widget-registry.ts`),
the same as `dashboard-cards-registry.ts`, so docker's, tunnels', file-manager's
and host-metrics's own homepage widgets keep working with homepage disabled;
only the canvas that renders them is gone. The frontend SDK gained
`useHostActions()` (every host action any plugin registered, for a widget
that offers a picker over what it can do with a host, such as Quick
Connect), `useActivityTypes()` and `activityTarget(type)` (the recent-activity
type list and its icon/tab/label, for a filter or an activity feed widget),
and `useHomepageWidgetTypes()`/`homepageWidgetType(id)` (the read side of
`registerHomepageWidget`, for the canvas and its add-widget menu).
`@termix/plugin-sdk/ui` gained `PluginViewPlaceholder`, and `WidgetTitle` and
`runVisibleInterval` moved from the homepage feature directory to
`src/ui/lib/` now that a plugin, not a core feature, is their biggest
caller. The shared `host`/`selectHost`/`noHostConfigured`/`loading` strings
several widget-owning plugins relied on through the `homepage` namespace's
core fallback moved to core's `common.*`, which is what that fallback is
actually for.
**Wake-on-LAN (B20)** imports nothing from core. It owns no table: `macAddress`
and `broadcastAddress` are host settings (`contributes.settings.host`), moved
out of `ssh_data.mac_address`/`wol_broadcast_address` by a boot migration in
`src/backend/utils/crypto-migration/wake-on-lan-settings-migration.ts`. A
"Wake" host action sends the magic packet, gated on `network:broadcast`
(medium, added to the capability catalog this step). It provides
`wake-on-lan.send` v1 (`wake(hostId): Promise<void>`, which resolves the
host's MAC and broadcast address itself and checks the caller's own
`wake-on-lan.send` permission), which automations (B17) already declared as
an optional dependency.
**Secret sources (B20)** imports nothing from core. It adopts `secret_sources`
(rename only; the access token is not a column - see below), and provides no
service, only `ctx.credentials.registerSecretResolver("op", resolve)`. The
1Password Connect client, its egress allowlist (an admin `privateEndpoints`
textarea, the same shape as the ai plugin's) and the source manager UI (a
component slot core's host editor and credential editor render through
`app.registerComponent("secret-sources.hint" | "secret-sources.manager", ...)`
and `PluginComponent`, replacing the static imports those two core files used
to carry) all moved in. The access token follows the pattern the ai plugin set
for provider keys: it lives in `ctx.secrets` under `"source:<id>"` rather than
in the table, so it is encrypted with the installation key rather than the
owner's data key and is readable whenever the plugin runs, not only while the
owner is signed in. A shared source's token is still only ever read for its
own owner: `resolveConnectReference` and the `/test` route both go through
`ctx.asUser(<owner>)` before reading it, so a recipient using a shared source
never touches the raw value directly. `src/backend/utils/crypto-migration/secret-sources-token-migration.ts`
moves an existing owner-DEK-encrypted token out of the column and into
`ctx.secrets` at boot, the same shape as `ai-settings-migration.ts`'s provider
key move. The `/users/secret-source-private-endpoints` route and its admin UI
are gone; the allowlist is the plugin's own admin setting now.
#### Host status is core
The status dot in the host list, the dashboard and the homepage widget is
core, in `src/backend/hosts/status/`. **B16** moved it out of host-metrics's
poller. Core opens a TCP connection to every host with status checks on
(`status_check_enabled`, a core host column), on the host's own interval
(`status_check_interval`) or the admin default (`global_status_check_interval`
under `GET/PUT /host/status/settings`), through core's jump host chain when the
host has one. It never logs in: on RADIUS or Duo backed devices a login fires
a real 2FA push. That gives "reachable" or "offline". A host is "online" when
a login worked: an open session (`ctx.hosts.trackSession`) or a plugin's
`ctx.hosts.status.reportLogin`. Polling is demand driven: a user asking for
`GET /host/status` starts their own hosts. Core emits `host.status`
(`{ hostId, ownerUserId, status, previous, online, reason? }`) when a status
changes, and `host.key.updated` (`{ hostId }`) when a changed host key is
accepted. The status half of the old `stats_config` moved into the two columns
(`host-status-config-migration.ts`); its metrics half is host-metrics's.
**Host metrics (B16)** polls only hosts somebody is looking at (a tab, a
dashboard row, a homepage widget, or another plugin through the
`host-metrics.viewers` service, v1: `register(hostId)`, `heartbeat`,
`unregister`, all as the caller). It samples over `ctx.ssh` pools and timers
from `ctx.schedule`, keeps the adaptive metrics concurrency (one worker per 20
polled hosts, 5 to 50, `METRICS_POLL_CONCURRENCY` overrides), and waits for
core to call a host reachable before the first heavy sample. It emits
`plugin.host-metrics.snapshot` (`{ hostId, ownerUserId, metrics }`) for every
sample, `plugin.host-metrics.status` (`{ hostId, ownerUserId, state,
previous }`, state `collecting`, `auth_failed`, `host_key_changed` or
`unreachable`) when collection changes, and `plugin.host-metrics.health-check`
(`{ hostId, userId, checkId, ok, detail? }`) for each health check result;
automations subscribes to them. A plugin adds data to every sample by
providing `host-metrics.collectors` (v1: `collect(client, hostId)`) under its
own provider name; the result lands under `extra[<name>]`. Proxmox does not:
its stats come from the Proxmox API, not from SSH on the host. The intervals,
history retention and the new-host default are admin settings, the
temperature unit a user setting, and the rest of `stats_config` host
settings.
**Alerts (E1)** is the one place every alert lands, from any plugin, official
or not. A plugin sends with `ctx.notify.send({ title, body?, severity?,
category?, audience?, channelIds?, link?, dedupeKey?, context? })` and never
needs to know the alerts plugin exists:
- Core resolves `audience` to user ids: nothing means the acting user,
`{ userId }` one user, `"admins"` every admin, `{ permission }` every user
who holds it. It then hands `{ source, recipients, notification }` to the
hub, the one plugin that called `ctx.notify.serve(hub)` with the
`notify:hub` capability (high risk: it sees every alert). A second hub is
refused, and a hub whose grant is withdrawn is ignored. With no hub running,
`send` reaches nobody and `channels()` answers `[]`, so a sender never
checks. `notify.hub` is core's word and names no plugin.
- `category` is `<pluginId>.<kind>` by convention (`acme-ssl.renewal_failed`,
`automations.run_failed`) and defaults to the sending plugin's id; users
route by it. `severity` is `info`, `success`, `warning` (the default) or
`critical`. `link.tab` names a tab type the inbox opens; `link.url` must be
http(s). An alert with a `dedupeKey` is dropped while an unread one with the
same key is in that user's inbox.
- The alerts plugin stores each alert once per recipient in
`p_alerts_items`, pushes it to the user's open `GET /stream` (server-sent
events), and sends it to that user's channels: the ones their delivery
rules match (`p_alerts_rules`: a category pattern such as `docker.*` and a
lowest severity) plus the `channelIds` the sender named, only ever the
recipient's own. Channels (`p_alerts_channels`: webhook, ntfy, Discord and
email over the admin's SMTP server) keep their config sealed with
`ctx.secrets.seal`, so an alert sent at night with no user signed in is still
delivered. Private addresses need the channel's opt-in and an exact host in
core's `notification_private_endpoint_allowlist`, which it reads with
`ctx.settings.readCore`.
- Termix announcements (`termix-alerts.json` in the Docs repo, which core
fetched in 2.8) come into each inbox the first time the user looks, and one
the user deletes is recorded in `p_alerts_dismissed` (adopted from core's
`dismissed_alerts`) and never comes back. They never go to channels.
- The upgrade copies core's `notification_channels` into `p_alerts_channels`
with the same ids and reseals each config once its owner's data key opens
(`upgrade/notification-channel-migration.ts`, at boot and at a password
login). The old table stays, emptied of configs, until 3.0.0.
- The frontend puts a bell at the bottom of the rail (a rail item with
`placement: "footer"` and a `useBadge` hook showing the unread count), an
inbox in its panel, and inbox, channels and delivery rules in its tab. New
alerts pop up through a `shell.overlay` component, as far as the user's
`popups` setting allows. Other plugins open it with the `alerts.open` and
`alerts.openChannels` actions. Automations sends its notify steps and
failed runs here, and acme-ssl a failed renewal to every admin.
### 10. Lifecycle
`activate(ctx)` creates all state. Everything registered through `ctx` or `app`
is disposed automatically on deactivate. See [Lifecycle rules](#lifecycle-rules).
### 11. Tests
The security regressions for the runtime live in
`src/backend/tests/plugins/security/` (one file per area of the D2 review) and
`src/ui/tests/plugin-host/security/`. A fix to plugin code gets its test in
that plugin's own `tests/`, like everything else.
Each plugin has `plugins/<id>/tests/backend` (node) and
`plugins/<id>/tests/frontend` (jsdom), run by its own vitest config:
```ts
// plugins/<id>/vitest.config.ts
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
export default pluginVitestConfig(import.meta.url);
```
The preset supplies both projects, the shared setup file
(`@termix/plugin-sdk/testing/setup`, which core's `vitest.setup.ts` re-exports
so there is one copy) and the `@/` alias the frontends still use. The setup
also keeps a test server started on port 0 off the ports `fetch` refuses,
which Windows can hand out.
`npm run test` runs core only, `npm run test:plugins` runs every plugin and
`npm run test:all` runs both. Core tests (`src/backend/tests`, `src/ui/tests`)
never test plugin code; they may test the plugin runtime with fixture plugins,
which is why `src/backend/tests/plugins/` still holds the loader, manifest,
capability, registry, service, secret and shutdown suites.
`@termix/plugin-sdk/testing` provides `createMockCtx()`, which enforces
capabilities the way the runtime does, `createFakeContext()` for tests that do
not care about the gates, `createTestDb(pluginDir)` for a real database, and
`renderWithApp(plugin, options)` for frontend tests.
`createTestDb(pluginDir, { before })` opens an in-memory SQLite with stub
`users` (with `is_admin`, **B12**), `ssh_data`, `roles` and `user_roles` tables and foreign keys on, runs
`before` (seed a legacy table there to test an adoption), then applies the
plugin's own `migrations/sqlite` with the runner's splitter. `ctx.db.refs()`
against this database returns the same four, built by `buildRefTable`, which
maps each declared property to its real snake_case column (`displayName` to
`display_name`) the same way `buildTable` does, so a repository written
against the real schema's property names works unchanged in a test. Pass its
`database` to
`createMockCtx({ db })`: `define` builds the real table, `client` is Drizzle, and
`persisted` counts `persist()` calls. The mock also takes `router` (for example
`() => express.Router()`, so routes are served for real), `permissions`
(enforced by `ctx.rbac`, answering 403 like core; omit it to pass everything)
and `hosts` (`PluginHostSummary[]`, what `ctx.hosts.list/get/checkAccess`
serve - **B8** added this option, since it existed on `createFakeContext`
already but was not forwarded), and returns `setActor(userId)` for a test
middleware and `services` for what the plugin provided. **B12** added a
`services` option seeding other plugins' services for a consumer's test,
keyed `<service>` or `<service>#<provider>`, and the doubles now answer a
missing service with an empty handle, like the runtime, instead of throwing. **B8** also gave
`ctx.registry` on both doubles a real in-memory backing (it was a no-op stub
before), so `provide`/`consume`/`revoke` round-trip the way the real registry
does. `plugins/workspaces/tests/backend/helpers.ts` is the worked example.
The doubles' `wsRoutes` (**B15**) record each route's `handler` (or the
`upgrade` callback) and `options`, so a test can hand a route a fake socket;
docker's `tests/backend/console.test.ts` opens a console, disposes the plugin
and activates it again that way.
`renderWithApp` also takes `api` (a stub for `app.api` and `usePluginApi()`),
`layout` (what `app.tabs.getLayout()` returns) and `ready` (fire
`app.tabs.onReady`). `app.tabs.applyLayout` is recorded in `shellCalls` and runs
the shell's real restore rules, `openedTabs()` lists what it opened, and
`renderOpenedTab(i)` renders one the way the shell would, placeholder included.
**C1** added `registered.loginMethods()` / `.secondFactors()` and
`renderLoginMethod`, `renderSecondFactor` and `renderEnrollment`, and the
backend doubles take `refuseEnrollment` to make `recordEnrollment` refuse the
way core's policy does. `renderWithApp` activates the plugin against core's real registries,
records what it registered and the shell calls it made, renders any tab,
panel, card, host editor section, settings component or slot, and
`deactivate()` disposes it all. Every bundled plugin has a
`tests/frontend/activate.test.tsx` built on it.
### 12. Auth
Core keeps password login, sessions, API keys, trusted proxy login, Electron
auto-session and trusted devices, plus the base SSH auth types password, key,
stored credential, agent and none. Every other login method, second factor and
SSH auth method is a plugin through `ctx.auth`.
**C5** moved Vault, the last SSH auth method core still registered, into
its plugin and deleted `src/backend/auth/legacy-providers.ts`. **C4** had
already deleted `src/ui/auth/legacy-auth-ui.tsx`. Core registers only the
base types and branches on no plugin type name.
**C1** moved TOTP and passkeys out:
- `totp` registers the `totp` second factor. It owns `p_totp_enrollments`
(one row per user: the active secret, an unfinished setup's secret and the
backup codes, all sealed with `ctx.secrets.seal`), so `verify` reads them
during login with no acting user. Its routes at `/plugin-api/totp/`
(`status`, `setup`, `enable`, `disable`, `backup-codes`) re-authenticate
with a TOTP or backup code, never the account password, which a plugin
cannot see. `enable` calls `ctx.auth.recordEnrollment` before it turns
anything on, so core's policy can refuse it cleanly.
- `webauthn` registers the `passkey` login method (inline on the login
screen) and adopts `webauthn_credentials`. A passkey is a login method only,
as in 2.8: an assertion whose authenticator verified the user returns
`mfaSatisfied`, which skips core's second-factor step; without it the user
still gets their factors. The relying party id and origin come from the
request exactly as before (no core setting holds them); the relying party
name is `ctx.settings.readCore("app_name")`.
`POST /plugin-api/webauthn/authenticate/options` is public; the assertion
goes to core's `/users/auth/passkey/verify`.
- `src/backend/utils/crypto-migration/totp-migration.ts` takes a 2.8
database across: every `core`/`totp` enrolment row (A8) becomes
`totp`/`totp`, every user with `users.totp_enabled` gets one, and once
the plugin's table exists each secret and its backup codes are decrypted
with the user's data key, sealed into it, and the `users` columns cleared.
The rows move even while the plugin is off, so those users fail closed. The
`users` TOTP columns left `schema.ts`, and the drizzle migration that would
drop them is a no-op, like an adopted table.
- Core keeps no TOTP knowledge. `totp_enabled` in the login body and
`/users/me` is still sent under that name for 2.8 clients (Termix-Mobile,
older remote-sync peers) but means "has any second factor"; the admin user
list sends `second_factor_enabled`. Trusted proxy login, "disable password
login" and a password reset that wipes data go through
`user_second_factors` and `resetUserSecondFactors`.
`/users/admin/totp/disable` is gone; the generic admin reset covers it.
`AuthManager.unlockWithSystemKey` (was `authenticateWebAuthnUser`) opens
the data key for any identity that carries no password.
**C2** moved external logins out:
- `sso` registers the `oidc` login method: OIDC, GitHub and Google, one
button per enabled provider (the method's instances), plus the provider
configured through the `OIDC_*` environment variables as instance `0`. It
adopts `sso_providers` as `p_sso_providers`, ignoring any `ldap` rows left
in it. Its public routes are `/plugin-api/sso/start` (sends the browser to
the provider), `/callback` (GET and POST), `/backchannel-logout` and
`/config`; provider management is `/plugin-api/sso/providers`, gated on
`sso.manage`, and renders as the custom `providers` field in the plugin's
Admin Settings section. Login state (nonce, PKCE verifier, return address)
lives in `ctx.kv` for ten minutes.
- The redirect URI is `<base>/plugin-api/sso/callback`. Providers that
existed before 2.9 keep sending `<base>/users/oidc/callback`, the URI their
identity provider was registered with, because the sso plugin's second
migration sets `legacy_callback` on every adopted row; the env provider
always does. Core answers that old URL with a 308 to the plugin's callback,
and the token exchange sends the same redirect URI the authorize request
did, so strict providers accept it. The admin page shows each provider's
URI and switches it to the new one once the admin has registered that.
`/users/oidc/backchannel-logout` is a 308 to the plugin as well.
- `ldap` registers the `ldap` form method, one form per directory, in its own
`p_ldap_providers`. `utils/crypto-migration/ldap-provider-migration.ts`
copies the 2.8 LDAP rows out of `sso_providers` (adopted or not) with their
ids and deletes them there. It runs from the loader whenever a plugin's
migrations applied something (`plugin-data-moves.ts`), so the rows are in
place before the ldap plugin first activates, at boot or when it is enabled
later. LDAP identities are stored under provider `ldap:<id>` so their ids
can never clash with SSO ones; `external-identity-migration.ts` writes them
that way and the LDAP migration renames any bare ones.
- Both return 2.8's `users.oidc_identifier` value in `legacy`, so pre-2.9
accounts are found and new ones keep the column filled. The sso plugin
passes its group role map as `roles` and the id token's `sub`/`sid` as
`logoutClaims`; `/backchannel-logout` ends those sessions with
`ctx.auth.revokeSessions`. LDAP counts failed binds with
`ctx.auth.loginRateLimit`, the limiter password login uses.
- Core keeps provisioning (the allowed-users matcher is
`auth/allowed-users.ts`), `oidc_auto_provision`, silent sign-in (it starts
the first redirect method), account linking and the trusted proxy rule:
every `external` method is refused while trusted proxy login is on, and
`/users/proxy-login` refuses while any external method lists an instance.
The 2.8 client routes `/users/oidc/authorize`, `/users/oidc-config` (307 to
`/plugin-api/sso/config`), `/users/sso-providers` and `/users/ldap/login`
live in `database/routes/auth-compat-routes.ts` and only forward to the
registered methods, so with the plugins off they answer 404.
**C3** moved OPKSSH and Warpgate out:
- `opkssh` registers the `opkssh` SSH auth type. It adopts `opkssh_tokens` as
`p_opkssh_tokens`, the certificate and key sealed with `ctx.secrets.seal`.
Rows 2.8 wrote were encrypted with the user's data key, which a plugin cannot
open: they read as expired and the user signs in once, which is what a
cached certificate is for (at most 24 hours old).
- The binary comes from `ctx.process.ensureBinary`: the copy the Docker image
bakes into `/app/opkssh-bundled` (`OPKSSH_BUNDLED_DIR`) when its checksum
matches, else the one in the plugin's data folder, else a download pinned to
the release's SHA-256. `opkssh login` runs through `ctx.process.run` and is
killed on cancel, close and deactivate. The config lives at
`<DATA_DIR>/plugin-data/opkssh/config.yml`; `redirect_uris` there are still the
localhost listener candidates only, and the public callback still goes to
OPKSSH as `--remote-redirect-uri`.
- The chooser and callback pages are public plugin routes
(`/plugin-api/opkssh/chooser/:id/*`, `/callback`, `/callback/:id/*`).
New installs register `<base>/plugin-api/opkssh/callback` with their
identity provider. `utils/crypto-migration/opkssh-config-migration.ts`
(run from `plugin-data-moves.ts`) copies a 2.8 `.opk/config.yml` in and
turns on the admin setting `legacyCallback`, so an upgraded install keeps
sending `<base>/host/opkssh-callback`, which core answers with a 307 to the
plugin (`database/routes/host-compat-routes.ts`). The admin page shows the
URI to register.
- The sign-in dialog is a `terminal.overlay` contribution. The terminal knows
no sign-in by name: it forwards `<interaction>_start_auth`, `_cancel` and
`_auth_completed` to `ctx.ssh` for any provider.
- `warpgate` registers a keyboard-interactive handler
(`ctx.auth.registerKeyboardInteractiveHandler`) and the host setting
`useWarpgate`, which `warpgate-settings-migration.ts` fills from
`ssh_data.use_warpgate` (and from hosts still on auth type `warpgate`).
The column is out of `schema.ts`; its drizzle drop is a no-op.
- A handler's browser round is the generic `browser` decision
(`{ id, label, url, code, instructions }`). The terminal sends it as
`<id>_auth_required` and takes `<id>_auth_continue`, so Warpgate keeps its
2.8 message names for Termix-Mobile; the file manager and docker answer
`requires_browser_sign_in` and continue on `connect-browser-sign-in`.
- `@termix/plugin-sdk/ssh-certs` (`applyCertificateAuth`) replaces core's
`opkssh-cert-auth.ts`: opkssh, step-ca, core key auth with a CA
certificate, and vault use it.
**C4** moved Step-CA out:
- `step-ca` registers the `stepca` SSH auth provider. Termix never runs the
`step` binary: the plugin talks to the CA's HTTPS API and the identity
provider through `ctx.fetch` (`network:outbound`), using its `tls` option
to bootstrap the CA root by fingerprint and pin it afterwards.
- Issued certificates live in its own table, `p_step_ca_certs`, sealed with
`ctx.secrets.seal`, until the certificate's `validBefore`. 2.8 cached them
in `opkssh_tokens`; those are left to expire.
- The CA URL, fingerprint, provisioner and private endpoint list are admin
settings. `step-ca-settings-migration.ts` copies the 2.8 `step_ca_*`
settings rows in and turns on `legacyCallback` when a CA was configured,
so an upgraded install keeps sending `<base>/host/step-ca-callback`, which
core answers with a 307 to `/plugin-api/step-ca/callback`.
- With `REDIS_URL` set, a callback that lands on another instance is handed
to the one that started the sign-in through Redis
(`TERMIX_STEP_CA_REDIS_PREFIX`), as in 2.8.
- The sign-in dialog is a `terminal.overlay` contribution speaking
`stepca_*`.
**C5** moved HashiCorp Vault out:
- `vault` registers the `vault` SSH auth provider. It adopts
`vault_profiles` and `vault_tokens` as `p_vault_profiles` and
`p_vault_tokens`. Profiles keep their ids and sync ids; cached
certificates are sealed with `ctx.secrets.seal`, and the ones 2.8 wrote
with the user's data key do not unseal, so they are dropped and cost one
sign-in. A fresh install has no foreign key from a token to its profile
(the SDK has no builder for one to a plugin's own table), so deleting a
profile deletes its tokens in code.
- A host's profile is the `profileId` host setting (hidden, edited in the
plugin's `registerSshAuthEditor` editor, which also holds the profile
manager where the host editor always had it).
`vault-settings-migration.ts` copies `ssh_data.vault_profile_id` into it;
the column is out of `schema.ts` and its drizzle drop is a no-op.
- Vault is reached through `ctx.fetch`, with the profile's own Vault host
passed as `allowPrivateHosts`, since Vault servers are usually internal.
- Profile routes are `/plugin-api/vault/profiles`, gated on `vault.use`;
sharing a profile needs `vault.share` (admins by default), which replaces
2.8's admin check. Only the owner edits or deletes one.
- The sync entity keeps its wire name `vaultProfiles` and order 20. Hosts no
longer reference it, so core's host entity lost its `vaultProfileId`
reference and `sync-references.ts` has no Vault case.
- The callback is `/plugin-api/vault/oidc/callback` (public). The boot
migration decides `legacyCallback` once: on when the 2.8 database used
Vault, so Termix keeps sending `<base>/vault/oidc/callback`, the URI Vault
roles allow, which core answers with a 307 to the plugin
(`registerVaultCompatRoutes` in `host-compat-routes.ts`; nginx proxies
only that path now).
- The sign-in dialog is a `terminal.overlay` contribution. It keeps the 2.8
message names (`vault_auth_url`, `vault_completed`, `vault_error`) for
Termix-Mobile.
**C6** moved Termix Identity out. It never was an SSH auth type: issued
certificates are downloaded once and never stored, and no connect path reads
them, so `termix-identity` is a standalone feature plugin with no provider.
- It adopts `termix_identities`, `termix_identity_keys` and
`termix_identity_ca` as `p_termix_identity_identities`, `_keys` and `_ca`.
The migrations keep, by hand, the links from keys and CA to their identity
(cascade) and from a key to `ssh_credentials` (set null).
- The CA private key is sealed with `ctx.secrets.seal` (`secrets:own`), so
issuing a certificate no longer needs the owner's data key unlocked.
`termix-identity-ca-migration.ts` reseals 2.8 rows, which core encrypted
with the owner's data key; it runs from `runPluginDataMoves` right after
the adoption and again at boot for a row whose key was not open.
- The resolver, `/plugin-api/termix-identity/u/<handle>[/<ALGO>|/ca]`, is
public through `ctx.http.router({ public })`. The 2.8 URLs under
`/termix-id/u/` answer 308 to it (`registerTermixIdCompatRoutes` in
`host-compat-routes.ts`; nginx proxies only `/termix-id/u/` now), because
servers fetch them from provisioning scripts. The management routes moved
and are gated on the new `termix-identity.use` (admins and users).
- Publishing a saved key and saving a generated pair go through
`ctx.credentials.listSshKeys` and `createSshKey` (**C6**, see the ctx
surface): the plugin never sees a stored private key.
- The rail item keeps its id `termix-id`, so saved layouts, hidden-rail
preferences and presets carry over, and uses `app.desktop` to stay hidden
in a standalone desktop app, as the shell did before.
#### One SSH connect pipeline
Every SSH connection, core's and plugins', goes through
`src/backend/hosts/connect/`:
1. `resolveHostById` resolves the host for the acting user: RBAC, owner-key
decryption, shared-host overrides and shared agent, and `op://` external
secret references.
2. `buildConnectConfig` builds the ssh2 config: the defaults for the
connection's profile (terminal, session, stream, forward, background or
jump), the host key verifier, then the auth provider's `prepare`.
3. `openSshTransport` does port knocking, the Cloudflare Access tunnel, the
jump host chain (every hop through steps 1 and 2) or SOCKS5.
4. `connectHost` connects with a keyboard-interactive handler: a prompt channel
when a person can answer (terminal, file manager, docker), otherwise the
stored password for password prompts and nothing else.
Keyboard-interactive rounds are classified once (`classifyKeyboardInteractive`):
a plugin's keyboard-interactive handler first, then push MFA, TOTP, and plain
input. The transport decides how to ask.
A host whose `authType` has no provider fails with
`This host uses <type>, which needs the <plugin> plugin` (the plugin is found
from `contributes.auth.sshAuthTypes`, including disabled plugins), and the host
editor shows the same notice.
#### SSH auth providers
```ts
ctx.auth.registerSshAuthProvider({
type: "corp-ca", // stored in ssh_data.auth_type
labelKey: "authType",
requiresSecret: false, // shared hosts: does a recipient need a secret
needsUserInteraction: true, // a browser sign-in or a person at the keyboard
supportsBackground: false, // can metrics and fleets poll it unattended
credentialType: false, // also offered as a stored credential type
interaction: "corp-ca", // what its outcomes call the browser step
fields: [/* settings fields */],
connectOptions: (host, purpose) => ({/* config overrides */}),
prepare: async (config, host, env) => {
// fill config (env.client is the ssh2 Client, for certificate patching)
return { status: "ready" };
// or { status: "interaction-required", interaction: "corp-ca", message }
// or { status: "error", code: "missing-secret", message }
},
onKeyboardInteractive: (round, host) => null, // claim a prompt style
onAuthFailed: (host, env, context) => undefined, // clear caches, retry once
onBanner: (banner, host, env) => undefined,
// claim an ssh2 "banner" event during the handshake, for a server that
// holds the connection open pending an out-of-band step (Tailscale SSH
// check mode). Return { action: "hold", timeoutMs, message, details? } to
// extend the connect timeout and have the terminal send
// "<type>_check_required" with `details` merged in, or
// { action: "release", details? } once the banner says it is done, which
// sends "<type>_check_completed". Undefined leaves the banner unhandled.
// B3 (tailscale) is the only caller today.
startInteraction: async (request) => {}, // start the browser step
});
```
The type must be listed in `contributes.auth.sshAuthTypes`, and the plugin
needs `auth:provide`. The grant is checked on every `prepare`. `fields` are the
plugin's own host settings (declare them in `contributes.settings.host` too):
the host editor renders them when the plugin registered no editor, and
`prepare` reads them with `ctx.settings.getHost(host.id, key)`. A frontend can
instead draw its own editor with `app.registerSshAuthEditor`.
A transport that can show a browser step sends `<interaction>_auth_required`
and later calls the provider's `startInteraction` for `<interaction>_start_auth`.
A server that speaks its own prompt style on any host, whatever its auth type
(an SSH bastion asking for a browser approval), is a keyboard-interactive
handler instead (**C3**):
```ts
ctx.auth.registerKeyboardInteractiveHandler({
id: "gate", // listed in contributes.auth.keyboardInteractive
label: "Gate", // shown in the sign-in dialog title
detect: (round, host, settings) =>
/gate/i.test(round.name)
? { kind: "browser", url, code, instructions: round.instructions }
: null,
autoAnswerPasswords: (host, settings) => settings.enabled === true,
});
```
`settings` is the plugin's own host settings for the host, defaults filled
in: the host resolver puts every plugin's host settings on the connect host
(`pluginSettings`) because both hooks run synchronously inside ssh2's
callback, and the handler only sees its own slice. Needs `auth:provide`; the
declaration is checked at registration, and deactivate removes the handler.
#### Login methods and second factors
```ts
ctx.auth.registerLoginMethod({
id: "corp-sso",
labelKey: "signIn",
kind: "redirect",
external: true, // governs the "second factor after external logins" setting
describe: async () => [{ id: "main", label: "Corp", enabled: true }],
start: async (request, instanceId) => ({ redirectUrl }),
callback: async (request) => ({
kind: "external",
provider: "corp",
subject: "123",
email,
name,
groups,
isAdmin,
allowedUsers,
returnTo,
rememberMe,
}),
});
ctx.auth.registerSecondFactor({
id: "pin",
labelKey: "pin",
isEnrolled: async (userId) => true,
challenge: async (userId) => ({/* for the UI */}),
verify: async (userId, body) => body.pin === "1234",
reset: async (userId) => {},
});
await ctx.auth.recordEnrollment(userId, "pin"); // and removeEnrollment
```
A method never issues a session. It returns an identity: an existing user id,
or an external identity. Core then:
1. finds the user through `user_external_identities` (provider, subject), or
provisions one with the core rules: first user is admin, the allowed-users
list is checked on every sign-in, provisioning needs SSO auto-provisioning,
admin approval is a hook that is off today, the admin group and role map
are kept in step;
2. unlocks the data key (the password for password logins);
3. runs second factors: every row in `user_second_factors` plus any factor
that reports itself enrolled. **Fail closed:** a row whose plugin is
disabled or gone refuses the login ("contact an admin"). A trusted device
or a method that already proved a factor (a verified passkey) skips the step.
This step is skipped entirely for a login method whose registration sets
`external: true` (OIDC, LDAP; a plugin declares it the same way) unless the
admin setting "ask for a second factor after external logins" is on. Off
by default, matching the behaviour before second factors applied to every
method: a local method (password, a passkey, a plugin's own local form)
always runs them;
4. issues the JWT, cookie, trusted device and the `login` audit line.
Routes, all under `/users/auth`: `GET methods` (public), `GET :methodId/start`,
`GET|POST :methodId/callback`, `POST :methodId/verify`,
`POST second-factor/:factorId/challenge`, `POST second-factor/:factorId/verify`.
A redirect method's provider sends the browser back to
`/users/auth/<methodId>/callback`, or to a public route of the plugin's own
that calls `ctx.auth.completeRedirectLogin(methodId, req, res)`, which runs
the same callback and pipeline (**C2**, for a URL that has to stay stable in
the provider's configuration). The older routes (`/users/login`,
`/users/oidc/*`, `/users/ldap/login`) are thin wrappers over the same
pipeline.
**Identity extras (C2).** An external identity may also carry `roles`
(`{ desired, managed }`: core adds the desired roles and removes managed ones
the user no longer maps to), `logoutClaims` (`providerId`, `sub`, `sid`,
stored on the session for `ctx.auth.revokeSessions`), `legacy` (the 2.8
`oidc_identifier` and `sso_provider_id`, only for a plugin that took over a
2.8 method) and `rateLimitKey` (cleared on success; keys are kept apart per
plugin). Core copies only these documented fields, so a plugin cannot set
core-only ones. `ctx.auth.countLinkedUsers(provider)` answers how many users
sign in through a provider, for refusing to delete one still in use.
`ctx.http.baseUrl(req)` is the install's public URL (origin plus base path,
honouring `OIDC_FORCE_HTTPS`) for building callback URLs. `/users/totp/verify-login` stays for 2.8 clients: it answers with
the factor named in `factor`, or the user's first required factor, and core
never names one itself.
**Enrolment policy (C1).** `ctx.auth.recordEnrollment` refuses with a
`LoginMethodError` (409) while trusted proxy login is on or the password
login setting is off, the rules the 2.8 TOTP route had, now applied to every
factor. When it records one it also revokes the user's other sessions and
forgets their trusted devices, so nothing that skipped the factor before
keeps skipping it. The session to keep is the request's: the HTTP middleware
puts the session id next to the actor (`getActorSessionId`).
An admin clears a user's factors, including orphaned ones, with
`DELETE /users/admin/:userId/second-factors` (audited as
`admin_reset_second_factors`).
Password login is always a method. The admin "allow password login" setting
still turns it off, but only while another method (an enabled SSO instance,
or trusted proxy login) can sign someone in; otherwise it stays on with a
warning so nobody is locked out.
#### Login UI before sign-in
The login screen loads the frontends of enabled plugins that contribute login
methods or second factors from the public `GET /plugins/public-manifest`
(ids, versions, asset info and `contributes.auth` only), before anyone signs
in. `app.registerLoginMethod` draws a button or form for a method the server
lists (props: `instances`, `rememberMe`, `submit`, `startRedirect`,
`complete`); a redirect method with no UI gets one button per instance.
`app.registerSecondFactorUI` draws the challenge (props: `verify`,
`challenge`, `cancel`) and, with `enrollment`, a section in Settings >
Security. **C1** gave `app.registerLoginMethod` the same `enrollment` (a
passkey is registered there) and `placement: "inline"`, which also draws
the method under the password form; the login screen no longer knows the
passkey method by name. A server that sends a second-factor step without
naming its factors gets every factor UI registered on the screen. At activation before sign-in `app.api` calls fail with 401, so do
nothing there but register.
### 13. Where plugins live
Official plugins live in `plugins/<id>/` as npm workspace packages in this
monorepo for all of 2.9.0, and get split into their own repos later. Every
plugin is enabled by default in 2.9.0; there is no install UI until 3.0.0.
The layout is fixed, and the directory name must equal the manifest `id`:
```
plugins/<id>/
manifest.json id, capabilities, contributions
package.json @termix-plugin/<id>, private, scripts call the CLI
tsconfig.json extends @termix/plugin-sdk/tsconfig.plugin.json
vitest.config.ts one line, the SDK preset
src/backend/index.ts exports activate(ctx) and deactivate()
src/frontend/index.tsx exports activate(app) and deactivate()
locales/en.json English strings; Crowdin writes locales/translated/
migrations/{sqlite,postgres,mysql}/ owned tables, one .sql per dialect
migrations/snapshot.json diff basis for the generator
src/backend/tables.ts defineTable() definitions
tests/backend/ vitest, node
tests/frontend/ vitest, jsdom
README.md CHANGELOG.md icon.svg
dist/ build output, gitignored
```
#### Which plugins ship, and from where
`docker/bundled-plugins.json` lists every plugin the image ships and where
each comes from. `npm run build:plugins` (`scripts/build-plugins.cjs`) reads
it and stages each one in `dist/plugins/<id>/`:
```json
{ "id": "tunnels", "source": "workspace" }
{ "id": "tunnels", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/termix-plugin-tunnels/releases/download/v1.0.0/tunnels-1.0.0.tmxplug",
"sha256": "<64 hex>" }
```
A `workspace` entry is built from `plugins/<id>/`. A `tmxplug` entry is
downloaded (cached under `node_modules/.cache/termix-bundled-plugins/`) or
read from a local `path`, refused unless its sha256 matches the one pinned in
the file, and unpacked. The build fails on a `plugins/` folder nobody listed,
a workspace entry with no folder, or a tmxplug entry whose folder still
exists, so the two can never both ship.
Switching a plugin over once its own repo exists:
1. Tag a release in the plugin's repo. Its release workflow publishes
`<id>-<version>.tmxplug` and the `.sig`.
2. Replace the plugin's entry with `source: "tmxplug"`, the release `url` and
the `sha256` the workflow printed (or `sha256sum` of the download).
3. Delete `plugins/<id>/` and run `npm install` so the workspace goes away.
4. Run `npm run build:plugins` and check `dist/plugins/<id>/` holds the
plugin.
A tmxplug plugin is not in the Vite dev server's plugin glob, so `npm run dev`
does not hot-reload it; its built frontend still loads from
`/plugin-assets/<id>/`.
### 14. Upgrades must be lossless
Every move of data, settings or host columns into a plugin comes with a
migration for all three dialects and a test proving existing data survives.
#### Moving a host column into a plugin
A6 built the host-scope settings path but moved no columns; each Phase B step
moves its own. `ssh_data` still holds `enable_docker`, `stats_config`,
`web_ui_config`, `enable_proxmox`, `enable_rdp`, `mac_address` and the rest,
and the hardcoded host editor tabs still read them. The order matters, because
dropping a column before its data has moved loses it:
1. Declare the fields in the plugin's `contributes.settings.host`, keeping the
key names the form already uses so the editor tab can be deleted rather than
rewritten.
2. Write a boot migration that copies each row's column into host-scope
`plugin_settings` for that plugin, keyed by the host id as text. Make it
idempotent by checking for an existing row first, the way
`tailscale-settings-migration.ts` does, and cover it with a test that runs
it twice.
3. Switch the plugin's backend to `ctx.settings.getHost` and delete the
hardcoded editor tab; the schema-driven Plugins group replaces it.
4. Only then drop the column, in lockstep across all four artifacts: the
`schema.ts` declaration, the `db/index.ts` bootstrap DDL, a drizzle
migration per dialect, and `npm run schema:generate` for the pg and mysql
variants. The two bootstrap guard tests fail if these disagree.
A column that is still read anywhere in core is not ready to be dropped. Ship
the copy and the switch first, and drop it in a later step if that is safer.
**Order at boot (B14).** On Postgres and MySQL, core's drizzle migrations run
while the database opens, and the copies into `plugin_settings` run later,
after the plugins table exists. A real `DROP COLUMN` in step 4 therefore
deletes the data before it is copied. So step 4 removes the column from
`schema.ts`, the pg/mysql variants and the `db/index.ts` bootstrap, and the
generated drizzle migration's `DROP COLUMN` statements are replaced with a
comment and `SELECT 1;`, exactly as for an adopted table. The column stays in
the database, unused, until 3.0.0 removes it. A copy reads it with raw SQL
through `utils/crypto-migration/raw-rows.ts`, whose `selectRows` and
`runStatement` work on all three engines: `getDb().all()` exists only on
SQLite. Remote desktop's host options and `user_preferences.rdp_defaults` are
the first columns handled this way. **B16** split `stats_config` the same way, into
two core columns and host-metrics's host settings. Adding columns and
dropping one in the same drizzle-kit run makes it ask whether that is a
rename, which it cannot do without a terminal, so B16 generated two
migrations: the added columns first, then the no-op drop.
**D0** dropped the rest this way (the file manager, tunnels, web endpoint,
terminal, tmux monitor, session sharing and session recording columns and the
dead `show_*_in_sidebar` ones), rewrote the two proxmox drops that really ran
`DROP COLUMN` on Postgres and MySQL, and moved every copy onto
`selectLegacyRows`, which reads a column schema.ts no longer declares on all
three engines and answers nothing on a database that never had it. A 2.8
boolean read raw goes through `legacyFlag`, since SQLite and MySQL hand back
0 and 1. The copies live in `src/backend/plugins/boot-migrations.ts`
(`runPluginDataMigrations`), which the starter runs after the plugins
activate and which runs again when an admin enables a plugin without a
restart. The core half of boot (keys, auth, core data migrations) is
`runCoreBootMigrations` in `src/backend/boot.ts`; the boot integration tests
share both with the starter.
A host switch whose 2.8 column defaulted to on declares `enableDefault: true`
(file manager, terminal), because a copy only writes the hosts that changed
it, and every other host has to keep reading on.
**Before activate, not after.** A copy that sets how long a plugin keeps
history goes in `src/backend/upgrade/plugin-data-moves.ts`
(`runPluginDataMoves`), which the loader runs once a plugin's migrations
applied and before its `activate`. A plugin that prunes on activate would
otherwise prune by its own default first: session recording's retention
(and its 2.8 files) and host metrics' history retention move there.
#### Proving it (D4)
- **The fixture.** `src/backend/tests/fixtures/upgrade/` is a 2.8 install:
`db.sqlite` in 2.8's exact schema (`sqlite-2.8-schema.sql`) with rows in
every table `LEGACY_TABLE_OWNERS` lists, every host flag and 2.8 auth type,
TOTP, passkey, SSO and LDAP users, custom roles and the moved settings keys;
`fixture.env` with the keys it was encrypted with; and `files/`, the
recording files its rows point at. `rows.ts` is the source, and
`npx tsx scripts/build-upgrade-fixture.ts` rewrites the rest, encrypting
the columns 2.8 encrypted the way 2.8 did. `upgrade-fixture-data.test.ts`
fails when a legacy table has no rows.
- **The test.** `src/backend/tests/plugins/upgrade-fixture.test.ts` boots it
through `bootCore` (the starter's order: backup, database, core migrations,
plugins, data copies) against `dist/plugins`, checks every row, setting,
secret, role, login and sync type through plugin routes, settings and ctx,
then boots the same data directory again and checks that nothing changed.
- **Postgres and MySQL.** `scripts/lib/upgrade-sql.mjs` prints every statement
the upgrade runs on each engine, snapshotted in
`fixtures/upgrade/upgrade-{postgres,mysql}.sql` by `scripts/upgrade-sql.test.ts`,
which also refuses a `DROP TABLE` of a legacy table or any `DROP COLUMN`.
`bash scripts/upgrade-check.sh` starts both servers in Docker, builds the
2.8 schema from the tag's own drizzle migrations, copies the fixture in and
runs the same test with `TEST_DIALECT` set.
- **The backup.** `backupBeforeUpgrade` (`src/backend/boot.ts`) copies the
SQLite file before anything opens it. Each upgrade that rewrites the
database has its own marker (`ensurePreupgradeBackup({ reason })`); 2.9.0's
is `pre-plugin-runtime`, so an install that already has the 2.5 refactor's
marker still gets a copy. The test checks the copy is the 2.8 file byte for
byte and that a second boot makes no new one.
**Left for 3.0.0.** These stay in 2.9.0 so a downgrade still works, and go
once every install has booted it:
- The moved `ssh_data` columns (every plugin host column above, the proxmox,
remote desktop, docker, warpgate, vault and stats columns) and
`user_preferences.rdp_defaults`.
- `users.totp_secret`, `totp_enabled` and `totp_backup_codes`; `users.is_oidc`,
`oidc_identifier`, `sso_provider_id` and the 2.8 per-user OIDC columns, and
`sessions.sso_provider_id`, `oidc_sub` and `oidc_sid`, once nothing needs the
2.8 shape.
- The legacy settings rows the plugins copied: the terminal's
(`terminal_session_*`, `command_history_enabled`, `touch_input_settings`,
`terminal_image_*`), `session_sharing_globally_enabled`, `guac_enabled`,
`guac_url`, the `step_ca_*` rows and `acme_ssl_settings`.
- `DATA_DIR/.opk`, `DATA_DIR/certbot` and `DATA_DIR/acme-webroot`.
- The 2.8 URLs kept for identity providers and Termix-Mobile:
`auth-compat-routes.ts` (`/users/oidc/*`, `/users/ldap/login` and the rest),
`host-compat-routes.ts` (`/host/opkssh-callback`, `/host/step-ca-callback`)
and the `/termix-id/u/` and `/vault` redirects.
- The `@termix/plugin-sdk/ui` typings for plugins built outside this repo,
which arrive with the split into separate repositories.
---
## Manifest v2 reference
`plugins/<id>/manifest.json`. The directory name must equal `id`. Unknown
fields are rejected at every level: a typo'd `contribute` used to validate
clean and be silently dropped, which made a manifest describe something it did
not do.
**D2** added the discovery rules:
- `RESERVED_PLUGIN_IDS` (`admin`, `hosts`, `credentials`, `core`, `termix`,
`plugin`, `plugins`, `users`, `system`, `sdk`) are refused. A plugin's
permissions are registered as `<id>.<name>`, so a plugin called `admin`
would otherwise mint `admin.*` permissions.
- `backend`, `frontend` and `locales` must be relative paths inside the
plugin: no absolute path, no `..`, no backslash. The loader also realpaths
the backend entry and every migration file and refuses one that resolves
outside the plugin, so a symlink cannot point elsewhere.
- A plugin whose table prefix overlaps a loaded plugin's (`foo` and
`foo-bar` both own `p_foo_bar_*`) is refused, bundled first.
- A user plugin can never take the id of a bundled plugin folder, even when
the bundled one failed to load.
- A `uiPresets` key must be a plain property name.
```jsonc
{
"id": "example", // ^[a-z][a-z0-9-]{1,39}$, equals the directory name
"name": "Example",
"version": "1.0.0", // semver
"description": "What it does.",
"author": { "name": "You", "url": "https://..." },
"license": "MIT",
"repository": "https://...", // optional
"category": "Productivity", // see the enum below
"icon": "Sparkles", // optional: Lucide name or a bundled icon.svg
"engine": {
"termix": ">=2.9.0", // display only, never enforced
"api": "1", // the real compatibility gate
},
"capabilities": ["kv:own", "ui:surface"],
"dependencies": { "other-plugin": "^1.0.0" }, // missing one blocks activation
"optionalDependencies": { "maybe": "^1.0.0" }, // missing one is normal
"provides": [
{
"service": "example.thing",
"version": "1.0.0",
"permission": "example.use",
},
],
"requires": [
{ "service": "other.thing", "versionRange": "^1.0.0", "optional": true },
],
"providesSecret": [
{ "key": "api-key", "permission": "example.secrets.share" },
],
"requiresSecret": [
{ "plugin": "other-plugin", "key": "api-key", "optional": true },
],
"contributes": {
"tabs": [
{
"id": "example",
"titleKey": "nav.example",
"icon": "Sparkles",
"openFrom": ["rail"],
},
],
"actions": [
{
"id": "example.open",
"titleKey": "k",
"handler": "open",
"permission": "example.use",
},
],
"actionSlots": [{ "id": "example.toolbar", "accepts": ["button"] }],
"permissions": [
{
"name": "use", // registered as "example.use"
"titleKey": "permissions.use.title", // plugin-relative
"descriptionKey": "permissions.use.description",
"defaultRoles": ["user"], // system roles only, applied once
},
],
"auth": {
// each needs "auth:provide" in capabilities
"sshAuthTypes": ["corp-ca"], // host auth types this plugin handles
"loginMethods": ["corp-sso"],
"secondFactors": ["pin"],
"secretSchemes": ["op"], // "<scheme>://..." secret references this plugin resolves
"keyboardInteractive": ["gate"], // keyboard-interactive handler ids
},
"settings": {
"admin": [
{
"key": "apiKey",
"type": "secret", // encrypted at rest, never sent to a browser
"labelKey": "settings.apiKey.label",
"descriptionKey": "settings.apiKey.description",
"placeholderKey": "settings.apiKey.placeholder",
"group": "settings.group.api", // section heading
},
{
"key": "retries",
"type": "number",
"labelKey": "k",
"min": 0,
"max": 5,
"default": 3,
},
{
"key": "browser",
"type": "custom", // a registered component, for what a schema cannot express
"component": "devices",
},
],
"user": [
{
"key": "advanced",
"type": "boolean",
"labelKey": "k",
"permission": "tweak", // one of this plugin's own permissions
},
],
"host": {
"editorGroup": "ssh", // host editor strip for the generated tab, default "top"
"editorOrder": 30, // position among that strip's tabs
"enableKey": "enableExample", // boolean rendered first, gating the rest
"enableLabelKey": "k",
"fields": [
{
"key": "port",
"type": "number",
"labelKey": "k",
"requires": "enableExample",
},
],
},
},
"hostCapability": {
"key": "enableExample",
"labelKey": "k",
"editorTab": "general",
},
},
"backend": "dist/backend.js", // default
"frontend": "dist/frontend.js", // default
"locales": "locales", // default
"platforms": ["linux", "win32", "darwin"], // optional
"nativeDependencies": ["serialport"], // optional, see "Native dependencies"
}
```
`category` is one of: Terminal, Files & Transfer, Infrastructure, Monitoring,
Networking, Access & Security, Productivity.
`openFrom` is one of: rail, host-context-menu, palette.
Field types are `boolean`, `string`, `number`, `select`, `multiselect`,
`secret`, `textarea`, `json` and `custom`. `labelKey` is required for every
type but `custom`, which needs `component` instead; `options` is required and
non-empty for `select` and `multiselect` and rejected elsewhere; `min`/`max`
belong to `number` alone; and `requires` must name a boolean field in the same
scope, which includes the host section's `enableKey`.
Cross-field rules the JSON schema cannot express, checked by `parseManifest`:
- `engine.api` must match the SDK major version this build implements.
- Every `provides[].permission`, `providesSecret[].permission` and
`contributes.actions[].permission` must appear in the plugin's own
`contributes.permissions`, compared against the qualified `<pluginId>.<name>`
form. A permission the catalog never sees is one no admin can grant, so the
surface would be invisible rather than denied.
- `requiresSecret[].plugin` and both dependency maps must not name the plugin
itself, and a plugin cannot be in both `dependencies` and
`optionalDependencies`.
- Every `contributes.permissions[].name` must be unique, must not start with a
core group (`hosts`, `snippets`, `credentials`, `admin`) or with the plugin's
own id, and every `defaultRoles` entry must be `admin` or `user`.
- A `contributes.settings` field `permission` written as a bare short name must
appear in the plugin's own `contributes.permissions`. A dotted id is taken as
given, because the validator cannot know which other plugins exist.
`contributes.panels` and `contributes.dashboardCards` (each `{ id, titleKey,
icon? }`) declare the views a plugin owns besides its tabs; the app object
refuses to register a view the manifest does not declare. `contributes.guest`
opts a plugin into anonymous guest pages, and `contributes.guestViews` names
the `?view=` pages it serves there. `provides[].names` lists the named
providers a plugin registers for a service keyed by provider (see Cross-plugin
use). Action contributions and slots
accept the kinds `button` and `component`.
`contributes.auth` lists the ids a plugin registers through `ctx.auth`. A
register call for an id not listed here throws. Core also reads
`sshAuthTypes` from disabled plugins, to say which plugin a host needs.
`secretSchemes` (**B20**) is the same idea for
`ctx.credentials.registerSecretResolver`: core reads it from disabled
plugins too, so a reference whose scheme belongs to a disabled plugin can
name that plugin in its error instead of saying nothing resolves it.
`contributes.hostCapability`, `provides`, `requires`, `providesSecret` and
`requiresSecret` are carried forward from v1 unchanged because the service and
secret registries read them at activation. Phase B reshapes them.
Removed in A5: `contributes.permissionGroup` and its `defaultForRole`, replaced
by `contributes.permissions`. A plugin no longer picks its own group name.
Removed in v2: `permissions` (replaced by `capabilities`), `sidecars`, the
`capabilities.{backend,frontend,electron,platforms}` object (replaced by the
`backend`/`frontend`/`platforms` fields), the v1 shape of
`contributes.dashboardCards`, `contributes.settingsPanel`, `contributes.apiPrefix`, and
`process:transport-owner` (the marker for the tier that no longer exists).
---
## Capability catalog
Colon-separated, ordered worst first. The i18n keys resolve under
`plugins.capabilities.<id>.title` and `.consequence` in
`src/ui/locales/en.json`, and the copy describes the consequence to the person
deciding, not the mechanism.
| Capability | Risk | Meaning |
| -------------------- | -------- | -------------------------------------------------------------- |
| `credentials:read` | critical | See plaintext secrets for any reachable host |
| `system:tls` | critical | Replace the server certificate and answer ACME challenges |
| `ssh:connect` | high | Run commands on hosts it is connected to |
| `process:spawn` | high | Run programs on the Termix server |
| `users:write` | high | Create and change user accounts |
| `auth:provide` | high | Add a login method, second factor or SSH auth type |
| `device:serial` | high | Open a physical serial or USB device on the Termix server |
| `hosts:write` | medium | Create and change hosts |
| `credentials:use` | medium | Connect using a host's stored credentials, without seeing them |
| `credentials:write` | medium | Save new credentials for the acting user |
| `network:outbound` | medium | Make outbound requests |
| `network:serve` | medium | Open a listening port |
| `network:broadcast` | medium | Send network broadcast packets |
| `users:read` | medium | See usernames and roles, never hashes or 2FA state |
| `events:core` | medium | Subscribe to and emit core events carrying other users' data |
| `notify:send` | medium | Send alerts to users and through their channels |
| `notify:hub` | high | Receive, store and deliver every alert all plugins send |
| `audit:read` | medium | Read the audit log |
| `desktop:window` | medium | Open desktop windows (Electron only) |
| `hosts:read` | low | See the host list it can already see |
| `db:own` | low | Own tables |
| `kv:own` | low | Small key/value state |
| `files:own` | low | Its own files on disk |
| `secrets:own` | low | Its own encrypted secrets |
| `settings:read-core` | low | Read core server settings |
| `ui:surface` | low | Contribute tabs, panels and settings |
### Rules
- One catalog, in `packages/plugin-sdk/src/capabilities.ts`. Nothing else
defines a capability.
- Adding one is an entry here plus the SDK method that checks it. A capability
nothing checks is decoration, and v1 shipped nineteen of those.
- A capability is checked where the privileged thing happens, not at
activation. Activation-time checks say what a plugin might do; call-time
checks say what it did.
- `process:spawn` is checked by `ctx.process` (**C3**); before that it was
only a statement of intent.
- `events:core` is the exception that proves the shape: without it a plugin may
only emit under `plugin.<its id>.`, because otherwise it could publish
`host.status` and drive the automations engine as though core had.
---
## The ctx surface
Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
Every member is listed; the types in `@termix/plugin-sdk/backend` are the
authority for signatures.
| Member | Capability | What it does |
| ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `pluginId`, `manifest` | none | The plugin's id and parsed manifest |
| `log.debug/info/warn/error` | none | Lines in core's log, tagged with the plugin |
| `events.emit/on` | `events:core` for core topics | Topics under `plugin.<id>.*`, or core's (`user.deleted`, `user.data_wiped`, host events) |
| `kv.get/set/delete/list` | `kv:own` | Small key/value state |
| `files.dataDir()` | `files:own` | A per-plugin folder, `<DATA_DIR>/plugin-data/<id>` |
| `db.define/client/refs/persist/dialect` | `db:own` (not `dialect`) | The plugin's own tables through Drizzle; `persist` flushes SQLite to disk |
| `sync.registerEntity` | none | Adds a table to sync between a linked desktop and its server |
| `registry.provide/consume/revoke` | none | The low-level named registry services sit on |
| `services.provide/get/providers` | per-service RBAC | Typed, versioned cross-plugin services, checked per call |
| `secrets.get/set/delete/seal/unseal` | `secrets:own` | The plugin's own encrypted values |
| `secrets.offer/withdraw/getShared` | per-secret RBAC | Secrets one plugin lends another |
| `http.router(options)`, `http.baseUrl(req)` | `network:serve` (router) | The router mounted at `/plugin-api/<id>/`, and the public origin for callback URLs |
| `ws.route/upgrade` | `network:serve` | Sockets at `/plugin-ws/<id>/<path>` |
| `rbac.has/hasFor/require` | own permissions (require) | The acting user's role permissions; `require` is route middleware |
| `settings.get/set/getUser/setUser/getHost/setHost/listHostValues/getAll/onChange/onValidate` | `hosts:read` (listHostValues) | The plugin's admin, user and host settings from its manifest |
| `settings.readCore(key)` | `settings:read-core` | One of the core keys in `CORE_SETTINGS_ALLOWLIST` |
| `disposables.add` | none | Extra cleanup on deactivate |
| `capabilities.has/require` | the named capability | A generic check for privileged code no ctx member wraps |
| `hosts.list/get/checkAccess` | `hosts:read` | Hosts the acting user can see, without secrets |
| `hosts.create/update/delete/listOwned/share/listUsers/listRoles` | `hosts:write` | Host changes and sharing on the user's behalf |
| `hosts.trackSession/recordActivity` | `hosts:read` | The online indicator and a recent activity entry |
| `hosts.status.get/check/reportLogin/registerPort` | `hosts:read` | Core's reachability status (`get` is async since **D2**) |
| `ssh.connect/withConnection/jumpChain` | `ssh:connect`, `credentials:use` | Connections through core's one connect pipeline |
| `ssh.resolveHost/openTransport/classifyKeyboardInteractive/autoResponses/startInteraction/cancelInteraction` | `ssh:connect`, `credentials:use` | The lower level, for a transport with its own prompt flow; `resolveHost` redacts secrets without `credentials:read` |
| `ssh.prepare` | `ssh:connect`, `credentials:use`, `credentials:read` | The ssh2 config for a transport that connects its own client; it carries the password and key |
| `ssh.poolKey/dropPooled/requiresSecret/supportsBackground` | `ssh:connect` | Pool housekeeping and questions for an auth type's provider |
| `auth.registerLoginMethod/registerSecondFactor/recordEnrollment/removeEnrollment` | `auth:provide` | Login methods and second factors |
| `auth.completeRedirectLogin/revokeSessions/loginRateLimit/countLinkedUsers` | `auth:provide` | What a login method needs from core's session and provisioning code |
| `auth.registerSshAuthProvider/registerKeyboardInteractiveHandler` | `auth:provide` | SSH auth types and keyboard-interactive handlers |
| `credentials.listSshKeys` / `.createSshKey` | `credentials:use` / `credentials:write` | The user's saved SSH keys |
| `credentials.resolveHostProtocol` | `credentials:read` | A host's RDP, VNC or Telnet login |
| `credentials.registerSecretResolver` | `auth:provide` | Resolves external secret references in host and credential fields |
| `desktop.openIsolatedWindow/launchNativeRdp/available` | `desktop:window` (not `available`) | Electron windows and the native RDP client |
| `audit.record` | none | An audit line under the plugin's own action name |
| `schedule.every/after` | none | Timers that stop on deactivate and never overlap |
| `notify.channels/send` | `notify:send` | Alerts, handed to the hub plugin with the audience resolved in core |
| `notify.serve` | `notify:hub` | Makes the plugin the one hub that stores and delivers alerts |
| `fetch(url, init)` | `network:outbound` | Outbound HTTP through core's SSRF guard; `allowPrivateHosts` hosts also go through the configured proxy |
| `process.run/ensureBinary` | `process:spawn` (+ `network:outbound` to download) | Programs on the server |
| `system.tlsStatus/writeTlsCertificate/reloadTls/publishHttpChallenge/registerTlsRenewer` | `system:tls` | The server certificate |
| `asUser(userId, fn)` | none, always audited | Background work as a user |
| `currentActor()` | none | The acting user for the current call |
**B11** added `ctx.capabilities.has(capability)` / `.require(capability)`, a
generic check for a capability no other ctx member wraps. Unlike every other
row above, the capability it checks is not fixed at the call site: the
plugin names it. That is right for exactly one kind of caller - privileged
code core cannot mediate through its own primitives, because it is not core's
to mediate. The serial plugin bundling `serialport` and opening an OS device
file is the first: there is no `ctx.serial.open()` for core to gate, because
the whole point is that the plugin talks to the hardware directly. `has`
answers without an audit line, for deciding whether to offer something;
`require` throws `PluginCapabilityError` and audits like a guarded member. A
plugin reaching for this first checks whether its need is actually one of the
rows above - `device:serial` is not a general "trust me" capability, it is
specifically for code a dedicated ctx member cannot cover.
`ctx.ssh` was pulled forward from B so plugin transports go through core's
connect pipeline instead of importing ssh2 helpers from core:
- `connect(hostOrId, { purpose, pool? })` returns `{ client, host, dispose }`.
`host` is the host core resolved (ip, port, username, name and the rest),
for a plugin that only had a numeric id and needs the details, such as
substituting them into a command. **B2** added this field.
- `withConnection(host, { pool, purpose }, fn)` borrows a pooled connection.
- `jumpChain(jumpHosts, { forHost? })` returns a client at the end of a jump
chain, for forwarding to something that is not SSH. `host` on the result is
`forHost` when given, else a placeholder built from the last hop's id, since
a chain has no single resolved host of its own.
- `prepare(host, { client, purpose })`, `openTransport`,
`classifyKeyboardInteractive` and `autoResponses` are the lower level for a
transport with its own prompt flow (**B6**'s interactive file-manager
connect with its TOTP and browser sign-in parking flow).
- **B15** showed the higher level covers a multi-request HTTP handshake too:
the Docker panel passes `connect` a `prompt` channel whose `ask` answers the
waiting request with what to ask (a code, a browser sign-in, credentials)
and resolves when the next request (`connect-totp`, `connect-browser-sign-in`)
brings the answer. A retry TOTP round re-asks instead of failing. docker
has no auth code of its own left.
- `requiresSecret(authType)` and `supportsBackground(authType)` ask the
provider.
- `purpose` is a free label (`"terminal"`, `"docker"`) that auth providers
see in `connectOptions`. **D1** moved the keepalive, timeout and
environment defaults onto `profile`: `"terminal"` (an interactive shell),
`"session"` (a long-lived browsing session such as the file manager or
Docker), `"stream"` (a long transfer or console), `"forward"` (port
forwarding) or `"background"`, the default, for short exec work. Core's own
jump chain uses a `"jump"` profile plugins cannot pick. Core no longer keeps
a list of purposes, so it names no plugin.
- **B9** added what an interactive transport needs, for the terminal:
`resolveHost(hostId, { syncId? })` (the host with secrets, by sync id first,
audited), the `"terminal"` purpose, `interactive` and `hostKeySocket` on
`prepare` (so a changed host key can be accepted over the socket), the
provider's `onBanner` and `onAuthFailed` hooks bound on its result,
`openTransport(..., { resolveDns, log })`, and `startInteraction` /
`cancelInteraction` for the browser sign-ins (opkssh, step-ca, vault), which
the terminal used to reach by import. Providers gained an optional
`cancelInteraction`.
- **B7** added `sock` to the connect options: an already-open stream to the
host, such as a `forwardOut` channel through another host. `connect` then
skips the transport step (port knocking, proxy, jump hosts, DNS) and runs
the host key check and auth over that stream. The tunnels plugin opens the
endpoint leg of a source-to-endpoint tunnel this way, so both legs go
through the pipeline; before B7 tunnels built their own ssh2 `Client` and
never checked host keys at all.
Each new connection is audited and runs as the current actor; pooled reuse is
not. Connections and pool entries are closed on deactivate.
**B4** added `ctx.hosts`, for a plugin that groups or acts across hosts it
does not own the way fleets does:
- `list()` / `get(hostId)` return `PluginHostSummary` (id, userId, name, ip,
port, username, tags, folder, authType - no secrets) for the acting user's
own and shared hosts. Needs `hosts:read`.
- `checkAccess(hostId, level)` mirrors `PermissionManager.canAccessHost`,
naming the level ("connect" | "view" | "edit" | "manage") rather than a
bare yes/no, because a plugin fanning out across a fleet needs to know
whether it can only connect, or also manage. Needs `hosts:read`.
- `share(hostId, targets, permissionLevel, durationHours)` grants access the
same way the host editor's own share action does, snapshotting shared
secrets per target through core's `SharedHostSecretsManager`. Refuses a
host the caller does not hold "manage" on. Needs `hosts:write`, because
granting access to a host is a write on that host even when the plugin
owns neither the host nor the grant.
- `listUsers()` / `listRoles()` return minimal `{ id, username }` /
`{ id, name, displayName }` lists (roles filtered to non-system) for a
share-target picker UI, without exposing the full admin user/role
management surface. Needs `hosts:write`, matching `share`.
`ctx.hosts.get` returns the same non-secret projection as `list`; a plugin
that needs to actually connect uses `ctx.ssh.connect(hostId)` or
`withConnection(hostId, ...)`, which resolve the full host (secrets included)
through the one connect pipeline. fleets never holds a `PluginSshHost` itself.
**B5** added `create`, `update` and `listOwned`, for a plugin that creates or
maintains hosts on the user's behalf rather than only sharing access to
existing ones (proxmox's discovery and sync flow is the first caller):
- `create(host)` inserts a host owned by the acting user, encrypted the same
way the host editor's own create route does. Takes `PluginHostCreateInput`
(name, ip, port, username, authType required; connection flags, tags,
folder, jumpHosts and the rest optional) and returns the decrypted
`PluginHostRecord`. Needs `hosts:write`.
- `update(hostId, patch)` updates a host the acting user owns, refusing one it
does not (sharing a write onto someone else's host goes through `share()`,
never `update()`). Returns `null` for a host that does not exist or is not
owned by the caller. Needs `hosts:write`.
- `listOwned()` returns every host the acting user owns as full
`PluginHostRecord`s (credentialId, jumpHosts and the rest `list()`'s
narrower `PluginHostSummary` leaves out), for a plugin scanning its own
hosts in the background, such as proxmox's auto-sync sweep. Needs
`hosts:write`, matching `create`/`update`: this is the same "full host
detail" surface those calls need to read back, not a wider read grant than
`hosts:read` gives through `list`/`get`.
**B9** added `trackSession(hostId)` (the online indicator, counted with every
other feature's sessions; synchronous, so only the declaration is checked, like
`ctx.http.router`) and `recordActivity(hostId, type, hostName)` (the same rate
limit and access rule as the dashboard's route, which now shares
`services/recent-activity.ts` with it). Both need `hosts:read`.
`PluginHostRecord` carries no secret auth material (password, key, vault
token): a plugin that creates a host picks an `authType` and, for
`"credential"`, a `credentialId` it does not need to see the contents of.
**B8** added `ctx.desktop.openIsolatedWindow({ url, partition?, title?,
ignoreCert? })`, for a plugin that wants a URL shown outside the main
renderer's own session (a tunnelled or direct host web UI, so far). The
backend cannot open a `BrowserWindow` itself: it runs as a separate forked
process (`electron/main.cjs` forks it with `stdio: [..., "ipc"]`), so the call
is relayed over that same fork IPC channel to a small request/response
protocol in `src/backend/utils/electron-ipc-bridge.ts`, and handled in
`electron/main.cjs` by `createIsolatedWindows` (`electron/isolated-window.cjs`,
the module both the renderer's own isolated-window IPC call and this bridge
share). Rejects outside the desktop app (`ELECTRON_EMBEDDED` unset, or no
`process.send`). Each window gets its own non-persistent session, so it never
shares cookies or storage with the main window or with another isolated
window.
**B14** added `ctx.desktop.launchNativeRdp({ host, port, username, domain })`
over the same bridge (a second entry in `BACKEND_REQUEST_HANDLERS`,
`launch-native-rdp`), which opens mstsc on Windows with a generated `.rdp`
file that never carries the password, and `ctx.desktop.available()`, which
says whether the server runs embedded in the desktop app. The renderer's own
`openNativeRdp` IPC call is gone; the desktop app's frontend always asks its
embedded backend, which is the one that can reach Electron.
**B14** also added `ctx.credentials.resolveHostProtocol(hostId, protocol)`,
the first member behind `credentials:read`. It returns the host's address,
jump hosts and the plaintext RDP, VNC or Telnet login the acting user may use,
for a plugin that has to hand them to another program (guacd). The logins
stay in core next to the host: an owner gets the stored login or the stored
credential it points at; a shared recipient gets only core's sharing
resolution (the owner's shared snapshot or their own override), never the
owner's raw secret; no connect access, or no host, is `null`. Every call is
audited as `plugin_credentials_read`, allowed or refused.
**C6** added two members for Termix Identity, both for the acting user's own
saved SSH keys and both audited:
- `listSshKeys()` returns `{ id, name, username, publicKey }` for every key
credential the actor owns. Core derives the public key from the private
key when none was stored, so the plugin never receives private material.
Needs `credentials:use` and the core `credentials.view` permission.
- `createSshKey({ name, description, username, privateKey, publicKey,
keyType })` saves a key pair as a new encrypted credential and returns its
id. Needs `credentials:write`, a new capability, the core
`credentials.create` permission and an unlocked data key. There is no
delete: a plugin that has to undo a save orders its own writes so it never
needs one (termix-identity publishes the key first and removes that row if
the save fails).
**C7** added `ctx.system`, the server certificate, all behind `system:tls`
(critical: whoever holds it decides what certificate every browser is shown)
and all audited. Core keeps serving HTTPS: at boot it makes a self-signed
certificate only when none exists or its own self-signed one is expiring, and
it never replaces a certificate a CA issued, so a certificate keeps being
served, unrenewed, with its plugin disabled. The code is in `src/backend/tls/`.
- `tlsStatus()` is `{ enabled, certificate, renewal }`: the served leaf (names,
issuer, dates, `selfSigned`, fingerprint) and the plugin that renews it.
- `writeTlsCertificate(cert, key)` validates the pair with node's X509 parser
(it parses, the leaf has not expired, the key matches) and swaps both files
in by rename. It does not reload.
- `reloadTls()` serves what is on disk now: `setSecureContext` on the direct
HTTPS server (starting it if HTTPS was off), or an nginx reload in Docker.
- `publishHttpChallenge(token, content)` answers
`GET /.well-known/acme-challenge/<token>`, which core serves publicly
because no `/plugin-api/` path can be on it. nginx proxies that location
to the backend on both the HTTPS server and the port 80 redirect server.
- `registerTlsRenewer()` tells core the plugin renews the certificate. Admin
settings warn with the expiry date when a CA-issued certificate has no
renewer. Both registrations are dropped on deactivate.
Uploading a certificate by hand stays core (`POST /users/tls-certificate`),
since it is not ACME. The acme-ssl plugin is the first caller: it runs
acme-client with every request sent through `ctx.fetch` (an axios adapter),
keeps its account key sealed in `ctx.kv`, and checks every 12 hours.
**C3** added `ctx.process`, for a plugin that ships a program (opkssh):
- `run(file, args, { env, cwd, timeoutMs })` starts it without a shell and
returns `{ pid, onStdout, onStderr, exited, kill }`. Output arrives as UTF-8
text. Every program still running is killed on deactivate. Audited as
`process_run`.
- `ensureBinary({ name, version, url, sha256, prebuilt })` returns the path of
a verified executable: the first `prebuilt` path whose SHA-256 matches (a
copy baked into the Docker image, used in place), else
`<DATA_DIR>/plugin-data/<id>/bin/<name>` when it matches, else a download over
https (redirects followed, `network:outbound` checked only then), written
atomically with mode 755. A mismatch throws and keeps nothing.
**B20** added `ctx.credentials.registerSecretResolver(scheme, resolve)`,
behind `auth:provide` like `ctx.auth`'s registrations, for a plugin that
expands `"<scheme>://..."` references in a host's secret fields into the
real secret (secret-sources registers `"op"` for
`"op://vault/item/field"`). The scheme must be listed in
`contributes.auth.secretSchemes`, which exists for the same reason
`sshAuthTypes` does: so core can name the plugin a disabled scheme needs. A
core registry in `src/backend/hosts/connect/secret-resolver-registry.ts`
mirrors the SSH auth provider registry one-for-one (one owner per scheme,
`SecretResolverMissingError` naming the plugin when none is registered).
`resolve` is called once per host resolution, for the user the field's owner
resolves as, by `src/backend/hosts/external-secrets.ts`, which is scheme-
generic now: it detects a reference by its `scheme://` prefix, looks up the
registered resolver, and caches the result for a minute. Core registers
nothing here itself.
**B16** added `ctx.hosts.status`, the plugin side of core's host status
check (see [Host status](#host-status-is-core)):
- `get(hostId)` is core's last result, or `null` before the first check or
for a host with status checks off. `check(hostId)` checks now unless the
last result is under 30 seconds old. host-metrics calls it before the first
heavy sample of a host.
- `reportLogin(hostId, { ok, hostKeyChanged? })` tells core a login to the
host worked or failed. A working login is what turns "reachable" into
"online". Synchronous and not audited, like `trackSession`: a poller calls
it on every sample.
- `registerPort(connectionType, resolve)` tells core which port proves a host
of that connection type is up, for a protocol whose port is the plugin's own
host setting. remote-desktop registers `rdp`, `vnc` and `telnet`; without
it core checks the host's port. Removed on deactivate.
**B16** also added `ctx.ssh.dropPooled(pool, hostId)`, which closes this
plugin's pooled connections to one host (host-metrics calls it on
`host.updated`). It only touches keys this plugin created, so, like
deactivate, it never closes another plugin's connections. The core pool is
never cleared as a whole by a plugin.
**B16** added `ctx.schedule`: `every(ms, fn, { jitterMs?, runNow? })` and
`after(ms, fn)`, each returning a function that stops it. Timers live in the
plugin's disposable bag, so deactivate clears them; a run that throws is
logged, and a repeating job skips a tick while its last run is still going.
`jitterMs` delays the first run by a random amount up to that, so a fleet of
per-host timers spreads out. Needs no capability.
`ctx.db.persist({ lazy: true })` (**B16**) marks the database dirty and lets
the debounced save write it, for frequent low-value writes such as metrics
samples; the default still saves before returning.
**B17** added `ctx.notify` and `ctx.fetch`:
- `ctx.notify.channels()` lists the acting user's notification channels
(id, name, type, enabled, never the config), and `send` delivers an alert.
Both need `notify:send` and are audited (`plugin_notify_channels`,
`plugin_notify_send`). **E1** moved channels, delivery and the inbox out of
core into the alerts plugin, gave `send` one notification argument with an
audience, and added `serve`; see Alerts under "Cross-plugin use".
- `ctx.fetch(url, { method, headers, body, timeoutMs, allowPrivateHosts })`
goes through `safeOutboundFetch`: http and https only, no embedded
credentials, DNS pinned, no redirects, and private or loopback addresses
refused unless the exact host is in `allowPrivateHosts`. Needs
`network:outbound`, audited as `plugin_fetch`. Automations' HTTP step
passes the admin allowlist it reads through `ctx.settings.readCore`
(`notification_private_endpoint_allowlist` joined `CORE_SETTINGS_ALLOWLIST`).
**C4** added `tls: { ca, rejectUnauthorized }` for a private CA: `ca`
trusts a PEM bundle, and `rejectUnauthorized: false` is for the one request
that fetches a CA root by fingerprint, which the caller then checks
(step-ca, like `step ca bootstrap`).
**B18** added three members for the AI assistant:
- `ctx.secrets.get(key)` / `.set(key, value)` / `.delete(key)` store a
plugin's own secrets for the acting user, next to the `offer` side of
`ctx.secrets`. Rows live in `plugin_settings` under the `secret` scope
(`scope_id` the user id), encrypted with `encryptSystemSecret`, so they go
with the plugin on uninstall and with the user when `UserRepository.delete`
removes that user's rows. The settings service is manifest-driven and never
reads them. All three need `secrets:own` and an actor; `set` and `delete`
are audited without the value, `get` only checks the capability, like
`ctx.db.persist`, because a plugin reads its keys on every request that
uses them. The AI assistant keeps each provider's key under
`provider:<id>` instead of in its providers table.
- **C1** added `ctx.secrets.seal(value)` and `.unseal(sealed)`: the same
installation-key encryption, returned to the plugin instead of stored, for
a secret that lives in the plugin's own table and has to be read without
an acting user (a second factor checked during login). Both need
`secrets:own`; neither needs an actor or writes an audit line. `unseal`
answers null for anything `seal` did not write. The totp plugin keeps its
secrets and backup codes this way.
- `ctx.fetch` takes a `signal`. It aborts the request and a streamed body;
`timeoutMs` still only covers the wait for the headers, so a long model
stream is not cut.
- `ctx.hosts.delete(hostId)` deletes a host the actor owns through core's
own delete path (`hosts/delete-host.ts`, shared with the host editor's
route: access grants, activity, plugin settings, the tombstone and the
`host.deleted` event). Needs `hosts:write`, and the actor needs the core
`hosts.delete` permission. False for a host that is missing or not theirs.
`ctx.hosts.create` (**B14**) hands the created row to every plugin's
`hostImportNormalizer`, the same as a bulk import row, so a plugin creating a
host can set another plugin's host settings (proxmox marks an imported
Windows guest as RDP) without core naming either.
### The actor
Never comes from plugin code. It is held in `AsyncLocalStorage` and set two
ways, with no third (a public socket's guest has none):
- **A request.** A4's HTTP and WebSocket middleware runs the handler as the
user core already authenticated.
- **`ctx.asUser(userId, fn)`** for background work with no request behind it.
Always audited, and every core API inside still applies that user's RBAC.
A plugin cannot pass a user id to a guarded method and have it believed. This
is what closes the forged-caller bug the old worker broker had, where a worker
could set `callerUserId` on its own messages.
Two calls do take a user id, for background work that reaches another
plugin: `ctx.services.get(name, { userId })` (and a handle's
`asUser(userId)`) and `ctx.secrets.getShared(..., { userId })`. Naming anyone
but the current actor there is the same act as `ctx.asUser`, so since **D2**
it writes the same `plugin_as_user` audit line.
`ctx.ssh` never believes a host object's own `userId` either. Before **D2**
a call with no actor ran as whatever `userId` the plugin put on the host it
passed, which reached that user's jump hosts with that user's credentials.
Now every `ctx.ssh` call needs an actor, except for a host core itself
resolved earlier (from `resolveHost`, `connect` or `withConnection`), which
keeps the user it was resolved for. Any other object host runs as the actor.
**D2** also put the per-user access check on `hosts.status.*`,
`hosts.trackSession`, `hosts.recordActivity` and `ssh.startInteraction`: with
an actor, a host the actor cannot connect to answers nothing. With no actor
they are the plugin's own background work (a poller, a guacd session opening)
and only the capability applies.
### Credentials
Only a plugin holding `credentials:read` sees plaintext credential material
(`HOST_SECRET_FIELDS` in `ctx-hosts.ts`: passwords, keys, passphrases, sudo
and SOCKS passwords, the autostart ones and `terminalConfig.sudoPassword`).
Since **D2**:
- `ctx.hosts` always returns hosts with those removed, `listOwned` included,
and `listOwned` is audited. `update` never moves `id`, `userId` or
`syncId`.
- `ctx.ssh.connect().host` is always redacted.
- `ctx.ssh.resolveHost` returns the full host, audited as
`plugin_credentials_read`, only to a plugin with `credentials:read`. Any
other plugin gets the redacted host, and core remembers the real one, so
handing that object back to `connect`, `withConnection` or `prepare`
connects with the real secrets.
- `ctx.ssh.prepare` needs `credentials:read`, because the ssh2 config it
returns carries them.
ssh-terminal, file-manager, host-metrics and tailscale declare
`credentials:read`: they run `prepare` or read a sudo password.
`credentials.resolveHostProtocol` was already behind it.
### Refusals
`assertCapability` writes the refusal's audit line itself, named after the
call (`plugin_kv_set`), so no guard can forget to. A synchronous declaration
check writes it in the background through `capabilityRefused`. Successes are
audited for every privileged call except the hot-path reads a plugin makes on
every request or sample: `kv.get`, `secrets.get/seal/unseal`, `db.persist`,
`hosts.list/get/checkAccess`, `hosts.status.*` and `hosts.trackSession`.
`src/backend/tests/plugins/security/ctx-guards.test.ts` walks the whole ctx
object and fails for any member that runs without a capability and is not on
its `UNGATED` list with a reason.
Hooks ssh2 calls synchronously (a provider's `onBanner` and `onAuthFailed`,
keyboard-interactive `detect` and `autoAnswerPasswords`) answer from the
grant cache and do nothing until the grant is confirmed.
`ctx.process.run` gives the program a small base environment (path, home,
temp, locale, proxy) plus the plugin's own `env`, never the server's, which
holds the JWT secret and database keys.
---
## Lifecycle rules
1. **Everything is created inside `activate`.** No servers, timers, listeners
or connections at module scope. ESM caches modules, so disable-then-enable
re-runs `activate` against the same module object: anything created at
module scope is already torn down by the second call and never comes back.
2. **Everything is released on deactivate.** Registrations made through `ctx`
are tracked automatically. Anything the plugin creates itself goes in
`ctx.disposables.add(fn)`. A plugin that keeps a port after deactivate makes
"disabled" a lie and makes re-enabling fail on a port it still holds.
3. **Disposal does not depend on the plugin behaving.** The runtime empties the
disposable bag even when the plugin's `deactivate()` throws or does not
exist, in reverse registration order, isolating each disposer.
4. **A failed `activate` is still cleaned up**, and the plugin's own
`deactivate()` is _not_ called: it never finished starting, so it has no
state to tear down.
5. **Plugins never touch process signals or `process.exit`.** Core calls
`deactivate` on shutdown: `gracefulShutdown` in `src/backend/starter.ts`
calls `shutdownPlugins()`, which stops every plugin in reverse activation
order. A plugin that installs its own `SIGTERM` handler and exits skips the
rest of that sequence, including other plugins and the database flush.
6. **Enable, disable, enable must work without a restart.**
### States
`enabled`, `disabled`, `blocked` (a hard dependency is missing, recoverable),
`failed` (activation threw, a dependency cycle, or the error budget tripped).
`lastError` carries the reason.
### Error budget
Core wraps plugin callbacks it invokes. Five failures inside 60 seconds (both
configurable) marks the plugin `failed` and deactivates it, because a plugin
throwing on every call is worse than one that is off.
`POST /plugins/:id/retry` clears the budget and starts it again.
---
## What this protects, and what it does not
Be precise about this, because it is easy to overclaim and the previous
architecture did.
**What the capability system gives you**
- Every privileged thing a plugin can do through `ctx` is declared in a
manifest you can read before installing it.
- Every privileged call is checked against what was granted, and refused if it
was not.
- Every privileged call, allowed or refused, leaves an audit line naming the
plugin and the acting user. You can answer "what did this plugin do, and on
whose behalf".
- A plugin cannot name a user and be believed, so it cannot act as someone it
was not invoked by.
- Removing a capability from a manifest revokes the grant on the next boot.
**What it does not give you**
- **Containment.** A plugin runs in the server process. It can
`import("node:fs")` and read whatever the server user can read, open its own
sockets, spawn processes, and reach core modules directly. Guarding `ctx`
does not change any of that.
- **Protection from malicious code.** A plugin that wants to bypass the gate
can. Nothing here stops it.
`ctx.db` is a clear example. It hands back a handle scoped to the plugin's own
tables, and the `p_<id>_` prefix, the CLI check and the runner's check all say
a plugin may only touch its own. Since **D2** both checks are one function,
`findUnownedTableWrites` in `@termix/plugin-sdk/ddl`, which reads each
statement the way the engine does: comments and every quoting style are
seen through, every name in a `DROP TABLE a, b` list is checked, a rename
target counts as a write, reads (`FROM`, `JOIN`, `REFERENCES`) are allowed,
and `ATTACH`, `PRAGMA`, `GRANT`, triggers, functions and other statement kinds
are refused outright. The legacy-table exemption applies only to the bundled
plugin that adopts the table. `removePluginData` finds a plugin's tables in
the database's own catalog by prefix, leaves a longer prefix another plugin
owns, and takes the plugin's settings and secrets with it; role permissions
stay. None of that is enforced by the engine: a
plugin holding that handle can write any table in the database, and `ctx.db.refs`
hands it `users`, `ssh_data`, `roles` and `user_roles` outright (the last two
added in **B2**, for a plugin that shares its own rows with a role and needs
to resolve a caller's role membership and display names). The prefix, the
capability, the audit line, lint and review are the contract. The engine is
not.
So the honest boundary is: capabilities stop accidental and casual overreach,
and make every privileged call auditable. What stops malicious code is
**signing, review and the kill list** - the same trust model as any other
software you choose to install on a server.
The old worker tier claimed more than this, and the claim was not true either:
`worker_threads` shares the process, so a worker plugin could already reach
anything core could. The honest options for real containment are a child
process with `--permission`, or a WASM isolate. Neither is on the roadmap, and
until one is, this document does not pretend otherwise.
---
## Threat model
Written in **D2**, after a security review of the whole plugin system. The
short version: a plugin is code you run on your server, with the same reach
as Termix itself. Everything below follows from that.
### What a malicious plugin can do
A plugin runs in the Termix server process and in the browser tab of anyone
who uses Termix. In the server it can do everything the server's OS user can:
- read `DATA_DIR`, which holds the database, the system key, the JWT secret
and every user's encrypted data, and import core modules to decrypt it;
- read and write any table, whatever `ctx.db` says it owns;
- open sockets, listen on ports, spawn programs and read the environment;
- replace or patch core modules in memory, including the auth middleware and
the capability check itself;
- log in as any user, because `ctx.auth` identities and `ctx.asUser` are
trusted by design.
In the browser it runs in the shell's own JavaScript context, so it can read
anything the page can, including the session cookie's effects and every
other plugin's UI.
None of the checks in this document stop that. They are not a sandbox.
### What the system does about it
- **Signing and review.** Official plugins live in this repo and ship in the
Termix image; they are reviewed like core and trusted by shipping inside
it. A plugin outside the image is a `.tmxplug` signed by a key compiled
into Termix (see [Packaging and signing](#packaging-and-signing)), and
`TERMIX_REQUIRE_SIGNED_PLUGINS=true` refuses anything else. Community
review and the install UI arrive in 3.0.0.
- **The kill list (3.0.0).** A signed list of plugin versions known to be bad,
which the server refuses to load, so a bad plugin can be switched off on
every install without waiting for each admin.
- **Capability consent.** A manifest names every privileged thing its code
does through `ctx`, with a risk level. In 3.0.0 an admin grants each one
when installing; in 2.9.0 bundled plugins get what they declare.
- **Audit.** Every privileged `ctx` call, allowed or refused, writes a line
with the plugin id and the acting user, and so does every switch of the
actor (`ctx.asUser`, or a service handle or shared secret named for another
user). "What did this plugin do, as whom" has an answer.
- **Honest plugins stay honest.** The checks D2 added or confirmed stop the
accidental and the confused-deputy cases, which are the ones that actually
happen:
- every privileged member is guarded, and a test walks `ctx` so a new
member without a guard fails CI;
- plaintext credentials only reach a plugin holding `credentials:read`;
- a host object's `userId` is never believed, so a plugin cannot borrow
another user's jump hosts or credentials by naming them;
- `ctx.hosts` and `ctx.ssh` apply the actor's RBAC to every host;
- routes need login unless declared public, public routes are listed in
the admin plugin dialog, a disabled plugin answers 503, errors hide their
stack, and an anonymous caller cannot spend a plugin's error budget;
- `/plugin-assets` serves only a running plugin's browser files, with no
way out of the plugin folder, symlinks included;
- a user plugin cannot take a bundled plugin's id, a reserved id (`admin`,
`hosts`, `credentials` and a few more) or an id whose table prefix
overlaps another plugin's;
- migrations are parsed, not pattern-matched, and may only write the
plugin's own tables;
- a plugin's programs get a small environment without the server's
secrets.
### Before installing a community plugin
- **Know where it came from.** Install from a source you would trust with
root on the server. Prefer a signed `.tmxplug` and run with
`TERMIX_REQUIRE_SIGNED_PLUGINS=true`.
- **Read the capabilities.** Anything critical or high (`credentials:read`,
`system:tls`, `ssh:connect`, `process:spawn`, `users:write`,
`auth:provide`, `device:serial`) means the plugin can reach your hosts,
your users or the server itself. Ask why it needs each one.
- **Read the public routes.** `publicRoutes` on `GET /plugins` lists every
path it serves without login. Each one is attack surface on your server.
- **Check its dependencies.** A plugin's npm dependencies run with the same
reach as the plugin.
- **Watch the audit log after installing.** Filter on `plugin:<id>` and look
for calls you did not expect, especially `plugin_as_user` and
`plugin_credentials_read`.
- **Run Termix as an unprivileged user**, in its container or under its own
OS account, so "everything the server user can do" is as little as
possible.
---
## Building a plugin
`termix-plugin`, the CLI the SDK ships, is the only build a plugin needs. It
runs from the plugin's own directory, so a plugin that moves to its own repo
keeps working unchanged.
| Command | What it does |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `termix-plugin build` | esbuild bundles `src/backend/index.ts` to `dist/backend.js` (ESM, node22) and `src/frontend/index.tsx` to `dist/frontend.js` (ESM, browser), then copies `locales/` and `migrations/` into `dist/`. Imported CSS lands in `dist/frontend.css`. |
| `termix-plugin validate` | Runs the SDK's `parseManifest`, checks the files the manifest names exist, and checks that migrations only touch this plugin's tables and exist for every dialect. |
| `termix-plugin migrations` | Diffs `src/backend/tables.ts` against `migrations/snapshot.json` and writes one `.sql` per dialect. `--check` fails when a definition changed without a migration. A rename or a type change is refused rather than guessed at, because that is a data decision. |
| `termix-plugin test` | Runs the plugin's vitest suite. |
| `termix-plugin pack` | Validates, then writes `<id>-<version>.tmxplug` (see [Packaging and signing](#packaging-and-signing)). `--out <dir>` picks the folder. |
| `termix-plugin sign` | Signs a `.tmxplug` with the key in `TERMIX_PLUGIN_SIGNING_KEY` and writes `<file>.tmxplug.sig`. |
| `termix-plugin verify` | Checks a `.tmxplug` against its `.sig` and `--key <base64>[,<base64>]`. |
| `termix-plugin keygen` | Writes a new Ed25519 private key to `termix-plugin-signing.key` and prints the public key and key id. Never prints the private key. |
`npm run build:plugins` stages every plugin listed in
`docker/bundled-plugins.json` in `dist/plugins/<id>/`, which is what `getBundledPluginsDir()` resolves to and
what Docker and electron-builder package. `npm run build`, `build:backend` and
`dev:backend` all call it.
TypeScript only type-checks (`tsconfig.plugins.backend.json` and
`tsconfig.plugins.frontend.json`, `noEmit`); esbuild does
every emit. Nothing is written next to a source file.
### Packaging and signing
**The `.tmxplug` file.** A gzipped tar of `manifest.json`, `dist/`,
`locales/`, `migrations/`, `README.md`, `CHANGELOG.md` and `icon.svg`
(whichever exist). `pack` refuses when `validate` finds an error. The archive
is reproducible: entries sorted by path, files only, mode 0644, owner 0,
mtime 0, and a gzip header with no timestamp and a fixed OS byte. The same
tag rebuilt on any machine gives the same sha256, as long as the build output
is the same (check out with the same line endings).
**Signatures.** `termix-plugin sign` makes an Ed25519 signature over the raw
32-byte sha256 of the `.tmxplug` and writes it, base64, to
`<file>.tmxplug.sig`. The private key comes from `TERMIX_PLUGIN_SIGNING_KEY`
(base64 PKCS8 as `keygen` writes it, or PEM); in CI it is a GitHub Actions
secret and it is never committed. A registry index carries the same base64
string in each version's `signature`.
**Pinned trust.** `src/backend/plugins/trust.ts` holds the registry public
keys as compile-time constants (`TRUSTED_PLUGIN_KEYS`) and
`verifyPluginArtifact(buffer, sha256, signature)`, which checks the hash and
then the signature against each pinned key. A key read from a registry index,
a download or the data directory is never trusted: whoever serves the index
could sign anything with it. Rotation needs a Termix release: add the new
key, ship, sign with it, and keep the old key for one release cycle so late
upgraders still accept plugins signed before the switch. The steps are in the
Termix-Registry README.
**What the server loads.** Bundled plugins (`dist/plugins/`) are trusted by
shipping in the image and are not checked. In the data directory,
`<DATA_DIR>/plugins/` holds either a plugin folder (as before) or a
`<name>.tmxplug` with an optional `<name>.tmxplug.sig` next to it. A
`.tmxplug` is unpacked fresh on every load into
`<DATA_DIR>/plugins/.unpacked/<id>/` (plain files only, no path outside it).
A `.sig` that does not verify always blocks the plugin. With
`TERMIX_REQUIRE_SIGNED_PLUGINS=true`, a plugin folder is blocked (it carries
no signature) and so is a `.tmxplug` without a valid `.sig`. A blocked
plugin is logged and skipped like any other plugin that fails to load.
**Publishing the SDK.** Plugins outside this repo depend on
`@termix/plugin-sdk` from npm. It is published from this repo with
`npm run build:sdk && npm publish --workspace @termix/plugin-sdk`; the
package's `files` list ships `dist/` with its `.d.ts` files, the CLI, the
manifest schema and `tsconfig.plugin.json`. Outside a Termix checkout the
vitest preset does not alias the shell's modules, so `renderWithApp` and
`@termix/plugin-sdk/ui` are only available to plugins in this repo for now.
### Host-provided packages
These ship with the Termix server and are never bundled into a plugin, because
a second copy of express or React is a bug, and a second `ssh2` means a second
set of native bindings. A plugin may depend on anything outside these lists,
and that is bundled into its output.
**Backend** (plus every node builtin): `@termix/plugin-sdk`, `express`, `ssh2`,
`ws`, `multer`, `cookie-parser`, `axios`, `jszip`, `guacamole-lite`,
`@anthropic-ai/sdk`, `drizzle-orm`, `sharp` (native, so it cannot be bundled;
the terminal's image upload loads it on first use).
**Frontend**: `react`, `react-dom`, `react-dom/client`, `react/jsx-runtime`,
`i18next`, `react-i18next`, `sonner`, `@termix/plugin-sdk/frontend` and
`@termix/plugin-sdk/ui`, all resolved through the import map (see UI). Everything else, including `lucide-react`, `axios`,
`cytoscape` and `guacamole-common-js`, is bundled into the plugin that uses
it.
The lists live in `packages/plugin-sdk/cli/lib/externals.mjs`.
### Native dependencies
A package with a compiled `.node` binding (`serialport`, `better-sqlite3`,
`node-pty`) cannot be bundled the way an ordinary npm dependency is. esbuild
would inline its JS, but the binding is loaded through a path computed
relative to the original package's own directory, which breaks once that JS
moves into `dist/backend.js` somewhere else entirely. It also cannot go on
the host-provided list above: that list is for packages every plugin shares
one instance of, and a serial port binding is one plugin's business, not
core's.
**B11** (the serial plugin, the first bundled plugin with a native
dependency) resolved this by declaring the package in the manifest's
`nativeDependencies` array (`["serialport"]`) as well as a real dependency in
the plugin's own `package.json`. `termix-plugin build` adds every entry to
esbuild's `external` list for that plugin alone, so the compiled bundle keeps
a plain `import { SerialPort } from "serialport"` instead of inlining it, and
`termix-plugin validate` checks the name is actually declared in the
plugin's own `dependencies`.
At runtime that import is a bare specifier Node resolves by walking up from
wherever `dist/backend.js` is loaded, through `dist/plugins/serial/`,
`dist/plugins/`, `dist/`, to the repo (or image) root. **The three
deployments this repo builds all end up with the package there because of
how npm workspaces already install it**, not because of anything specific to
native code:
- **Dev and CI.** `npm install` at the repo root installs every workspace's
own dependencies, including a plugin's, and hoists them into the root
`node_modules` unless a version conflict forces one to stay nested. Nothing
about `serialport` is special here; it lands exactly where any of this
plugin's other dependencies would.
- **Docker.** The image's `npm ci` runs once at the repo root over the whole
workspace and produces one `node_modules`, which is copied into the final
image wholesale (`docker/Dockerfile`'s `production-deps` stage). Its native
binding compiles against the image's own `node:26-slim` toolchain
(`python3 make g++`, already installed for `better-sqlite3`), once per
target architecture under `docker buildx`, so linux x64 and arm64 each get
a binary actually built for that machine rather than a cross-compiled one.
- **Electron.** `electron-builder.json`'s `asarUnpack` already unpacks all of
`node_modules` (not a curated subset), and `npm run electron:rebuild` runs
`electron-rebuild` over the same hoisted `node_modules`, rebuilding
`@serialport/bindings-cpp` against Electron's ABI by name, exactly as it
already did for `better-sqlite3` and `node-pty`. Nothing in that script
changed: a package's native binding gets rebuilt because the script names
it, not because of which `package.json` declared the package as a
dependency.
None of this required a new packaging mechanism, which is the honest reason
it was chosen over shipping the plugin its own `node_modules`: this repo's
build already treats the whole workspace as one `node_modules`, so a
native dependency declared in a plugin's `package.json` rides along for
free. A **community plugin installed from a tarball has none of this**: there
is no repo-root `npm install` to hoist its dependency into, no shared
`node_modules` its bundle can walk up to. Shipping a native dependency in a
plugin nobody's build system already vendors is an open problem this step
does not solve, and it waits for the split into separate repositories. A community plugin
author who hits this today has three honest options, worst to best: ask the
person installing it to `npm install` the native package into the server's
own `node_modules` by hand (fragile, easy to get wrong on upgrade); avoid a
native dependency and reach for a WASM build of the same functionality if one
exists; or wait for prebuild-per-platform packaging support, which is not
built yet.
---
## Converting a feature into a plugin
The checklist every Phase B step follows. A9 ran it on workspaces, so
`plugins/workspaces/` is the worked example for each step: read its source and
tests next to this list. Steps are ordered so data is never dropped before it
has moved. Commands run from the repo root unless they start with `cd`.
A conversion is done when the plugin imports nothing from `src/` or `@/`, core
imports nothing of the feature, every piece of existing data survives an
upgrade on all three engines, and the checks in step 15 are green.
### 1. Scaffold
1. Create `plugins/<id>/` with the layout in [Where plugins live](#13-where-plugins-live).
The directory name is the manifest `id`. If A2 already moved the feature
there, keep the directory and work through the rest in place.
2. `package.json` is `@termix-plugin/<id>`, private, with the four scripts
(`build`, `test`, `typecheck`, `validate`) calling `termix-plugin`, and
`@termix/plugin-sdk` as a dev dependency. `vitest.config.ts` is the one-line
preset.
3. `tsconfig.json` extends `@termix/plugin-sdk/tsconfig.plugin.json` and has
**no `paths`** into `../../src`. A path alias is how core imports sneak back.
4. Run `npm install` so the workspace is linked.
### 2. Manifest and capabilities
1. Fill in the manifest (see the [reference](#manifest-v2-reference)). Declare
every view the frontend registers in `contributes.panels`, `tabs` or
`dashboardCards`, or `app` refuses to register it.
2. Pick the **smallest** set of capabilities from the
[catalog](#capability-catalog). Choose by the ctx members the code calls,
not by what the feature feels like:
| The code calls | Declare |
| ---------------------------------------- | -------------------------------- |
| `ctx.db.*` | `db:own` |
| `ctx.kv.*` | `kv:own` |
| `ctx.http.router`, `ctx.ws.*` | `network:serve` |
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` |
| `ctx.auth.*` | `auth:provide` |
| `ctx.process.*` | `process:spawn` |
| `ctx.settings.readCore` | `settings:read-core` |
| `ctx.events.emit` outside `plugin.<id>.` | `events:core` |
| anything registered through `app` | `ui:surface` |
Workspaces needs `db:own`, `network:serve` and `ui:surface`, nothing else.
3. `cd plugins/<id> && npx termix-plugin validate` must print `ok`.
### 3. Backend: replace every core import with the SDK
1. List what the backend reaches in core:
`grep -rn "src/backend\|src/types" plugins/<id>/src`. Each hit gets replaced.
A plugin never imports `../../../../src/...`.
2. The usual replacements:
| Core import | SDK replacement |
| --------------------------------------------------- | -------------------------------------------------------------------------------------- |
| `AuthManager` middleware, `req.userId` | nothing: core authenticates `ctx.http` routes; read the user with `ctx.currentActor()` |
| `requirePermission(...)` | `ctx.rbac.require("<short name>")` |
| `databaseLogger`, `*Logger` | `ctx.log` |
| `createCurrent*Repository()` and `schema.ts` tables | the plugin's own table through `ctx.db` (step 4) |
| `insertReturning` / `.returning()` | insert, then select by a key you set (a sync id) |
| `DatabaseSaveTrigger`, a repository write hook | `await ctx.db.persist()` after every write |
| ssh2 connection code, the pool, `resolveHostById` | `ctx.ssh.connect` / `withConnection` / `jumpChain` |
| `getCurrentSettingValue` | `ctx.settings.get` for its own keys (step 5), `readCore` for core's |
| another plugin's source | `ctx.services.get(...)` (step 10) |
3. Everything the backend creates lives inside `activate(ctx)`: the table
(`ctx.db.define`), the repository, the router, timers
(`ctx.disposables.add`). Nothing at module scope. `deactivate` is usually
empty.
4. **When the SDK does not have it, add it to the SDK.** Never work around a
gap with a core import. A new ctx member is:
1. the type and a doc comment in `packages/plugin-sdk/src/backend.ts`;
2. the implementation in `src/backend/plugins/ctx.ts`, wrapped in `guarded()`
when it is privileged, so it checks the capability and writes an audit
line; a new capability is one entry in `src/capabilities.ts` plus its
i18n title and consequence under `plugins.capabilities.<id>` in
`src/ui/locales/en.json`;
3. the same member in `createFakeContext` and a gated one in `createMockCtx`
(`packages/plugin-sdk/src/testing.ts`);
4. a core test in `src/backend/tests/plugins/` (allowed and refused);
5. a row in [The ctx surface](#the-ctx-surface) and a line in the right
section of this document.
Then `npm run build:sdk`, because core and the CLI read the SDK's `dist`.
A9 added `ctx.db.persist`, `ctx.db.dialect`, sync `shouldSync`,
`createTestDb`, `app.t`, `app.hasPermission` and the rail `permission` this
way.
### 4. Tables: adopt them, then remove them from core
1. Write `src/backend/tables.ts`. Wrap each definition that replaces a core
table in `adoptLegacyTable("<legacy name>", defineTable(...))` and export
`tables`. Keep the legacy column names (camelCase properties become
snake_case columns) and **the legacy index names**. The table must be listed
under this plugin in `LEGACY_TABLE_OWNERS` (`packages/plugin-sdk/src/db.ts`).
Use `refUser()` / `refHost()` for owner columns: they cascade on delete.
2. `cd plugins/<id> && npx termix-plugin migrations adopt_<table>` writes
`migrations/{sqlite,postgres,mysql}/0001_adopt_<table>.sql` and
`migrations/snapshot.json`. Read all three files. Never edit one after it
has shipped; add a new migration instead.
3. Remove the table from core, in this order:
- `src/backend/database/db/schema.ts`: the table block.
- `src/backend/database/db/index.ts`: the `CREATE TABLE` bootstrap block and
any `ALTER` for it. Some of these files use CRLF line endings; keep them.
- `src/backend/database/db/performance-indexes.ts`: its entries. The adopted
definition's indexes replace them.
- `src/backend/database/repositories/factory.ts` and the repository file.
- `src/backend/database/routes/delete-user-data.ts` and the user/host
repositories: explicit deletes of the table. A `refUser`/`refHost` column
cascades on every engine, because the foreign key survives the rename.
Prefer that cascade. A table that cannot carry such a column (evidence
that has to outlive the account, like a recording) subscribes to
`ctx.events.on("user.deleted", ...)` or `"host.deleted"` instead, added
to the SDK the step 3 way; **B13** added `user.deleted`
(`TOPICS.userDeleted` in `src/backend/plugins/events.ts`), emitted from
`deleteUserAndRelatedData` right where the old direct repository call
used to sit. `host.deleted` already existed (B7).
- `src/backend/sync/entities.ts`, if core synced it (step 9).
- Anything else `grep -rn "<table_name>\|<tableConst>" src` finds, including
core tests and `src/types`.
4. `npm run schema:generate` (the pg and mysql schema variants), then
`npm run schema:migrations`. drizzle-kit writes a `DROP TABLE` for each
removed table in `drizzle/{sqlite,postgres,mysql}/`. **Replace every one of
those statements with a comment and `SELECT 1;`** (MySQL refuses an empty
query) and keep the new `meta` snapshots. The drop would run at boot, before
the plugin's adoption, and delete every row.
5. Why the order is safe: core boots first, runs its drizzle migrations (the
legacy table is still there, untouched), then the plugin's migration renames
it. A plugin that is disabled at upgrade time simply leaves the legacy table
alone until it is enabled.
6. Tests (step 13) prove the adoption with the legacy table present and absent.
### 5. Global settings keys become plugin settings
1. Declare each key in `contributes.settings.admin` (install-wide) or `.user`,
keeping the key name the frontend already sends where possible, and give it
a `default`. Secrets are `type: "secret"`.
2. Write a boot migration in `src/backend/utils/crypto-migration/`, modelled on
`tailscale-settings-migration.ts`: read the old row from `settings` (or the
per-user source), write it to `plugin_settings` for the plugin **only when
no row exists there yet**, then leave the old row in place for one release.
Call it from the boot sequence in `starter.ts` after the plugins table exists.
3. Test it in `src/backend/tests/utils/`: values move, secrets stay encrypted,
and running it twice changes nothing.
4. Switch the plugin to `ctx.settings.get` / `getUser`, delete the core
settings route and UI for those keys, and remove the keys from
`CORE_SETTINGS_ALLOWLIST` if they were there.
### 6. `ssh_data` host columns become host-scope settings
Follow [Moving a host column into a plugin](#moving-a-host-column-into-a-plugin)
exactly: declare `contributes.settings.host`, copy with an idempotent migration
tested by running it twice, switch the plugin to `ctx.settings.getHost` and
delete the hardcoded editor tab, and only then drop the column in lockstep
across `schema.ts`, `db/index.ts`, `schema:generate` and a drizzle migration per
dialect. Copy and drop can ship in different steps; a column core still reads
is not ready to drop.
### 7. Permissions
1. Declare `contributes.permissions` with `titleKey` and `descriptionKey` in the
plugin's own locales. **Keep existing ids**: core registers `<id>.<name>`, so
pick the short name that makes the id equal the old core one (`ai` + `use` is
`ai.use`) and no role needs migrating.
2. Remove those entries from `PERMISSION_CATALOG` and `SYSTEM_ROLE_DEFAULTS` in
`src/backend/utils/permission-catalog.ts`, and move their i18n.
3. `defaultRoles` are `admin` and/or `user`, applied once.
4. Gate every route. `router.use(ctx.rbac.require("<name>"))` before the routes
is the simplest way to make sure none is missed; workspaces declares `use`
and gates the whole router on it.
5. Gate the frontend too: `permission` on the rail item, `usePermission` in
components, `await app.hasPermission(...)` before background calls (the
workspaces autosave), so a user without it collects no 403s.
### 8. HTTP, WebSockets and nginx
1. Routes go on `ctx.http.router()` (served at `/plugin-api/<id>/`) and sockets
on `ctx.ws.route` / `ctx.ws.upgrade` (`/plugin-ws/<id>/<path>`). Route paths
are relative to the mount point.
2. Update every route's OpenAPI JSDoc path to `/plugin-api/<id>/...`.
3. Delete the feature's own port and server from core: its import in
`src/backend/starter.ts`, its `listen`, and its `location` blocks in **both**
`docker/nginx.conf` and `docker/nginx-https.conf`. `/plugin-api/` and
`/plugin-ws/` are already proxied.
4. Point the frontend at the plugin client (step 11); old paths such as
`/workspaces` stop existing.
### 9. Sync entities
If core synced the data, or it is personal configuration that should follow a
user between a linked desktop and its server, register it in `activate`:
`ctx.sync.registerEntity({ type, table, order, ... })` and list the wire name
under `contributes.syncEntities`. Keep an existing wire name exactly and
remove core's registration from `src/backend/sync/entities.ts`. Give an
entity that references others a higher `order` than they have. Use
`shouldSync` for rows that belong to one install (workspaces leaves out its
`last_session` row). Deletes need no extra call.
### 10. Cross-plugin and core callers
1. `grep -rn` the old repository or module across `src/` and `plugins/`. A core
caller means the feature is not fully out yet: move the logic behind a core
extension point or into the plugin.
2. Another plugin calling it gets a service: declare it in `provides`
(`<id>.<noun>`, a version and a permission), `ctx.services.provide` it in
`activate`, and have the caller declare an optional `requires` entry plus
`optionalDependencies`. The caller must work when it is absent (catch and
degrade). Workspaces provides `workspaces.saved` to the AI plugin.
### 11. Frontend
1. List core imports:
`grep -rn "from \"@/\|src/ui" plugins/<id>/src/frontend`. Replace each:
| Core import | SDK replacement |
| ----------------------------------------------- | ----------------------------------------------------------- |
| `@/components/*` | `@termix/plugin-sdk/ui` |
| `authApi`, `handleApiError` from `@/main-axios` | `app.api` or `usePluginApi()`, paths relative to the plugin |
| `react-i18next`, `i18next.t` | `useTranslation()` in components, `app.t` elsewhere |
| `usePermissions` | `usePermission` / `app.hasPermission` |
| shell modules (`workspaceUtils`, tab state) | `app.tabs`, `useTabs` |
| `@/types/*` | the plugin's own `types.ts` |
| small helpers (`getErrorMessage`) | a few lines in the plugin |
Something the plugin genuinely needs from the shell's component library goes
into `src/ui/plugin-host/sdk-ui.ts` (a contract change: list it under
[The `ui` entry](#the-ui-entry)).
2. Register everything in `activate(app)`: rail item (with `permission` when
there is one), panel, tabs, host editor sections, actions and slot
contributions. Use `app.onDispose` for timers.
3. Remove core's references: imports from the feature, hardcoded ids and
switch cases. Where core still needs the feature, name a slot, action or
component id in core's own vocabulary and let the plugin fill it.
`node scripts/check-core-plugin-ids.cjs` must pass.
4. The rail item appears in Appearance > Sidebar > Navigation on its own
because it is registered; confirm it there.
5. No hardcoded text (a relative time is a translated string), no em dashes,
square containers.
### 12. Locales
1. Move every key the plugin uses from `src/ui/locales/en.json` into
`plugins/<id>/locales/en.json`, keeping the key paths so the code changes
only its namespace. Keys still used by core stay in core; a plugin may keep
using core's `common.*`, which is the fallback.
2. Move the same keys, per language, from `src/ui/locales/translated/<xx_YY>.json`
into `plugins/<id>/locales/translated/<xx_YY>.json`, so no translation is lost.
Crowdin already picks up `plugins/*/locales/en.json` (`crowdin.yml`).
3. Manifest keys (`titleKey`, `labelKey`) are plugin-relative.
4. Only `en.json` is edited by hand afterwards.
### 13. Tests
1. Move the feature's tests to `plugins/<id>/tests/backend` and
`tests/frontend`. **No plugin test imports `src/`**, and no core test imports
`plugins/`. Delete core tests of code that left core.
2. The minimum a conversion ships:
- routes against a real database: `createTestDb(pluginDir)` +
`createMockCtx({ db, router: () => express.Router(), permissions })` +
`activate`, served on an ephemeral port and called with `fetch`
(`plugins/workspaces/tests/backend/helpers.ts`);
- permission denial on every route;
- the adoption with the legacy table present (rows and indexes survive) and
absent (fresh table), using `createTestDb`'s `before` hook with the legacy
DDL copied from `db/index.ts`;
- activate failing closed without each capability;
- a frontend `activate.test.tsx` on `renderWithApp`, plus the panel or tab
rendering against a stubbed `api`.
3. Every settings or host-column migration gets its run-twice test in core.
4. `cd plugins/<id> && npx vitest run` while working; `npm run test:plugins` at
the end.
### 14. Build, test and check by hand
```bash
npm run build:sdk
(cd plugins/<id> && npx termix-plugin validate && npx termix-plugin migrations --check)
npm run build:backend
npm run type-check
npm run lint
npm run test
npm run test:plugins
```
Then, with the app running:
1. Boot on an existing SQLite database from before the conversion: the data is
there, the `p_<id>_` tables exist and the legacy tables are gone.
2. Use every feature path once: create, edit, delete, anything background.
3. Remove the plugin's permission from a role: the rail item disappears and
the API answers 403.
4. Disable, enable and disable the plugin in admin without a restart: its
views show the "needs the plugin" placeholder while off and come back after.
5. On Postgres or MySQL if available: `npm run verify:dialect`, then boot.
6. Update this document for anything the step added to the contract.