/** * The backend contract: what a plugin's activate(ctx) receives. * * These are types only. Core builds the real object in * src/backend/plugins/ctx.ts and injects it at activation, so the SDK stays a * description of the contract rather than an implementation a plugin could * reach around. * * ctx.ssh is the part of step B's host surface that A8 needed early, so plugin * transports could leave core's internals behind. */ import type { HostSshOptions } from "./ssh-options.js"; export type { HostSshOptions } from "./ssh-options.js"; import type { PluginManifest, PluginSettingsField } from "./manifest.js"; import type { PluginTableDefinition } from "./db.js"; /** * Thrown when a plugin calls a guarded member without the capability. * * Two things must be true: the manifest declares it, and it is granted. A * grant for something the manifest never declared is ignored, so widening a * plugin's reach always needs a new manifest the user can see. */ export class PluginCapabilityError extends Error { readonly code = "EPLUGINCAP"; readonly pluginId: string; readonly capability: string; constructor(pluginId: string, capability: string) { super( `Plugin "${pluginId}" is not granted the "${capability}" capability. ` + `Declare it in the manifest's capabilities array and grant it in the plugin's settings.`, ); this.name = "PluginCapabilityError"; this.pluginId = pluginId; this.capability = capability; } } /** * Thrown by a login method or second factor to refuse a sign-in with a * message the user can see. `code` is a stable id the login screen can map to * its own text; `status` is the HTTP status for form methods. */ export class LoginMethodError extends Error { readonly status: number; readonly code?: string; constructor(message: string, status = 401, code?: string) { super(message); this.name = "LoginMethodError"; this.status = status; this.code = code; } } export interface PluginLogger { debug: (message: string) => void; info: (message: string) => void; warn: (message: string) => void; error: (message: string, error?: Error) => void; } export interface PluginEvents { /** Topics are namespaced to plugin..* unless the plugin holds events:core. */ emit: (topic: string, payload: unknown) => void; on: (topic: string, listener: (payload: unknown) => void) => () => void; } /** Small key/value state owned by this plugin. Requires kv:own. */ export interface PluginKeyValue { get: (key: string) => Promise; set: (key: string, value: unknown) => Promise; delete: (key: string) => Promise; list: () => Promise; } /** * A folder on disk this plugin owns, for state too large for ctx.kv (session * recordings, uploaded files). Requires files:own. */ export interface PluginFiles { /** * The plugin's own folder under DATA_DIR, created if it does not exist yet. * Always the same path for this plugin; a plugin lays out its own * subdirectories underneath it. */ dataDir: () => Promise; } /** * The plugin's own tables. * * `define` registers a table definition and returns the Drizzle table object * to query through `db`. Both require db:own. * * Scoped by name: every table carries the p__ prefix, and `refs` exposes * users, ssh_data, roles and user_roles (behind db:core-refs) so a plugin can * join against them. In-process code could reach around * all of this. The capability, the prefix, lint and review are the contract, * not a sandbox. */ export interface PluginDatabase { /** Registers a definition and returns its queryable table object. */ define: (definition: PluginTableDefinition) => Promise; /** Drizzle handle, scoped to this plugin's tables. */ client: () => Promise; /** * Core tables a plugin may join against: users, hosts, roles, userRoles. * Needs db:core-refs. Treat them as read-only. */ refs: () => Promise; /** * Flushes writes to disk. Call it after every write: on SQLite the database * lives in memory and is saved to its encrypted file only when asked, so a * write without it can be lost on restart. A no-op on Postgres and MySQL. */ persist: (options?: { /** * Mark the database dirty and let the debounced save pick it up, rather * than saving now. For frequent, low-value writes such as samples, where * losing a couple of seconds on a crash is fine. */ lazy?: boolean; }) => Promise; /** * The engine the server runs on. Table objects encode the same on all * three, but MySQL has no RETURNING, so portable code reads a written row * back by its key rather than relying on .returning(). */ readonly dialect: "sqlite" | "postgres" | "mysql"; } /** A row's shape on the wire, before it is written to a table. */ export type SyncRow = Record; export interface SyncEntityReference { /** * Where a local numeric id lives. A plain column ("credentialId"), or a * path into a JSON column: "jumpHosts[].hostId" walks every item of the * jumpHosts array, "layout.panes[].hostId" walks nested objects. A JSON * column may be stored as a string or as parsed JSON. */ field: string; /** * Wire field holding the portable id, e.g. "credentialSyncId". Only for a * plain column; a JSON path is rewritten in place. */ syncField?: string; /** The entity the id points at. */ entityType: string; /** How a JSON path stores the local id. Defaults to "number". */ idType?: "number" | "string"; } export interface SyncEntityPermissions { /** Checked before a pushed row that does not exist yet is written. */ create?: string; /** Checked before a pushed change to an existing row is written. */ update?: string; /** Checked before a pushed delete runs. */ delete?: string; } export interface SyncWriteEvent { /** The local id of the row that was written. */ id: number | null; userId: string; /** The row as it arrived, before references were resolved. */ wire: SyncRow; created: boolean; /** Another entity's syncId to its local id here, for ids serialize put on the wire. */ resolveId?: (entityType: string, syncId: string) => Promise; } export interface SyncEntityRegistration { /** Stable wire name. Never change it: records on both sides match on it. */ type: string; /** The table, as returned by ctx.db.define. */ table: unknown; /** Column holding the owning user id. Defaults to "userId". */ userColumn?: string; /** Fields stored encrypted with the owner's data key. Plaintext on the wire. */ encryptedFields?: readonly string[]; /** Local-id to sync-id translations applied on the way out and back. */ references?: readonly SyncEntityReference[]; /** Lower sorts first. Reference targets must sort before their referrers. */ order?: number; /** * Fields that stay on this side: never sent, never overwritten by an * inbound row, and ignored when deciding whether a row changed. */ readOnlyFields?: readonly string[]; /** One row per user rather than many, keyed on the owner. */ singleton?: boolean; /** * Names core uses when its own rows point at this entity without knowing * which plugin provides it. Nothing in core uses one today. */ answersTo?: readonly string[]; /** * Rows this returns false for are left out of sync in both directions, for * state that belongs to one install (a per-device "last session"). */ shouldSync?: (row: SyncRow) => boolean; /** RBAC permissions a pushed change needs on the server. */ permissions?: SyncEntityPermissions; /** * Runs after `references` on the way out, for anything they cannot * express. Gets the row with references already translated. */ serialize?: ( row: SyncRow, resolveSyncId: (entityType: string, id: number) => Promise, ) => Promise; /** Runs after `references` on the way in. */ deserialize?: ( row: SyncRow, resolveId: (entityType: string, syncId: string) => Promise, ) => Promise; /** Runs after a synced row was written on this side. */ afterWrite?: (event: SyncWriteEvent) => Promise; /** * Replaces the plain delete of a synced row, for rows with cleanup of their * own. Gets the stored row. */ remove?: (row: SyncRow, userId: string) => Promise; } /** * Adds an entity to sync between a linked desktop and its server. * * Deletes need nothing extra: core notices a registered row is gone and * sends the delete itself. */ export interface PluginSync { registerEntity: (entity: SyncEntityRegistration) => void; /** Needs sync:provide. Takes over /sync/v2 and /sync/link. Removed on deactivate. */ provide: (provider: PluginSyncProvider) => () => void; /** Needs sync:provide. The replica store, change feed and link registry. */ readonly host: PluginSyncHost; } /** * The plugin that owns server and desktop sync. Core forwards every request * under /sync to `handle` (an Express style handler) and answers 404 when no * plugin provides it. Call `next()` to let core answer instead. */ export interface PluginSyncProvider { handle: ( req: unknown, res: unknown, next: (error?: unknown) => void, ) => void | Promise; } /** A kind of data that syncs, as the sync provider sees it. */ export interface SyncEntityInfo { type: string; owner: string; order: number; readOnly: boolean; /** Whether the row would show in a load even if it exists. */ covers?: (syncId: string) => boolean; } /** A row ready for the wire, with its content hash. */ export interface SyncWireRow { syncId: string; wire: SyncRow; hash: string; } export interface SyncRecordInfo { entityType: string; syncId: string; revision: number; hash: string | null; deleted: boolean; error: string | null; errorHash: string | null; seq: number; } export interface SyncConflictInfo { id: number; entityType: string; syncId: string; localRow: string; createdAt: string; } export interface SyncAccountInfo { id: string; username: string; isAdmin: boolean; active: boolean; } export interface SyncProxyHeader { name: string; value: string; } export interface SyncBasicAuth { username: string; password: string; } /** A client certificate for a server that asks for mutual TLS. */ export interface SyncClientCert { /** Base64 of the PFX/P12 file. */ pfx: string; passphrase: string; /** The file name, to show which certificate is saved. */ name: string | null; } /** Extra per server connection options. Kept open so new ones need no migration. */ export type SyncLinkOptions = Record; /** One server a desktop is linked to, with a replica of its data. */ export interface SyncLinkInfo { id: number; /** The local user the replica belongs to. */ userId: string; serverUrl: string; serverName: string | null; serverVersion: string | null; sessionToken: string | null; customHeaders: SyncProxyHeader[]; basicAuth: SyncBasicAuth | null; allowInvalidCertificate: boolean; clientCert: SyncClientCert | null; remoteUserId: string | null; remoteUsername: string | null; account: { remoteUserId?: string; username?: string; isAdmin?: boolean; roles?: string[]; permissions?: string[]; } | null; disabledTypes: string[]; /** Extra connection options for this server, kept open for new ones. */ options: SyncLinkOptions; knownTypes: string[]; cursor: number; status: "idle" | "syncing" | "offline" | "error" | "signed_out"; lastError: string | null; linkedAt: string; lastSyncAt: string | null; } export interface SyncLinkCreate { userId: string; serverUrl: string; serverName?: string | null; serverVersion?: string | null; sessionToken: string; customHeaders?: SyncProxyHeader[]; basicAuth?: SyncBasicAuth | null; allowInvalidCertificate?: boolean; clientCert?: SyncClientCert | null; remoteUserId?: string | null; remoteUsername?: string | null; } export type SyncLinkPatch = Partial< Pick< SyncLinkInfo, | "serverName" | "serverVersion" | "sessionToken" | "customHeaders" | "basicAuth" | "allowInvalidCertificate" | "clientCert" | "remoteUserId" | "remoteUsername" | "account" | "disabledTypes" | "options" | "knownTypes" | "cursor" | "status" | "lastError" | "lastSyncAt" > >; export type SyncRemoteErrorKind = | "offline" | "tls" | "client_cert" | "proxy" | "basic_auth" | "signed_out" | "server"; export interface SyncRemoteError extends Error { readonly kind: SyncRemoteErrorKind; readonly status?: number; } export interface SyncRemoteTarget { serverUrl: string; sessionToken?: string | null; customHeaders?: SyncProxyHeader[]; basicAuth?: SyncBasicAuth | null; allowInvalidCertificate?: boolean; clientCert?: SyncClientCert | null; } export interface SyncRemoteRequest { method?: string; body?: unknown; timeoutMs?: number; signal?: AbortSignal; } export interface SyncPushOp { entityType: string; syncId: string; baseRevision: number; op: "upsert" | "delete"; row?: SyncRow; } export type SyncPushResult = | { syncId: string; entityType: string; status: "ok"; revision: number } | { syncId: string; entityType: string; status: "conflict"; revision: number; deleted: boolean; row: SyncRow | null; } | { syncId: string; entityType: string; status: "rejected"; reason: string }; export interface SyncRemotePlugin { id: string; name: string; version: string; source: string; artifact: boolean; enabled: boolean; active: boolean; desktop: string; syncEntities: string[]; granted: string[]; } /** * What the sync provider gets from core: the replica store, the change feed, * the linked server registry, sessions and a few helpers. Core keeps all of * it neutral; the provider decides when and how to use it. Needs sync:provide. */ export interface PluginSyncHost { /** True on the desktop app's own backend. */ readonly embedded: boolean; info: { appName: () => Promise; version: () => string }; entities: { list: () => SyncEntityInfo[]; get: (type: string) => SyncEntityInfo | undefined; /** Registers core's own entities once. */ ensureCore: () => Promise; onChanged: (listener: () => void) => () => void; onLocalWrite: (listener: () => void) => () => void; }; /** Rows and bookkeeping of one local user's replica. */ replica: { resolvers: (userId: string, onMissing?: () => void) => unknown; loadRows: ( type: string, userId: string, resolvers: unknown, ) => Promise<{ rows: Map; failed: Set }>; loadRow: ( type: string, userId: string, syncId: string, resolvers: unknown, ) => Promise; writeRow: ( type: string, userId: string, wire: SyncRow, resolvers: unknown, ) => Promise; deleteRow: (type: string, userId: string, syncId: string) => Promise; orderSelfReferences: (type: string, rows: SyncRow[]) => SyncRow[]; counts: (userId: string) => Promise>; wipe: (userId: string) => Promise; regenerateSyncIds: (userId: string) => Promise; removeSharedCopies: (userId: string) => Promise; records: { list: (userId: string) => Promise>; put: ( userId: string, type: string, syncId: string, values: { revision: number; hash: string | null; deleted: boolean }, ) => Promise; setError: ( userId: string, type: string, syncId: string, reason: string, hash: string, ) => Promise; clearErrors: (userId: string) => Promise; listErrors: (userId: string) => Promise; deleteForUser: (userId: string) => Promise; key: (type: string, syncId: string) => string; }; conflicts: { save: ( userId: string, type: string, syncId: string, row: SyncRow, revision: number, ) => Promise; list: (userId: string) => Promise; get: (userId: string, id: number) => Promise; delete: (userId: string, id: number) => Promise; }; }; /** The server side: what changed for an account and what a desktop pushed. */ feed: { withUserLock: (userId: string, run: () => Promise) => Promise; changes: ( userId: string, cursor: number, limit: number, types?: string[], ) => Promise<{ records: Array<{ seq: number; entityType: string; syncId: string; revision: number; deleted: boolean; row: SyncRow | null; }>; hasMore: boolean; nextCursor: number; entityTypes: Array<{ type: string; owner: string; order: number; readOnly: boolean; }>; }>; parsePushOps: (body: unknown) => Promise; applyPush: (userId: string, ops: SyncPushOp[]) => Promise; /** Streams a change notice to the account's open event streams. */ listen: (userId: string, res: unknown) => Promise<() => void>; }; /** * The linked server registry, kept in core so it is there before plugins. * One link per local user. Without a user, the active account is meant. */ links: { list: () => Promise; get: (userId?: string) => Promise; create: (link: SyncLinkCreate) => Promise; update: ( patch: SyncLinkPatch, userId?: string, ) => Promise; remove: (userId?: string) => Promise; }; /** Desktop accounts: the local user and the replica user of each server. */ accounts: { list: () => Promise; /** A new empty account, ready to be linked to a server. */ create: () => Promise; /** Which account the app signs in as from now on. */ setActive: (userId: string) => Promise; activeId: () => Promise; /** Deletes an account and all its data. The last one cannot go. */ remove: (userId: string) => Promise; }; http: { json: ( target: SyncRemoteTarget, pathname: string, request?: SyncRemoteRequest, ) => Promise; fetch: ( target: SyncRemoteTarget, pathname: string, request?: SyncRemoteRequest, ) => Promise<{ ok: boolean; status: number; body: AsyncIterable | null; }>; normalizeServerUrl: (input: string) => string | null; isRemoteError: (error: unknown) => error is SyncRemoteError; }; sessions: { /** A long lived session for a linked desktop. Shows in the account's sessions. */ issueDesktop: ( userId: string, options: { days: number; deviceInfo: string }, ) => Promise; revoke: (sessionId: string) => Promise; find: ( sessionId: string, ) => Promise<{ expiresAt: string; deviceInfo: string | null } | null>; findUser: ( userId: string, ) => Promise<{ id: string; username: string } | null>; /** The user and session behind a token, or null when it is not valid. */ verify: ( token: string, ) => Promise<{ userId: string; sessionId?: string } | null>; /** Sets the sign in cookie on a response, like a login does. A name sets another cookie. */ setCookie: ( req: unknown, res: unknown, value: string, options?: { name?: string; days?: number }, ) => void; /** Removes the sign in cookie, without ending the session behind it. */ clearCookie: (req: unknown, res: unknown, name?: string) => void; /** Express middleware that signs the request in and sets userId and sessionId. */ middleware: () => unknown; /** Express middleware that lets a request through only on a desktop. */ desktopOnly: () => unknown; }; hosts: { findForSync: ( userId: string, syncId: string, ) => Promise<{ id: number; name: string | null } | null>; statusBySyncId: (userId: string) => Promise>; }; plugins: { listForDesktop: () => Promise<{ plugins: SyncRemotePlugin[]; registryKeys: unknown; }>; /** Sends a plugin archive; false when there is no such plugin to share. */ sendPackage: (pluginId: string, res: unknown) => Promise; mirror: (link: SyncLinkInfo) => Promise; lastRemote: () => SyncRemotePlugin[]; localNames: () => Map; }; desktop: { /** Tells the app's main process how to reach the linked origin. */ applyProxy: (link: SyncLinkInfo | null) => Promise; deviceName: () => string; }; alerts: { send: (alert: { title: string; body: string; severity: "info" | "warning" | "error"; category: string; dedupeKey: string; link?: { tab: string }; }) => Promise; }; } /** * A plain key/value hand-off between plugins. `provide` and `revoke` only take * keys under the plugin's own id (`.something`); anyone may * `consume`. */ export interface PluginRegistry { provide: (key: string, value: T) => void; consume: (key: string) => T | undefined; revoke: (key: string, value?: unknown) => boolean; } export interface PluginServices { /** * `name` registers one of several providers of the same service, for a * service keyed by something the caller picks (sessions.live by session * type). It must be listed in the manifest's `provides[].names`. */ provide: ( service: string, implementation: T, options?: { name?: string }, ) => void; /** * The service must be listed in the manifest's `requires` (or be one this * plugin provides). `provider` picks a named provider; omitted, the unnamed * one. A `userId` other than the acting user needs users:impersonate. */ get: ( service: string, options?: { userId?: string; provider?: string }, ) => T; /** Names of the providers running now ("" for an unnamed one). */ providers: (service: string) => string[]; } export interface PluginSecrets { /** * The acting user's own copy of a secret this plugin stored, decrypted, or * null when nothing is stored. Needs secrets:own and an actor. Not audited: * a plugin reads its keys on every request that uses them. */ get: (key: string) => Promise; /** * Stores a secret for the acting user, encrypted at rest. Null clears it. * Needs secrets:own and an actor. Audited without the value. */ set: (key: string, value: string | null) => Promise; /** Removes the acting user's secret. Needs secrets:own. Audited. */ delete: (key: string) => Promise; /** * Encrypts a value with the installation key, for a plugin that keeps a * secret in its own table and has to read it without an acting user (a * second factor checked during login). Needs secrets:own. Not audited. */ seal: (value: string) => Promise; /** Reverses seal(). Null when the value cannot be decrypted. */ unseal: (sealed: string) => Promise; offer: ( key: string, resolve: (userId: string) => Promise | string | null, ) => void; withdraw: (key: string) => boolean; getShared: ( pluginId: string, key: string, options?: { userId?: string }, ) => Promise; } /** * Express types, structurally. The SDK does not depend on express: a plugin * brings its own copy, and a nominal type from core's would not match it. */ export interface PluginRequestLike { method: string; url: string; path: string; headers: Record; [key: string]: unknown; } export interface PluginResponseLike { status: (code: number) => PluginResponseLike; json: (body: unknown) => unknown; [key: string]: unknown; } export type PluginNextFunction = (error?: unknown) => void; export type PluginMiddleware = ( req: PluginRequestLike, res: PluginResponseLike, next: PluginNextFunction, ) => void; export interface PluginRouterOptions { /** * Paths served without authentication, relative to the plugin's mount point * and matched exactly ("/callback", not "/plugin-api//callback"). A * ":name" segment matches one segment, and a trailing "/*" matches any * number of further segments, for a proxied page ("/chooser/:id/*"). * An entry only opens the route registered with that same path, for the * methods it was registered with, so a literal route beside a ":param" * ("/webhook/config" next to "/webhook/:token") still needs a login. * * For the handful of routes an unauthenticated third party has to reach: an * OIDC callback, an inbound webhook. Every entry is audited when the router * is registered and every request that uses one is logged, because "this * plugin opened a hole in auth" is exactly the thing an operator should be * able to find later. */ public?: readonly string[]; /** Body size limit for JSON and urlencoded bodies. Defaults to "1mb". */ bodyLimit?: string; /** * Skips the built-in body parsers entirely, for a router that parses its own * (multer, a raw body for signature checking, a proxied stream). */ rawBody?: boolean; /** * Requests per minute one address may make to this plugin's public routes. * Defaults to 120, which a proxied page loading its own scripts and images * can pass on its own. Capped at 10000, and an admin's * TERMIX_PLUGIN_PUBLIC_RATE_LIMIT wins over it. */ publicRateLimit?: number; } /** * HTTP routes, served at /plugin-api// by the main backend server. * * No plugin gets its own port or nginx block. Core runs auth, the actor, the * enabled check, body limits and an error wrapper in front of every route, so * a plugin's router only has to handle its own paths. */ export interface PluginHttp { /** * Returns an Express Router mounted at /plugin-api//. Call it once in * activate and register routes on the result; it is unmounted automatically * on deactivate. */ router: (options?: PluginRouterOptions) => T; /** * The public base URL a request came in on: origin plus the install's base * path, no trailing slash. Honours forwarded headers from a trusted proxy, * BASE_PATH and EXTERNAL_FORCE_HTTPS. For building absolute callback URLs. */ baseUrl: (req: unknown) => string; } /** * Core RBAC, as a plugin sees it. * * Every method takes either a short name this plugin declares * ("services.use"), which is prefixed with the plugin id, or a full id * belonging to another plugin or a core group, which is used as given. That is * what makes a cross-plugin check expressible without letting a plugin gate its * own routes on someone else's authority. */ export interface PluginRbac { /** Whether the user this call is running as holds `permission`. */ has: (permission: string) => Promise; /** Whether `userId` holds `permission`. */ hasFor: (userId: string, permission: string) => Promise; /** * Middleware that rejects a request whose user lacks `permission`. * * A plugin may only require a permission it declares in * contributes.permissions, so a route cannot gate on admin.users.manage and * borrow someone else's authority. */ require: (permission: string) => PluginMiddleware; } export interface PluginWebSocketConnection { /** The authenticated user for this socket. */ readonly userId: string; /** The upgrade request, for query parameters and headers. */ readonly request: unknown; /** The ws WebSocket. Binary frames and backpressure work as usual. */ readonly socket: unknown; /** The client address, behind a trusted proxy when one is configured. */ readonly clientIp: string; /** The public origin the request came in on, for browser redirects. */ readonly requestOrigin: string; /** * Whether the user's data key is still unlocked. False for a guest and * once a session expires, so a long-lived socket can refuse work it can no * longer decrypt for. */ isDataUnlocked: () => boolean; } export type PluginWebSocketHandler = ( connection: PluginWebSocketConnection, ) => void | Promise; export interface PluginWebSocketOptions { /** * Serves the upgrade without core authentication. The handler gets an empty * userId and must authenticate the socket itself, which is what a share-link * guest or a token-in-query protocol needs. */ public?: boolean; /** * With `public`, still resolves a token when the client sent one, so one * route can serve signed-in users and a guest arriving with a token of its * own. The handler gets the user id, or an empty one for a guest. */ optionalAuth?: boolean; } /** * WebSocket routes, served at /plugin-ws// through the main server's * upgrade event. Every socket a plugin opened is closed on deactivate. */ export interface PluginWebSockets { route: ( path: string, handler: PluginWebSocketHandler, options?: PluginWebSocketOptions, ) => void; /** * Hands the raw upgrade to the plugin, for a library that insists on owning * its own WebSocket server (guacamole-lite attaches this way). Auth and the * actor still run first. */ upgrade: ( path: string, handler: ( request: unknown, socket: unknown, head: unknown, userId: string, ) => void, options?: PluginWebSocketOptions, ) => void; } /** * Anything the runtime should undo when the plugin deactivates. Everything * registered through ctx is tracked automatically; this is for resources a * plugin creates itself, such as a server or an interval. */ export interface PluginDisposables { add: (dispose: () => void | Promise) => void; } /** * The plugin's own settings, as declared in contributes.settings. * * Reading and writing a plugin's own settings needs no capability: the * manifest already says which fields exist, and a plugin that could not read * its own configuration would be useless. Only readCore, which reaches outside * the plugin's namespace, is gated on settings:read-core. * * Values are validated against the declared field on every write, so a key the * manifest never declared is rejected rather than stored. Secret fields are * encrypted at rest and returned decrypted here; the HTTP surface never sends * them to a browser. */ export interface PluginSettings { /** Install-wide admin settings. */ get: (key: string) => Promise; set: (key: string, value: unknown) => Promise; /** Per-user settings. */ getUser: (userId: string, key: string) => Promise; setUser: (userId: string, key: string, value: unknown) => Promise; /** * Per-host settings. A host follows its host defaults (server, user, * folder) for every key it has not set itself, and the value read here is * already the resolved one. */ getHost: ( hostId: number | string, key: string, ) => Promise; /** * The value as one user sees it. Only a `personal` field differs from * getHost: on a host shared with that user and following its defaults, it * resolves against the user's own defaults rather than the owner's. */ getHostFor: ( hostId: number | string, userId: string, key: string, ) => Promise; /** * What a user's hosts get for a host field when nothing more specific * sets it: their own defaults, then the server's, then the manifest * default. For something with no host, such as a quick connect. */ getHostDefault: ( userId: string, key: string, ) => Promise; /** * Writes a host value, which makes it the host's own rather than * following its defaults. `{ inherit: true }` hands the key back to the * defaults instead and ignores `value`. */ setHost: ( hostId: number | string, key: string, value: unknown, options?: { inherit?: boolean }, ) => Promise; /** * Every host that saved a value for one of this 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 is in flight. * Requires hosts:read. Secret fields cannot be listed. */ listHostValues: ( key: string, ) => Promise>; /** Every value in one scope, with declared defaults merged in. */ getAll: ( scope: "admin" | "user" | "host", scopeId?: string | number, ) => Promise>; /** * Fires when a key in this plugin's settings changes, from either the ctx * API or the HTTP routes. Disposed automatically on deactivate. */ onChange: (key: string, listener: (value: unknown) => void) => () => void; /** * Checks a save from the settings screen or host editor before anything is * written. Return field key to message for what is wrong; nothing means the * save goes ahead. Disposed automatically on deactivate. */ onValidate: ( scope: "admin" | "user" | "host", validator: ( values: Record, /** * `defaults` is set when the values are a host defaults level being * saved rather than one host, so there is no hostId. */ context: { hostId?: number; defaults?: { level: "admin" | "user" | "folder" }; }, ) => Record | void | Promise | void>, ) => () => void; /** * Reads a core server setting from a small documented allowlist. * Requires the settings:read-core capability. */ readCore: (key: string) => Promise; } /** A host's non-secret fields, for listing, tag matching and display. */ export interface PluginHostSummary { id: number; userId: string; name: string | null; ip: string; port: number; username: string; /** Comma-separated, as ssh_data stores them. Split on "," to match tags. */ tags: string | null; folder: string | null; authType: string; } /** * A host as ctx.hosts hands it to a plugin that creates, updates or scans * hosts on the user's behalf (import, discovery, sync), decrypted for that * user. Every field is listed here and core builds it field by field, so a * core column a plugin needs has to be added to this contract first. Carries * no secret auth material (password, key, vault tokens): a plugin that * creates a host picks an authType and, for "credential", a credentialId it * does not need to see the contents of. */ export interface PluginHostRecord { id: number; userId: string; /** Stable across a desktop and the server it syncs with. */ syncId: string | null; name: string | null; ip: string; port: number; username: string; authType: string; credentialId: number | null; overrideCredentialUsername: boolean; /** "ssh", or the id of the plugin protocol a host without SSH uses. */ connectionType: string; /** Comma-separated, as ssh_data stores them. */ tags: string | null; folder: string | null; parentHostId: number | null; pin: boolean; notes: string | null; jumpHosts: PluginHostJumpHost[]; enableSsh: boolean; sshPort: number | null; statusCheckEnabled: boolean; /** Seconds between status checks; null follows the global setting. */ statusCheckInterval: number | null; connectionOrigin: "local" | "remote" | null; /** Keepalive, legacy algorithms, agent and environment options. */ sshOptions: HostSshOptions; /** * The calling plugin's own host settings, secrets redacted. Another * plugin's settings are that plugin's to hand out, through a service. */ pluginSettings: Record; /** Core's last reachability check, or null before the first one. */ status: PluginHostStatusEntry | null; /** Desktop only: kept on this device, never synced to the server. */ localOnly: boolean; createdAt: string | null; updatedAt: string | null; } export interface PluginHostJumpHost { hostId: number; } /** * The fields a plugin may set when it creates a host. A key not listed here * is refused with an error rather than dropped. Host settings go in * `pluginSettings`, keyed by the plugin that declares them, and are * validated against that plugin's manifest: an undeclared key is refused, and * settings for a plugin that is not running are skipped and logged. */ export interface PluginHostCreateInput { name: string; ip: string; port: number; username: string; authType: string; credentialId?: number | null; overrideCredentialUsername?: boolean; connectionType?: string; /** Comma-separated or a list. */ tags?: string | string[] | null; folder?: string | null; pin?: boolean; notes?: string | null; jumpHosts?: PluginHostJumpHost[] | null; enableSsh?: boolean; sshPort?: number | null; statusCheckEnabled?: boolean; statusCheckInterval?: number | null; forceKeyboardInteractive?: boolean; sshOptions?: HostSshOptions | null; /** Host settings by plugin id, the shape a Termix export carries. */ pluginSettings?: Record>; /** * Each plugin's own host switch (its manifest enableKey), by plugin id. * A plugin that is not running is skipped and logged; one with no switch * is refused. */ pluginEnable?: Record; /** * Secrets, stored encrypted with the owner's data key and never returned * in a PluginHostRecord. An empty string or null clears the stored value. */ password?: string | null; key?: string | null; keyPassword?: string | null; keyType?: string | null; sudoPassword?: string | null; /** * Logins for plugin protocols (contributes.protocols), keyed by protocol * id. null removes that protocol's login. Unknown protocol ids are skipped. */ protocolAuth?: Record; } /** One protocol login on a host write. Absent keys keep what is stored. */ export interface PluginHostProtocolAuthInput { authType?: "direct" | "credential" | "none"; credentialId?: number | null; username?: string | null; password?: string | null; /** The protocol's declared credentialFields, secret ones included. */ fields?: Record; } /** Fields a plugin may change on a host the acting user owns. */ export type PluginHostUpdateInput = Partial; /** Every key PluginHostCreateInput and PluginHostUpdateInput accept. */ export const PLUGIN_HOST_INPUT_KEYS = [ "name", "ip", "port", "username", "authType", "credentialId", "overrideCredentialUsername", "connectionType", "tags", "folder", "pin", "notes", "jumpHosts", "enableSsh", "sshPort", "statusCheckEnabled", "statusCheckInterval", "forceKeyboardInteractive", "sshOptions", "pluginSettings", "pluginEnable", "password", "key", "keyPassword", "keyType", "sudoPassword", "protocolAuth", ] as const satisfies readonly (keyof PluginHostCreateInput)[]; /** The same shape canAccessHost returns internally, without secrets. */ export interface PluginHostAccess { hasAccess: boolean; isOwner: boolean; isShared: boolean; permissionLevel?: "connect" | "view" | "edit" | "manage"; expiresAt?: string | null; } export type PluginHostShareLevel = "connect" | "view" | "edit" | "manage"; export interface PluginShareTarget { type: "user" | "role"; id: string | number; } export interface PluginHostShareResult { hostId: number; shared: boolean; reason?: string; } /** A user or role a plugin can offer as a share target. No secrets, no roles' permission lists. */ export interface PluginShareableUser { id: string; username: string; } export interface PluginShareableRole { id: number; name: string; displayName: string | null; } /** One grant in ctx.hosts.setShares. */ export interface PluginHostShareGrant { target: PluginShareTarget; permissionLevel: PluginHostShareLevel; /** Omit for a grant that never expires. */ durationHours?: number; } /** * Hosts a plugin manages itself, for example from a config file. Core refuses * edits, deletes and share changes on a locked host from the UI, the API and * other plugins, and marks it in host reads so a layout can show it read only. * Pins and order stay with the user. Only the plugin that locks a host may * still write it. */ export interface PluginHostLockProvider { isLocked: (hostId: number) => boolean; /** A short line shown under the managed note, or null. */ note: (hostId: number) => string | null; /** False blocks adding hosts by hand (editor, import, other plugins). */ allowManualCreate: () => boolean; } export interface PluginHostLocksApi { /** Registers this plugin's locks. Needs hosts:lock. Removed on deactivate. */ provide: (provider: PluginHostLockProvider) => () => void; } /** * Hosts the acting user can see, and the RBAC + sharing operations a plugin * needs to build a feature (like fleets) on top of hosts it does not own. * * list/get/checkAccess need hosts:read. share, and the user/role pickers a * share UI needs, need hosts:write: sharing changes who can reach a host, * which is a write on that host's access, even though the plugin owns * neither the host nor the grant. */ export interface PluginHosts { /** Every host the acting user owns or was shared, without secrets. */ list: () => Promise; /** One host the acting user can see, or null if it does not exist or they cannot. */ get: (hostId: number) => Promise; /** The same access rule ctx.ssh.connect enforces, but naming the level, not just yes/no. */ checkAccess: ( hostId: number, level: PluginHostShareLevel, ) => Promise; /** * Creates a host owned by the acting user, encrypted the same way the host * editor's own create route does it. Needs hosts:write. For a plugin that * imports or discovers hosts on the user's behalf (B5's proxmox discovery). */ create: (host: PluginHostCreateInput) => Promise; /** * Updates a host the acting user owns. Refuses a host it does not own * (sharing a write onto someone else's host goes through share(), not * update()). Needs hosts:write. */ update: ( hostId: number, patch: PluginHostUpdateInput, ) => Promise; /** * Deletes a host the acting user owns, the same way the host editor's * delete does: its access grants, activity and plugin settings go with it * and remote sync gets a tombstone. Needs hosts:write, and the acting user * needs the core hosts.delete permission. False when the host does not * exist or is not theirs. */ delete: (hostId: number) => Promise; /** * Every host the acting user owns, decrypted and wide (PluginHostRecord, * not the narrow list() summary). For a plugin that scans its own hosts in * the background (auto-sync) and needs fields list() does not carry, such * as credentialId or jumpHosts. Needs hosts:write, matching create/update: * this is the same "full host detail" surface a create/update caller needs * to read back, not a wider read grant than hosts:read gives via list/get. */ listOwned: () => Promise; /** * Grants access to a host the caller manages, snapshotting shared secrets * for each target the same way the host editor's own share action does. * Refuses a host the caller does not hold "manage" on. */ share: ( hostId: number, targets: PluginShareTarget[], permissionLevel: PluginHostShareLevel, durationHours?: number, ) => Promise; /** * Replaces every grant on a host the acting user owns with this list. * Grants that stay keep their row and counters, missing ones are revoked. * Needs hosts:write. */ setShares: ( hostId: number, grants: PluginHostShareGrant[], ) => Promise; /** Users the caller may pick as a share target, for building a picker UI. */ listUsers: () => Promise; /** Non-system roles the caller may pick as a share target. */ listRoles: () => Promise; /** * Marks a live session on a host, for the online indicator. Counted with * every other feature's sessions; call the returned function once when the * session ends. Needs hosts:read. */ trackSession: (hostId: number) => () => void; /** * Adds a recent activity entry for the acting user, rate limited and * refused for a host they cannot reach, like the dashboard's own route. * Needs hosts:read. */ recordActivity: ( hostId: number, type: string, hostName: string, ) => Promise; /** * Core's reachability status, the one behind the host list's status dot. * Needs hosts:read. */ status: PluginHostStatusApi; /** * The hosts the acting user may connect to (their own and shared ones) as a * status provider needs them: address, port and jump hosts, no secrets. * Pass hostIds to narrow it. Needs hosts:read. */ probeTargets: (filter?: { hostIds?: number[]; }) => Promise; /** Whether a session is open to the host right now. Needs hosts:read. */ sessionActive: (hostId: number) => boolean; /** * Imports Termix JSON host rows for the acting user, the same way POST * /host/bulk-import does: up to 100 rows, jump hosts remapped by exportId, * defaults, protocol logins and plugin settings applied, one save at the end. * Needs hosts:write, plus credentials:write when credentials are passed, and * the acting user needs the core hosts.create and hosts.edit permissions. */ importMany: ( hosts: Record[], options?: PluginHostImportOptions, ) => Promise; /** Imports the Host entries of an OpenSSH config file. Same rules as importMany. */ importSshConfig: ( content: string, options?: Pick, ) => Promise; /** * The acting user's own hosts as Termix JSON. Without share the secrets are * included, decrypted. With share every secret is left out and credential * logins point at a scrubbed credentials list by alias. Needs hosts:export * and is always audited. */ exportMany: (options?: { share?: boolean; /** Only these of the user's hosts. */ hostIds?: number[]; }) => Promise; /** * Validates and writes this plugin's own host settings from an imported * row. Called for every imported row once the host exists. Return the * values to write, or null to write nothing. Removed when the plugin * deactivates. Needs hosts:write. */ registerImportNormalizer: ( normalizer: PluginHostImportNormalizer, ) => () => void; /** The recent activity list on the dashboard. */ activity: PluginHostActivityApi; /** Read only hosts this plugin manages. */ locks: PluginHostLocksApi; /** Folder rows for the acting user. Needs hosts:write. */ folders: PluginHostFoldersApi; } export interface PluginHostFoldersApi { /** Creates the folder row if missing and sets its color and icon. */ ensure: ( name: string, style?: { color?: string | null; icon?: string | null }, ) => Promise; /** Removes the folder row and subfolders once no host is left in them. */ removeEmpty: (name: string) => Promise; } export interface PluginHostImportOptions { /** Update a host that matches on ip, port and username instead of skipping it. */ overwrite?: boolean; /** * All or nothing: if any row fails, the hosts this call created are deleted * and the result says rolledBack. Cannot be combined with overwrite. */ atomic?: boolean; /** Credential placeholders rows can refer to by credentialAlias. */ credentials?: Array>; } export interface PluginHostImportResult { success: number; updated: number; skipped: number; failed: number; errors: string[]; createdHostIds: number[]; rolledBack?: boolean; } export interface PluginHostExport { hosts: Array>; /** Share exports only. */ version?: string; exportedAt?: string; credentials?: Array>; } export type PluginHostImportNormalizer = ( raw: Record, ) => Record | null; export interface PluginHostProbeTarget { id: number; /** The user who owns the host; its jump hosts are theirs. */ ownerUserId: string; ip: string; port: number; connectionType: string; jumpHosts: Array<{ hostId: number }>; statusCheckEnabled: boolean; /** Seconds, or null to use the global interval. */ statusCheckInterval: number | null; } /** * The plugin that owns host status. Core keeps the routes, the sync feed and * ctx.hosts.status; this fills them. One provider at a time. */ export interface PluginHostStatusProvider { start: () => void; stop: () => void; get: (hostId: number) => PluginHostStatusEntry | null; /** * Every status known, after polling is brought in line with the request: * with hostIds, exactly those of the user's hosts; without, all of them. */ statusesFor: ( userId: string, hostIds: number[] | null, ) => Promise>; /** Restarts the user's checks. */ refresh: (userId: string) => Promise; check: (hostId: number) => Promise; reportLogin: (hostId: number, outcome: { ok: boolean }) => void; registerPort: ( connectionType: string, resolve: ( hostId: number, ) => number | undefined | Promise, ) => () => void; /** The global check interval in seconds. */ getIntervalSeconds: () => Promise; setIntervalSeconds: (seconds: number) => Promise; } export interface PluginActivityEntry { id: number; userId: string; type: string; hostId: number; hostName: string | null; timestamp: string; } export type PluginRecordActivityResult = | { status: "logged"; id: number } | { status: "rate_limited" } | { status: "invalid_type" } | { status: "denied" } | { status: "unavailable" }; /** The plugin that stores recent activity. One provider at a time. */ export interface PluginActivityProvider { record: ( userId: string, entry: { type: string; hostId: number; hostName: string }, ) => Promise; list: (userId: string, limit: number) => Promise; deleteByUserId: (userId: string) => Promise; deleteByHostIds: (hostIds: number[]) => Promise; } export interface PluginHostActivityApi { /** * Becomes the store behind the dashboard's recent activity and * ctx.hosts.recordActivity. Needs hosts:activity-provide. Removed on * deactivate. */ provide: (provider: PluginActivityProvider) => () => void; } /** * online: the port answers, a session is open or a login worked. offline: the * port does not answer. "reachable" is no longer sent and stays only so older * plugins still type check. */ export type PluginHostStatus = "online" | "reachable" | "offline"; export interface PluginHostStatusEntry { status: PluginHostStatus; lastChecked: string; /** @deprecated No longer sent. */ reason?: "host_key_changed"; } /** * Core checks every host with status checks on by opening a TCP connection to * it. A plugin that logs in to hosts can report a working login, which marks * the host online right away. */ export interface PluginHostStatusApi { /** * The last known status, or null when core has not checked the host yet or * the acting user cannot reach it. */ get: (hostId: number) => Promise; /** Checks the host now unless core checked it recently. */ check: (hostId: number) => Promise; /** * Reports a login attempt. Only a working login changes the status; a * failed one is ignored. Not audited: pollers call it every sample. */ reportLogin: ( hostId: number, outcome: { ok: boolean; hostKeyChanged?: boolean }, ) => void; /** * Tells core which port proves a host of this connection type is up, for * protocols whose port lives in the plugin's own host settings. Core falls * back to the host's port. Removed on deactivate. */ registerPort: ( connectionType: string, resolve: ( hostId: number, ) => number | undefined | Promise, ) => () => void; /** * Becomes core's host status. Needs hosts:status-provide. Removed on * deactivate, which hands status back to core's built-in checks. */ provide: (provider: PluginHostStatusProvider) => () => void; } /** * A host as the SSH pipeline reads it: resolved by core, with secrets filled * in for the acting user. Plugins get one from core and hand it back; they * should not build one from scratch. */ export interface PluginSshHost { id: number; ip: string; port: number; username: string; userId?: string | null; authType?: string | null; sshOptions?: HostSshOptions | null; [key: string]: unknown; } /** Picks keepalive and timeout defaults. "plugin" is the generic one. */ /** What a connection is for, as a free label. Auth providers see it. */ export type PluginSshPurpose = string; /** * Keepalive, timeout and environment defaults for a kind of connection. * terminal: an interactive shell. session: a long-lived browsing session. * stream: a long transfer or console. forward: port forwarding. background * (the default): short exec work such as polling. */ export type PluginSshProfile = "terminal" | "session" | "stream" | "forward" | "background"; export type PluginSshAuthOutcome = | { status: "ready" } | { status: "interaction-required"; /** Transports send "_auth_required", e.g. "opkssh". */ interaction: string; message: string; /** Extra boolean HTTP transports put in their 401 body. */ flag?: string; } | { status: "error"; code: | "missing-secret" | "invalid-key" | "passphrase-required" | "provider-missing" | "failed"; message: string; } | { /** Try once more with these config changes. */ status: "retry"; patch: Record; message: string; }; export type PluginSshBannerDecision = | { /** Hold the connect timeout open and wait for the out-of-band step. */ action: "hold"; timeoutMs: number; message: string; details?: Record; } | { /** The out-of-band step finished; resume the normal connect timeout. */ action: "release"; details?: Record; }; export interface PluginKeyboardInteractivePrompt { prompt: string; echo?: boolean; } /** * A round the user finishes in a browser: open a URL, confirm a code there, * then continue. Transports show one generic dialog for it and name their * messages after `id` (the terminal sends "_auth_required" and waits for * "_auth_continue"). */ export interface PluginBrowserSignInRound { kind: "browser"; /** The keyboard-interactive handler that claimed the round. */ id: string; /** Product name for the dialog title. */ label: string; url: string | null; /** A code to compare in the browser, or "N/A". */ code: string; instructions: string; } export type PluginKeyboardInteractiveDecision = | { kind: "auto"; responses: string[] } | PluginBrowserSignInRound | { kind: "totp"; promptIndex: number } | { kind: "input"; promptIndex: number; isPush: boolean }; export type PluginSshPromptRequest = | { kind: "totp"; prompt: string; retry: boolean } | { kind: "input"; prompt: string; echo: boolean; isPush: boolean } | PluginBrowserSignInRound; /** What a keyboard-interactive handler's detect may return. */ export type PluginKeyboardInteractiveDetection = | Exclude | Omit; /** * Claims keyboard-interactive rounds on any host, whatever its auth type, * for a server that speaks its own prompt style (a bastion asking for a * browser sign-in). Handlers run before core's TOTP and push detection. * * `settings` is this plugin's host-scope settings for the host, with manifest * defaults filled in. Both hooks are synchronous; they run inside ssh2's * keyboard-interactive callback. */ export interface PluginKeyboardInteractiveHandler { /** Listed in contributes.auth.keyboardInteractive. */ id: string; /** Product name shown when a browser round is claimed. */ label: string; detect: ( round: { name: string; instructions: string; prompts: PluginKeyboardInteractivePrompt[]; }, host: PluginSshHost, settings: Record, ) => PluginKeyboardInteractiveDetection | null; /** Answer password prompts with the stored password instead of asking. */ autoAnswerPasswords?: ( host: PluginSshHost, settings: Record, ) => boolean; } /** Answers keyboard-interactive prompts. Resolve null to give up. */ export interface PluginSshPromptChannel { ask: (request: PluginSshPromptRequest) => Promise; } export interface PluginSshConnectOptions { purpose?: PluginSshPurpose; /** Keepalive and timeout defaults. Defaults to "background". */ profile?: PluginSshProfile; /** Overall time to reach "ready". Defaults to 30s. */ timeoutMs?: number; /** Without one, the stored password answers password prompts. */ prompt?: PluginSshPromptChannel; /** Config fields to force, e.g. a longer readyTimeout. */ overrides?: Record; /** * An already-open stream to the host, such as a forwardOut channel through * another host. The transport step is skipped: no port knocking, proxy, * jump hosts or DNS lookup. Host key checks and auth still run. */ sock?: unknown; } export interface PluginSshExecOptions extends PluginSshConnectOptions { /** Time to wait for the command to finish. Default 30s, at most 10 minutes. */ timeoutMs?: number; /** Most bytes kept of each of stdout and stderr. Default 256 KB, at most 4 MB. */ maxOutputBytes?: number; /** Written to the command's standard input, then closed. */ stdin?: string; } export interface PluginSshExecResult { stdout: string; stderr: string; /** The exit code. Null when the command was killed or ran out of time. */ code: number | null; /** Output went past maxOutputBytes and the rest was dropped. */ truncated: boolean; timedOut: boolean; } export interface PluginSshConnection { /** An ssh2 Client, already "ready". */ client: Client; jumpClient: Client | null; /** The host as core resolved it: ip, port, username, name and the rest. */ host: PluginSshHost; /** Ends the connection and its jump chain. Also runs on deactivate. */ dispose: () => void; } /** * SSH through core's one connect pipeline: host resolution, auth providers, * jump hosts, proxies, host key checks, keepalive and port knocking. * * Every call needs ssh:connect and credentials:use, runs as the acting user * (or the host's owner for background work on a resolved host) and writes an * audit line per new connection. Connections still open on deactivate are * closed. */ export interface PluginNetwork { /** * Whether a TCP port answers. An SSH banner is read and answered politely; * nothing is ever sent that logs in. Loopback, private and link-local * addresses are allowed, since status checks target the user's own hosts. */ tcpProbe: ( host: string, port: number, options?: { timeoutMs?: number }, ) => Promise; } /** * A way to reach some hosts other than a plain TCP connection. `claims` is * asked for every connection, so it must be quick. `open` returns a stream * the SSH client runs over, like a relayed socket. */ export interface PluginSshTransportProvider { claims: (hostId: number) => boolean; open: (host: { id: number; ip: string; port: number; }) => Promise; } export interface PluginSshTransports { /** Needs ssh:transport. Removed on deactivate. */ provide: (provider: PluginSshTransportProvider) => () => void; } export interface PluginSsh { /** Other ways to reach hosts, used by every connection to them. */ transports: PluginSshTransports; /** * Whether a TCP port answers through the host's jump host chain. Chains are * shared and closed when idle. The acting user must be able to connect to * the host. Needs ssh:connect. */ probe: ( hostId: number, options?: { port?: number; timeoutMs?: number }, ) => Promise; connect: ( host: number | PluginSshHost, options?: PluginSshConnectOptions, ) => Promise>; /** * Runs one command on a host with its stored login and closes the * connection. The acting user needs connect access to the host, the same as * connect. Output is capped and the command is stopped at the time limit. * Needs ssh:connect and credentials:use. */ exec: ( host: number | PluginSshHost, command: string, options?: PluginSshExecOptions, ) => Promise; /** Borrows a pooled connection. The pool key is `::
`. */ withConnection: ( host: number | PluginSshHost, options: PluginSshConnectOptions & { pool: string }, fn: (client: Client) => Promise, ) => Promise; /** * A ready client at the end of a jump host chain, each hop resolved and * authenticated through the pipeline. For forwarding to something that is * not an SSH server (a remote desktop port behind a bastion). */ jumpChain: ( jumpHosts: Array<{ hostId: number }>, options?: { /** Background work: resolve the hops as this host's owner. */ forHost?: PluginSshHost; /** Answers a hop asking for TOTP or other input. */ prompt?: PluginSshPromptChannel; }, ) => Promise>; /** The pool key withConnection would use, for clearing it. */ poolKey: (pool: string, host: PluginSshHost) => string; /** * Closes this plugin's pooled connections to a host, for when its details * changed. Only keys this plugin created are touched. */ dropPooled: (pool: string, hostId: number) => void; /** * The host as the acting user may connect to it: RBAC, owner-key * decryption, shared-host overrides and external secret references * resolved, secrets included. By sync id when given, since a numeric id * only names a host on the database that issued it. Null when the host is * not on this server or the user cannot reach it. */ resolveHost: ( hostId: number, options?: { syncId?: string | null }, ) => Promise; /** * Lower level: fills an ssh2 config for a client the plugin drives itself, * for transports with their own prompt flow. Never connects. */ prepare: ( host: PluginSshHost, options: { purpose?: PluginSshPurpose; /** Keepalive and timeout defaults. Defaults to "background". */ profile?: PluginSshProfile; client: unknown; /** Server-side host id when it differs from host.id. */ serverHostId?: number; log?: (level: "info" | "warning" | "error", message: string) => void; /** A person is at the keyboard, so providers may prompt. */ interactive?: boolean; /** * The ws socket the host key verifier asks on when a key is new or has * changed. Without one a changed key is refused. */ hostKeySocket?: unknown; }, ) => Promise; /** Lower level: port knocking, jump hosts or proxy; sets config.sock. */ openTransport: ( host: PluginSshHost, config: Record, options?: { /** False when the caller already resolved DNS, or a jump host will. */ resolveDns?: boolean; log?: (level: "info" | "warning" | "error", message: string) => void; /** Answers a jump host asking for TOTP or other input. */ prompt?: PluginSshPromptChannel; }, ) => Promise<{ jumpClient: unknown | null; via: string }>; /** * Starts the browser step behind an `_auth_required` message. * The provider for the host's own auth type wins, else the first provider * that owns the interaction. Rejects with PluginSshInteractionError when * none can. */ startInteraction: ( interaction: string, request: { hostId: number; socket: unknown; requestOrigin: string; payload?: Record; }, ) => Promise; /** Cancels a pending browser step on every provider that owns it. */ cancelInteraction: ( interaction: string, request: { hostId?: number; requestId?: string }, ) => Promise; /** What a keyboard-interactive round is. Pure; no capability needed. */ classifyKeyboardInteractive: ( round: { name: string; instructions: string; prompts: PluginKeyboardInteractivePrompt[]; }, host: PluginSshHost, ) => PluginKeyboardInteractiveDecision; /** Stored password for password prompts, empty for the rest. */ autoResponses: ( prompts: PluginKeyboardInteractivePrompt[], password: string | null | undefined, ) => string[]; /** Whether an auth type carries a secret (drives "auth_required" flows). */ requiresSecret: (authType: string) => boolean; /** Whether an auth type can connect unattended, for polling. */ supportsBackground: (authType: string) => boolean; } /** What ctx.ssh.prepare built, plus the provider hooks bound to it. */ export interface PluginSshPrepared { config: Record; outcome: PluginSshAuthOutcome; /** The auth type whose provider prepared it, null when none exists. */ authType: string | null; /** The provider's banner hook, bound to this host and connection. */ onBanner?: (banner: string) => PluginSshBannerDecision | undefined; /** The provider's auth failure hook, bound to this host and connection. */ onAuthFailed?: (context: { error: Error; retries: number; canRetry: boolean; methodNotAvailable: boolean; }) => PluginSshAuthOutcome | undefined; } /** Thrown by ctx.ssh.startInteraction when no provider can start it. */ export class PluginSshInteractionError extends Error { constructor(message: string) { super(message); this.name = "PluginSshInteractionError"; } } export interface PluginSshAuthEnv { /** The ssh2 Client about to connect; certificate auth patches it. */ client: unknown; userId: string; hostId: number; purpose: string; interactive: boolean; log: (level: "info" | "warning" | "error", message: string) => void; } /** * An SSH auth type. Its `type` is what ssh_data.auth_type stores, and it must * be listed in the manifest's contributes.auth.sshAuthTypes. */ export interface PluginSshAuthProvider { type: string; labelKey: string; descriptionKey?: string; /** Host and credential editor fields, in the settings field schema. */ fields?: PluginSettingsField[]; /** Also offered as a stored credential type. */ credentialType?: boolean; /** Needs a browser sign-in or a person at the keyboard. */ needsUserInteraction?: boolean; /** Carries a secret, so a shared host needs one resolved per recipient. */ requiresSecret?: boolean; /** Can connect unattended. Defaults to true. */ supportsBackground?: boolean; /** * Works for a host that was never saved (nothing in host settings, no host * id to key a cache on), so Quick Connect offers it. Default false. */ quickConnect?: boolean; /** Interaction name its outcomes use, for startInteraction routing. */ interaction?: string; connectOptions?: ( host: PluginSshHost, purpose: string, ) => Record; /** Fills the ssh2 config for this host. Never connects. */ prepare: ( config: Record, host: PluginSshHost, env: PluginSshAuthEnv, ) => Promise; /** Claims keyboard-interactive rounds for hosts of this type. */ onKeyboardInteractive?: ( round: { name: string; instructions: string; prompts: PluginKeyboardInteractivePrompt[]; }, host: PluginSshHost, ) => PluginKeyboardInteractiveDecision | null; /** Synchronous: decide on a retry before the socket closes. */ onAuthFailed?: ( host: PluginSshHost, env: PluginSshAuthEnv, context: { error: Error; retries: number; canRetry: boolean; methodNotAvailable: boolean; }, ) => PluginSshAuthOutcome | undefined; /** * Claims 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). A transport that can show this sends `_check_required` * / `_check_completed` over its socket, with `details` merged in. * Return nothing to leave the banner unhandled. */ onBanner?: ( banner: string, host: PluginSshHost, env: PluginSshAuthEnv, ) => PluginSshBannerDecision | undefined; /** Starts the browser step behind an interaction-required outcome. */ startInteraction?: (request: { userId: string; hostId: number; host: { name?: string | null; ip: string; username: string }; socket: unknown; requestOrigin: string; payload: Record; }) => Promise; /** Cancels a pending browser step. */ cancelInteraction?: (request: { userId: string; hostId?: number; requestId?: string; }) => void | Promise; } /** * Who a login method says the user is. Core finds or provisions the user, * runs second factors and issues the session; a method never does. */ export type PluginVerifiedIdentity = | { kind: "user"; userId: string; /** The method already proved a second factor (a verified passkey). */ mfaSatisfied?: boolean; /** Password logins unlock the user's data key with it. */ password?: string; /** Redirect methods: where the browser goes back to. */ returnTo?: string; rememberMe?: boolean; } | { kind: "external"; /** Stable provider id, e.g. an SSO provider row id. */ provider: string; /** The provider's id for the user. At most 255 characters. */ subject: string; email?: string | null; name?: string | null; groups?: string[]; /** Provider says the user is an admin (admin group, LDAP group). */ isAdmin?: boolean; /** Allowed-users list to check before provisioning or signing in. */ allowedUsers?: string | null; mfaSatisfied?: boolean; /** Redirect methods: where the browser goes back to. */ returnTo?: string; rememberMe?: boolean; /** * Provider groups mapped to Termix roles. Core adds the desired roles * and removes managed roles the user no longer maps to, on every login. * Roles outside `managed` are never touched. */ roles?: { desired: string[]; managed: string[] }; /** * Stored on the session so `ctx.auth.revokeSessions` can find it later, * for an identity provider's back-channel logout. */ logoutClaims?: { providerId?: number | null; sub?: string | null; sid?: string | null; }; /** * The identifier a 2.8 install kept for the user. Only for a plugin * that took over a 2.8 login method: core also checks `identifier` * against `allowedUsers`. `providerRowId` is no longer read. */ legacy?: { identifier: string; providerRowId?: number | null }; /** * The key this attempt was counted under with * `ctx.auth.loginRateLimit.recordFailure`. Cleared on success. */ rateLimitKey?: string; }; /** Request shape a login method sees. Structural, like PluginRequestLike. */ export interface PluginLoginRequest { body: Record; query: Record; headers: Record; ip?: string; [key: string]: unknown; } export interface PluginLoginInstance { /** Passed back to start/verify as instanceId, e.g. an SSO provider id. */ id: string; label: string; enabled: boolean; /** * Provider type shown to 2.8 clients by the old `GET /users/sso-providers` * route ("oidc", "github", "google", "ldap"). Leave unset otherwise. */ type?: string; /** * Redirect methods: the login page starts this instance without a click, * for a server that signs everyone in through one provider. The first * instance that asks wins. */ autoStart?: boolean; } export interface PluginLoginMethod { id: string; labelKey: string; icon?: string; kind: "redirect" | "form"; /** * True for a method that authenticates against something other than a * password or key this server holds: SSO, an external directory, a * federated identity provider. Governs whether the "ask for a second * factor after external logins" admin setting applies to it. Leave unset * for a local method (password, a passkey, a plugin's own local form). */ external?: boolean; /** Enabled instances, shown on the login screen. No secrets. */ describe?: () => Promise; /** Redirect methods: where to send the browser. */ start?: ( request: PluginLoginRequest, instanceId: string | null, ) => Promise<{ redirectUrl: string }>; /** Redirect methods: the provider's callback. */ callback?: (request: PluginLoginRequest) => Promise; /** Form methods: check what the user typed. */ verify?: ( request: PluginLoginRequest, instanceId: string | null, ) => Promise; } export interface PluginSecondFactor { id: string; labelKey: string; isEnrolled: (userId: string) => Promise; /** Anything the client needs before the user answers. */ challenge?: (userId: string) => Promise; /** * True when the answer is right. Return an object to refuse with a specific * message, for example when the factor had to be reset. */ verify: ( userId: string, body: Record, ) => Promise; /** Removes the user's enrolment. Called by the admin reset. */ reset?: (userId: string) => Promise; } /** A login core is about to turn into a session. */ export interface PluginAuthPolicyInput { userId: string; /** Login method id, `"proxy"` for a trusted proxy login. */ methodId: string; /** The method signs in through an outside identity provider (SSO, LDAP). */ external: boolean; deviceType: "web" | "desktop" | "mobile"; /** The device passed a second factor before and was remembered. */ trustedDevice: boolean; /** The method itself proved a second factor, like a verified passkey. */ mfaSatisfied: boolean; /** Every second factor core has, and whether this user set it up. */ secondFactors: { id: string; pluginId: string; enrolled: boolean }[]; } export interface PluginAuthPolicyDecision { /** False turns remember me off and ignores remembered devices. */ allowRememberDevice?: boolean; /** Caps how long the session lasts. */ maxSessionSeconds?: number; /** Ends the session after this many seconds without a request. */ idleTimeoutSeconds?: number; /** * Requires a second factor even for users who have none: they get a * session that can only set one up. `"new-device"` skips it on a * remembered device. */ secondFactor?: "always" | "new-device"; } /** * Login rules from a plugin. Core asks every provider and the strictest * answer wins. A provider that throws refuses the login. */ export interface PluginAuthPolicyProvider { evaluate: ( input: PluginAuthPolicyInput, ) => PluginAuthPolicyDecision | Promise; /** Whether the login page should offer remember me. */ allowRememberDevice?: () => boolean; } export interface PluginAuthPolicyApi { /** Needs auth:policy. Removed on deactivate. */ provide: (provider: PluginAuthPolicyProvider) => () => void; } export interface PluginUserTokenOptions { /** Why the token exists, kept in the audit log and on the session. */ purpose: string; /** Seconds until it expires. Defaults to 300 and is capped at 900. */ ttlSeconds?: number; } export interface PluginUserToken { /** Send as `Authorization: Bearer ` to core's own API. */ token: string; /** ISO time the session ends. */ expiresAt: string; } /** * Login methods, second factors and SSH auth types. Every register call needs * auth:provide and a matching contributes.auth entry, and is undone on * deactivate. Core owns sessions: a method returns an identity and core does * the rest. */ export interface PluginAuth { registerSshAuthProvider: (provider: PluginSshAuthProvider) => void; /** A host-independent keyboard-interactive handler. */ registerKeyboardInteractiveHandler: ( handler: PluginKeyboardInteractiveHandler, ) => void; registerLoginMethod: (method: PluginLoginMethod) => void; registerSecondFactor: (factor: PluginSecondFactor) => void; /** * Marks a user as enrolled in one of this plugin's factors. Core keeps the * row even when the plugin is gone, so it can refuse the login instead of * skipping the factor. */ recordEnrollment: (userId: string, factorId: string) => Promise; removeEnrollment: (userId: string, factorId: string) => Promise; /** * Finishes a redirect login from one of this plugin's own public routes: * runs the registered method's `callback`, then core finds or provisions * the user, runs second factors and redirects the browser back with a * session, exactly like `/users/auth//callback`. The method must * be one this plugin registered. */ completeRedirectLogin: ( methodId: string, req: unknown, res: unknown, ) => Promise; /** * Revokes sessions whose login carried matching `logoutClaims`, for a * back-channel logout. Needs `sub` or `sid`; `providerId` narrows it. * Returns how many sessions ended. */ revokeSessions: (match: { providerId?: number | null; sub?: string | null; sid?: string | null; }) => Promise; /** * Core's login rate limiter, the one password login uses. Keys are kept * apart per plugin. Put the key in the identity's `rateLimitKey` so a * successful login clears it. */ loginRateLimit: { isLocked: ( ip: string, key: string, ) => Promise<{ locked: boolean; remainingTime?: number }>; recordFailure: (ip: string, key: string) => Promise; }; /** * How many users are linked to an external identity from `provider` (the * `provider` of the identities this plugin returns). For refusing to * delete a provider people still sign in with. */ countLinkedUsers: (provider: string) => Promise; /** * A short-lived session for an existing user, for calling core's own HTTP * API as them from work with no request behind it, like a scheduled task * driving the Termix CLI. The user's RBAC applies unchanged. Needs * users:impersonate, not auth:provide, and every call is audited. * * The session is marked with the plugin and purpose. It cannot change the * user's password, second factors, API keys or linked logins, so it can * never outlive itself. */ issueUserToken: ( userId: string, options: PluginUserTokenOptions, ) => Promise; /** Login rules: idle timeout, session length, remember me, second factors. */ policy: PluginAuthPolicyApi; /** Second factors from every running plugin. Needs no capability. */ listSecondFactors: () => { id: string; pluginId: string }[]; } /** What a plugin may learn about a user. Needs users:read. */ export interface PluginUsers { /** The user's UI language, like "zh_CN", or null when they never chose one. */ getLocale: (userId: string) => Promise; } export interface PluginOpenIsolatedWindowRequest { /** http(s) only. Refused when it points anywhere else. */ url: string; /** Isolated Electron session partition; a fresh one when omitted. */ partition?: string; title?: string; /** Present an invalid TLS certificate on this window's own origin only. */ ignoreCert?: boolean; } /** * Opens a desktop window outside the main renderer, for a target a plugin * does not want sharing Termix's own session (a tunnelled or direct web UI). * Electron only: rejects when the server is not running embedded in the * desktop app. Needs desktop:window. */ /** * A host to open in the operating system's own client for a protocol. The * desktop app supports "rdp" (mstsc on Windows) today; any other protocol is * refused. */ export interface PluginExternalClientRequest { protocol: string; host: string; port?: number; username?: string; domain?: string; } export interface PluginDesktop { openIsolatedWindow: ( request: PluginOpenIsolatedWindowRequest, ) => Promise<{ success: true }>; /** * Opens the operating system's own client for a protocol. The password is * never passed; the client asks for it. Desktop app only. */ launchExternalClient: ( request: PluginExternalClientRequest, ) => Promise<{ success: boolean; error?: string }>; /** Whether the server runs embedded in the desktop app, so the calls above can work. */ available: () => boolean; /** * The user's own machine. Only works on the desktop app's embedded backend * and needs desktop:local; every call is audited once per plugin start. */ readonly local: PluginDesktopLocal; } export interface PluginLocalDirEntry { name: string; path: string; type: "file" | "directory" | "link"; size: number; modifiedTimestamp?: number; linkTarget?: string; hidden: boolean; } export interface PluginLocalWalkResult { files: Array<{ localPath: string; relativePath: string; size: number }>; emptyDirs: string[]; totalBytes: number; } export interface PluginLocalPty { readonly pid: number; /** The shell program that was started. */ readonly shell: string; write: (data: string) => void; resize: (cols: number, rows: number) => void; kill: () => void; onData: (handler: (data: string) => void) => () => void; onExit: (handler: (exitCode: number) => void) => () => void; } /** A download being written next to its final name, published when done. */ export interface PluginLocalDownload { /** Final path inside the chosen folder, links inside it already refused. */ readonly path: string; readonly stream: NodeJS.WritableStream; /** Moves the finished partial onto its final name. */ publish: (options?: { overwrite?: boolean }) => Promise; /** Removes the partial file. Safe to call after publish. */ abort: () => Promise; } export interface PluginLocalResponse { readonly status: number; readonly ok: boolean; readonly headers: { get: (name: string) => string | null }; /** A web stream of the body, or null when there is none. */ readonly body: AsyncIterable | null; text: () => Promise; } export interface PluginDesktopLocal { /** Whether local access is possible: embedded backend and capability granted. */ available: () => boolean; /** * Calls this plugin's own HTTP routes on the embedded backend ("local") or * on the server the desktop is linked to ("remote"), as the acting user. * The path is under /plugin-api/; nothing else is * reachable. The link's proxy headers and certificate rules apply. A body * may be a string, a buffer or a Node stream. */ pluginFetch: ( origin: "local" | "remote", subPath: string, init?: { method?: string; headers?: Record; body?: string | Buffer | NodeJS.ReadableStream; signal?: AbortSignal; }, ) => Promise; info: () => Promise<{ home: string; separator: string; platform: NodeJS.Platform; /** The desktop app's user data folder. */ userDataDir: string; /** The port the embedded backend listens on. */ backendPort: number; /** The server this desktop is linked to, or null. */ link: { serverUrl: string; token: string | null; /** * Headers for a request to the linked server: the session, plus the * proxy headers and basic auth the link was set up with. */ headers: Record; /** The link accepts a self-signed certificate. */ allowInvalidCertificate: boolean; } | null; }>; /** * GET a JSON route on the linked server with the link's session, proxy * headers and certificate rules. Rejects with an error whose `kind` is * "signed_out" when the server refused the session. */ linkJson: (pathname: string) => Promise; readonly fs: { list: (dirPath: string) => Promise<{ path: string; parent: string | null; entries: PluginLocalDirEntry[]; }>; mkdir: (parentPath: string, name: string) => Promise; createFile: (parentPath: string, name: string) => Promise; /** Renames inside the same folder; never replaces an existing name. */ rename: (path: string, newName: string) => Promise; /** Creates a folder; with rootPath it must stay inside that folder. */ ensureDir: (dirPath: string, rootPath?: string) => Promise; /** Which of the given paths exist. */ exists: (paths: string[]) => Promise; walk: (rootPaths: string[]) => Promise; /** Sends entries to the operating system's trash. */ trash: (paths: string[]) => Promise<{ trashed: number; failed: Array<{ path: string; error: string }>; }>; reveal: (path: string) => Promise; open: (path: string) => Promise; openRead: (path: string) => Promise<{ stream: NodeJS.ReadableStream; size: number; }>; openDownload: (options: { rootPath: string; destPath: string; transferId: string; overwrite?: boolean; }) => Promise; }; readonly pty: { spawn: (options?: { cols?: number; rows?: number; /** "wsl" on Windows starts WSL instead of PowerShell. */ shell?: string; }) => Promise; }; } /** * A host protocol whose login core keeps next to the host: any id a plugin * declares in contributes.protocols. */ export type PluginHostProtocol = string; /** * What a plugin needs to hand a host's protocol login to something core * does not run (guacd): the address and the plaintext credentials the acting * user is allowed to use. A shared recipient gets the owner's shared * snapshot or their own override, never the owner's raw secret. */ export interface PluginProtocolTarget { host: { id: number; name: string | null; ip: string; /** The host's SSH port, the old fallback when no protocol port is set. */ port: number; ownerUserId: string; jumpHosts: Array<{ hostId: number }>; }; /** True when the acting user is not the owner. */ shared: boolean; auth: { /** "direct", "credential", or "none" when the user is asked at connect time. */ authType: string; username: string; password: string; /** The protocol's declared credentialFields, secret ones included; "" when unset. */ fields: Record; }; } /** One of the acting user's saved SSH key credentials, public half only. */ export interface PluginSshKeyCredential { id: number; name: string; username: string | null; /** " ", derived from the private key when none was stored. Null when it cannot be. */ publicKey: string | null; } /** A saved credential without its secrets. */ export interface PluginCredentialSummary { id: number; name: string; username: string | null; authType: string; folder: string | null; } /** A key pair to save as a new credential for the acting user. */ export interface PluginSshKeyCredentialInput { name: string; description?: string; username?: string | null; privateKey: string; publicKey: string; /** The key's algorithm token, such as "ssh-ed25519". */ keyType: string; } /** * Plaintext host credentials, for a plugin that has to hand them to another * program. Needs credentials:read, and every call is audited. */ export interface PluginCredentials { /** * The acting user's own SSH key credentials, without any private material. * Needs credentials:use and the core credentials.view permission. Audited. */ listSshKeys: () => Promise; /** * The acting user's own credentials by name, with no secret material, so a * plugin can point a host at one. Needs credentials:use and the core * credentials.view permission. Audited. */ list: () => Promise; /** * Saves a key pair as a new encrypted credential owned by the acting user * and returns its id. Needs credentials:write, the core credentials.create * permission and an unlocked data key. Audited. */ createSshKey: (input: PluginSshKeyCredentialInput) => Promise<{ id: number }>; /** * The host's login for one of this plugin's own contributes.protocols. * Null when the host does not exist or the acting user cannot connect to * it. Needs credentials:read. Audited. */ resolveHostProtocol: ( hostId: number, protocol: PluginHostProtocol, ) => Promise; /** * Registers this plugin as the resolver for "://..." references in * a host's secret fields (secret-sources registers "op" for * "op://vault/item/field"). Needs auth:provide, like registerSshAuthProvider. * * `resolve` runs once per host resolution, for the user the field's owner * resolves as (the host owner, or the recipient for a personal override), * and its result is cached briefly by the connect pipeline. A scheme with no * registered resolver fails the connect with a message naming the plugin * that would provide it. */ registerSecretResolver: ( scheme: string, resolve: (userId: string, reference: string) => Promise, ) => void; /** * The public key line of an SSH key credential the acting user can see * (their own, with the core credentials.view permission). Derived from the * private key when none is stored, including a key kept behind a secret * reference. Null when the credential is missing, is not a key, or cannot * be read. Needs credentials:use. Audited. */ publicKey: (credentialId: number) => Promise; /** * Replaces secret references ("op://...") in a key and its passphrase with * the real values, for the acting user. Plain values pass through. Hands * private key material to the plugin. Needs credentials:read. Audited. */ resolveKeyRefs: ( key: string, passphrase?: string | null, ) => Promise<{ key: string; passphrase?: string }>; } /** * An audit line under an action name of the plugin's choosing (an SSH login), * for the acting user, next to the plugin_* line every privileged ctx call * already writes. */ export interface PluginAudit { record: (entry: { action: string; resourceType?: string; resourceId?: string; resourceName?: string; details?: string; success: boolean; errorMessage?: string; /** * The HTTP request or WebSocket upgrade this happened on, for the line's * IP address and user agent. */ request?: unknown; }) => Promise; /** * Called for every audit line core or any plugin writes, after it is * accepted. Fire and forget: a slow or throwing handler never delays or * fails the audited action. Lines written before the plugin started are not * replayed; read them with list or export. Returns an unsubscribe function; * it is also removed when the plugin deactivates. Needs audit:read. */ subscribe: (handler: (entry: PluginAuditEntry) => void) => () => void; /** A page of stored lines, newest first. Needs audit:read. */ list: (options?: { filters?: PluginAuditFilters; limit?: number; offset?: number; }) => Promise<{ entries: PluginAuditEntry[]; total: number }>; /** * Stored lines in time order, in batches, so an export never holds the * whole trail in memory. Needs audit:read. */ exportBatches: ( filters?: PluginAuditFilters, batchSize?: number, ) => AsyncGenerator; } export interface PluginAuditFilters { userId?: string; action?: string; resourceType?: string; success?: boolean; /** ISO date-time. */ startDate?: string; /** ISO date-time. */ endDate?: string; } export interface PluginAuditEntry { /** Only on stored lines (list and export). */ id?: number; /** ISO date-time. */ timestamp: string; userId: string | null; username: string; action: string; resourceType: string; resourceId: string | null; resourceName: string | null; details: string | null; ipAddress: string | null; userAgent: string | null; success: boolean; errorMessage: string | null; } /** A notification channel a user set up. Its config never leaves the hub. */ export interface PluginNotificationChannel { id: number; name: string; /** "webhook", "ntfy", "discord", "email", or whatever the hub supports. */ type: string; enabled: boolean; } export type PluginNotificationSeverity = "info" | "success" | "warning" | "critical"; /** * Who receives an alert. Left out, it goes to the acting user. "admins" and * a permission reach every user who holds it when the alert is sent. */ export type PluginNotificationAudience = { userId: string } | { permission: string } | "admins"; export interface PluginNotification { title: string; body?: string; /** Defaults to "warning". */ severity?: PluginNotificationSeverity; /** * What kind of alert this is, "." by convention, e.g. * "acme-ssl.renewal_failed". Users route alerts to channels by it. * Defaults to the sending plugin's id. */ category?: string; audience?: PluginNotificationAudience; /** * Channels to deliver to on top of the recipient's own routing rules. * Only channels the recipient owns are used. */ channelIds?: number[]; /** Where the alert points: a tab type to open, or an http(s) URL. */ link?: { tab?: string; url?: string }; /** While an unread alert with this key exists, repeats are dropped. */ dedupeKey?: string; /** * Extra fields a webhook receiver gets alongside the message. `sourceId` * and `sourceName` name what sent it (an automation, a rule). */ context?: { hostId?: number; hostName?: string; sourceId?: number | string; sourceName?: string; triggerType?: string; value?: unknown; threshold?: unknown; [key: string]: unknown; }; } export interface PluginNotifyResult { /** Users the alert reached. */ recipients: number; /** Channel deliveries that succeeded, across every recipient. */ delivered: number; failures: Array<{ channelId: number; name: string; error: string }>; } /** * The plugin that stores alerts and delivers them to channels. Core resolves * the audience and hands the hub a list of user ids; it never sees a channel. */ export interface PluginNotifyHub { deliver: (input: { /** The plugin that sent the alert. */ source: string; recipients: string[]; notification: PluginNotification; }) => Promise; channels: (userId: string) => Promise; } /** * Alerts. Every plugin sends through here and the hub plugin decides where * they land. Needs notify:send. With no hub running, send reaches nobody and * channels answers an empty list, so callers never have to check. */ export interface PluginNotify { /** The acting user's channels, without their config. Needs an actor. */ channels: () => Promise; /** * Sends an alert. With no audience it goes to the acting user, so a call * with neither is refused. One channel failing does not stop the others. */ send: (notification: PluginNotification) => Promise; /** * Makes this plugin the hub. Needs notify:hub. Only one hub runs at a time; * a second one is refused. Revoked on deactivate. */ serve: (hub: PluginNotifyHub) => () => void; } export interface PluginFetchInit { /** Return redirect responses without following them, or reject them (default). */ redirect?: "error" | "manual"; method?: string; headers?: Record; body?: string; /** * Aborts the request when no response headers arrive within this long. * Defaults to 30 seconds. A streamed body may also sit idle between chunks * for this long, never less than 5 minutes. */ timeoutMs?: number; /** * Exact private or loopback hosts this request may reach. Everything else * private is refused, and redirects are never followed. */ allowPrivateHosts?: readonly string[]; /** Aborts the request, and a streamed body, when the caller gives up. */ signal?: AbortSignal; /** * TLS for a private CA. `ca` trusts this PEM bundle instead of the system * store. `rejectUnauthorized: false` skips verification, only for fetching * a CA root by fingerprint, where the caller checks the answer itself. */ tls?: { ca?: string; rejectUnauthorized?: boolean }; } /** * Outbound HTTP through core's SSRF guard: http and https only, no embedded * credentials, DNS pinned, private addresses refused unless listed. Needs * network:outbound. */ export type PluginFetch = ( url: string, init?: PluginFetchInit, ) => Promise; /** * A generic capability check, for a privileged action that has no dedicated * ctx method to wrap it: a plugin bundling its own native dependency to touch * hardware (a serial port, a USB device) is the first caller. Core cannot * mediate that access the way it mediates ctx.ssh or ctx.db, so the * capability here is a declared, reviewable, audited statement of intent * rather than a technical gate. Running programs has its own member, * ctx.process. */ export interface PluginCapabilities { /** Whether this plugin currently holds `capability`. Not audited: for deciding whether to offer something, not for gating an action. */ has: (capability: string) => Promise; /** Throws PluginCapabilityError if this plugin does not hold `capability`. Audited like every guarded ctx method. */ require: (capability: string) => Promise; } export interface PluginProcessOptions { /** Added to a small base environment (PATH, HOME, temp, locale, proxy), never the whole server one. */ env?: Record; cwd?: string; /** Kills the program with SIGKILL after this long. */ timeoutMs?: number; } /** A running program started with ctx.process.run. */ export interface PluginProcessHandle { readonly pid: number | undefined; /** Output as UTF-8 text, in the chunks it arrives in. */ onStdout: (listener: (chunk: string) => void) => void; onStderr: (listener: (chunk: string) => void) => void; /** Settles when the program ends, or rejects when it cannot start. */ readonly exited: Promise<{ code: number | null; signal: string | null }>; kill: (signal?: "SIGTERM" | "SIGKILL") => void; } /** A program a plugin downloads and runs, pinned to one checksum. */ export interface PluginBinarySpec { /** File name under the plugin's data dir, e.g. "opkssh-linux-amd64". */ name: string; version: string; /** https URL of the release asset. Redirects are followed. */ url: string; /** Hex SHA-256 the file must have. */ sha256: string; /** * Absolute paths checked before downloading, such as a copy baked into the * Docker image. Only a regular file named `name` is considered, and one * whose checksum matches is used in place. */ prebuilt?: readonly string[]; } /** * Programs on the Termix server. Needs process:spawn. Every program still * running is killed on deactivate. */ export interface PluginProcess { /** Starts `file` with `args`, no shell. Audited. */ run: ( file: string, args: readonly string[], options?: PluginProcessOptions, ) => Promise; /** * Returns the path of a verified executable: a matching prebuilt copy, the * copy downloaded earlier, or a fresh download (which also needs * network:outbound). Throws when the checksum does not match. */ ensureBinary: (spec: PluginBinarySpec) => Promise; } /** * Timers owned by the plugin. Every timer is cleared on deactivate, a run that * throws is logged instead of crashing the server, and a repeating job never * overlaps itself: a tick that arrives while the last run is still going is * skipped. No capability needed. */ export interface PluginSchedule { /** Runs `fn` every `intervalMs`. Returns a function that stops it. */ every: ( intervalMs: number, fn: () => void | Promise, options?: { /** Delays the first run by up to this much, so many timers spread out. */ jitterMs?: number; /** Also run once straight away. */ runNow?: boolean; }, ) => () => void; /** Runs `fn` once after `delayMs`. Returns a function that cancels it. */ after: (delayMs: number, fn: () => void | Promise) => () => void; } /** The certificate core serves, read from its configured paths. */ export interface PluginTlsCertificateInfo { subject: string; issuer: string; /** DNS names and IPs from the subject alternative names. */ names: string[]; notBefore: string; notAfter: string; /** True for core's own first-boot certificate. */ selfSigned: boolean; /** SHA-256 fingerprint, colon separated. */ fingerprint: string; } export interface PluginTlsStatus { /** Whether Termix is set up to serve HTTPS at all. */ enabled: boolean; /** Null when there is no readable certificate. */ certificate: PluginTlsCertificateInfo | null; /** The plugin that renews the certificate, when one is registered. */ renewal: { pluginId: string; pluginName: string } | null; } export interface PluginTlsReloadResult { /** Whether the new certificate is being served now. */ applied: boolean; message: string; } /** * The server certificate. Needs system:tls, and every call is audited. Core * owns serving it; a plugin can only replace it, reload it and answer ACME * http-01 challenges. */ export interface PluginSystem { tlsStatus: () => Promise; /** * Replaces the served certificate and key. Throws when either does not * parse, the certificate has expired, or the key does not match it. Does * not reload: call reloadTls() after. */ writeTlsCertificate: ( certificatePem: string, privateKeyPem: string, ) => Promise; /** Serves the certificate on disk without a restart. */ reloadTls: () => Promise; /** * Answers GET /.well-known/acme-challenge/ with `content` until the * returned function is called or the plugin is disabled. */ publishHttpChallenge: (token: string, content: string) => Promise<() => void>; /** * Tells core this plugin renews the certificate, so admin settings stop * warning that nothing does. Dropped on deactivate. */ registerTlsRenewer: () => Promise<() => void>; } /** One installed plugin as ctx.plugins.list reports it. */ export interface PluginSummary { id: string; version: string; source: "bundled" | "user"; /** "active" when running; anything else means it is not. */ state: string; } /** The installed plugins. Needs plugins:read. */ export interface PluginPlugins { list: () => Promise; } export interface PluginContext { readonly pluginId: string; readonly manifest: PluginManifest; readonly log: PluginLogger; readonly events: PluginEvents; readonly kv: PluginKeyValue; readonly files: PluginFiles; readonly db: PluginDatabase; readonly sync: PluginSync; readonly registry: PluginRegistry; readonly services: PluginServices; readonly secrets: PluginSecrets; readonly http: PluginHttp; readonly ws: PluginWebSockets; readonly rbac: PluginRbac; readonly settings: PluginSettings; readonly disposables: PluginDisposables; /** A generic capability check, for privileged code no ctx method wraps. */ readonly capabilities: PluginCapabilities; /** Hosts the actor can see, and host-sharing operations. Needs hosts:read / hosts:write. */ readonly hosts: PluginHosts; /** SSH through core's connect pipeline. Needs ssh:connect and credentials:use. */ readonly ssh: PluginSsh; /** * Login methods, second factors and SSH auth types. Needs auth:provide, * except issueUserToken, which needs users:impersonate. */ readonly auth: PluginAuth; /** What a plugin may learn about a user. Needs users:read. */ readonly users: PluginUsers; /** Opens Electron windows outside the main renderer. Needs desktop:window. */ readonly desktop: PluginDesktop; /** Host protocol credentials and the user's saved SSH keys. See PluginCredentials. */ readonly credentials: PluginCredentials; /** Audit lines under the plugin's own action names. */ readonly audit: PluginAudit; /** Timers that stop on deactivate. */ readonly schedule: PluginSchedule; /** Core's notification channels. Needs notify:send. */ readonly notify: PluginNotify; /** Outbound HTTP through core's SSRF guard. Needs network:outbound. */ readonly fetch: PluginFetch; /** Plain reachability checks. Needs network:probe. */ readonly network: PluginNetwork; /** Programs on the Termix server. Needs process:spawn. */ readonly process: PluginProcess; /** The server certificate. Needs system:tls. */ readonly system: PluginSystem; /** The installed plugins and whether each runs. Needs plugins:read. */ readonly plugins: PluginPlugins; /** * Runs `fn` with `userId` as the acting user, for background work that has * no request behind it. Always audited, and every core API inside still * applies that user's RBAC. * * Needs users:impersonate. That, auth.issueUserToken and a `userId` option * on services.get or secrets.getShared are the only ways a plugin can act * as a user it names. */ asUser: (userId: string, fn: () => Promise | T) => Promise; /** The acting user for the current call, when there is one. */ currentActor: () => string | undefined; } export type PluginActivate = (ctx: PluginContext) => void | Promise; export type PluginDeactivate = () => void | Promise; export interface PluginModule { activate: PluginActivate; deactivate?: PluginDeactivate; } /** * Identity helper that gives a plugin entry its types without importing the * runtime. Everything a plugin creates belongs inside activate: the module is * cached by the ESM loader, so disable-then-enable re-runs activate on the * same module object. */ export function definePlugin(plugin: PluginModule): PluginModule { return plugin; } export type { PluginManifest } from "./manifest.js"; export type { PluginTableDefinition } from "./db.js"; export type { PluginSettingsField, PluginSettingsContribution, PluginSettingsScope, } from "./manifest.js";