diff --git a/.gitignore b/.gitignore index 458fe27ac..812a34091 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,5 @@ electron/build-info.cjs /scripts/auto-fix-blocklist.json /scripts/auto-fix-state.json /scripts/auto-fix-report-*.json + +/packages/plugin-sdk/ARCHITECTURE.md diff --git a/packages/plugin-sdk/ARCHITECTURE.md b/packages/plugin-sdk/ARCHITECTURE.md deleted file mode 100644 index 3f1a69139..000000000 --- a/packages/plugin-sdk/ARCHITECTURE.md +++ /dev/null @@ -1,3287 +0,0 @@ -# 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//`, 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 `.`. 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//NNNN_name.sql`. Table names are prefixed -`p__`, which `defineTable` adds. `ctx.kv` is for small -key/value state, and `ctx.files.dataDir()` (**B13**, needs `files:own`) is a -per-plugin folder, `/plugin-data/`, 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 -`/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__`. - -| 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//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//` and sockets under -`/plugin-ws//`, 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//`, 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/` 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 `//` (automation -webhooks), which the plugin's router serves in place. 2.8 clients' login -routes (`/users//authorize`, `/users//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: { : 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 ``, 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//frontend.js?v=` and calls `activate(app)`. - In dev the workspace plugins are imported from - `plugins//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/.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//` 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//` 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//` 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 `.`), `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 `_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 / " 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[]` 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 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:` 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//locales/en.json` and load into the -`` 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 `:`. -Translations go to `plugins//locales/translated/.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..*` 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(".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 -`".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(".hostSettingsSync", { exportValue, importValue })`. -- `ctx.registry.provide(".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//.cast`. **D4**: -`createFinished` moves a file written elsewhere (guacd's, in remote desktop's -recordings folder) to `ctx.files.dataDir()/session_recordings//`, -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()`. - -**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()`, 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", 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 ". -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/`; the 2.8 path -`/automations/webhook/` 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`, 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:"` 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()` 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[]`. 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 `.` 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//tests/backend` (node) and -`plugins//tests/frontend` (jsdom), run by its own vitest config: - -```ts -// plugins//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 `` or `#`, 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 `/plugin-api/sso/callback`. Providers that - existed before 2.9 keep sending `/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:` 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 - `/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 `/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 `/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 `_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 - `_auth_required` and takes `_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 `/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 `/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/[/|/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 , which needs the 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 - // "_check_required" with `details` merged in, or - // { action: "release", details? } once the banner says it is done, which - // sends "_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 `_auth_required` -and later calls the provider's `startInteraction` for `_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//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//` 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// - manifest.json id, capabilities, contributions - package.json @termix-plugin/, 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//`: - -```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//`. 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 - `-.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//` and run `npm install` so the workspace goes away. -4. Run `npm run build:plugins` and check `dist/plugins//` 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//`. - -### 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//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 `.`, 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"], // "://..." 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 `.` - 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..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..`, 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..*`, 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, `/plugin-data/` | -| `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//`, and the public origin for callback URLs | -| `ws.route/upgrade` | `network:serve` | Sockets at `/plugin-ws//` | -| `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/`, 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 - `/plugin-data//bin/` 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 `"://..."` 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:` 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__` 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:` 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 `-.tmxplug` (see [Packaging and signing](#packaging-and-signing)). `--out ` picks the folder. | -| `termix-plugin sign` | Signs a `.tmxplug` with the key in `TERMIX_PLUGIN_SIGNING_KEY` and writes `.tmxplug.sig`. | -| `termix-plugin verify` | Checks a `.tmxplug` against its `.sig` and `--key [,]`. | -| `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//`, 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 -`.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, -`/plugins/` holds either a plugin folder (as before) or a -`.tmxplug` with an optional `.tmxplug.sig` next to it. A -`.tmxplug` is unpacked fresh on every load into -`/plugins/.unpacked//` (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//` 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/`, 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..` | `events:core` | - | anything registered through `app` | `ui:surface` | - - Workspaces needs `db:own`, `network:serve` and `ui:surface`, nothing else. - -3. `cd plugins/ && 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//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("")` | - | `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.` 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("", 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/ && npx termix-plugin migrations adopt_` writes - `migrations/{sqlite,postgres,mysql}/0001_adopt_
.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 "\|" 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 `.`, 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(""))` 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//`) and sockets - on `ctx.ws.route` / `ctx.ws.upgrade` (`/plugin-ws//`). Route paths - are relative to the mount point. -2. Update every route's OpenAPI JSDoc path to `/plugin-api//...`. -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` - (`.`, 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//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//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/.json` - into `plugins//locales/translated/.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//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/ && 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/ && 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__` 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.