/** * 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 { 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; } /** * 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 read-only so a plugin can join against * them without being able to write them. In-process code could reach around * all of this - see "What this protects, and what it does not" in * ARCHITECTURE.md. 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; /** Read-only references to the core tables a plugin may point at. */ 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: () => 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 { /** Stored column holding a local numeric id, e.g. "credentialId". */ field: string; /** Wire field holding the portable id, e.g. "credentialSyncId". */ syncField: string; /** The entity the id points at. */ entityType: string; } export interface SyncEntityRegistration { /** Stable wire name. Never change it: tombstones and remote rows 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 DataCrypto translates between plaintext wire and encrypted row. */ 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 must never be overwritten by an inbound payload. */ readOnlyFields?: readonly string[]; /** One row per user rather than many, keyed on the owner. */ singleton?: boolean; /** * 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; /** * Escape hatch for a row whose references are not plain columns, such as ids * embedded in a JSON blob. Runs instead of `references`, not alongside it. */ serialize?: ( row: SyncRow, resolveSyncId: (entityType: string, id: number) => Promise, ) => Promise; deserialize?: ( row: SyncRow, resolveId: (entityType: string, syncId: string) => Promise, ) => Promise; } /** Adds an entity to remote sync between a desktop backend and a server. */ export interface PluginSync { registerEntity: (entity: SyncEntityRegistration) => void; /** * Records that a row was deleted, so a pull on the other side of remote * sync removes it too. Call it with the row's syncId right after deleting * it, for a registered entity whose rows can be deleted (a no-op silently * loses deletes across devices otherwise). A falsy syncId is ignored. */ recordTombstone: ( userId: string, entityType: string, syncId: string | null | undefined, ) => Promise; } export interface PluginRegistry { provide: (key: string, value: T) => void; consume: (key: string) => T | undefined; revoke: (key: string, value?: unknown) => boolean; } export interface PluginServices { provide: (service: string, implementation: T) => void; get: (service: string, options?: { userId?: string }) => T; } export interface PluginSecrets { 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"). * * 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; } /** * 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; } /** * 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; } 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; } /** * 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. */ getHost: ( hostId: number | string, key: string, ) => Promise; setHost: ( hostId: number | string, key: string, value: unknown, ) => 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; /** * 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 record wide enough for a plugin that creates or updates hosts on the * user's behalf (import, discovery, sync), decrypted for that user. Unlike * PluginHostSummary this carries the fields a plugin needs to set up a * connectable host, not just what a list view shows. Still 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; name: string | null; ip: string; port: number; username: string; authType: string; credentialId?: number | null; overrideCredentialUsername?: boolean | null; connectionType?: string | null; tags: string | null; folder: string | null; jumpHosts?: unknown; enableSsh?: boolean | null; enableRdp?: boolean | null; enableTerminal?: boolean | null; enableFileManager?: boolean | null; enableTunnel?: boolean | null; enableDocker?: boolean | null; createdAt?: string | null; updatedAt?: string | null; [key: string]: unknown; } /** Fields a plugin may set when creating a host it will own or manage. */ export type PluginHostCreateInput = Partial< Omit > & { name: string; ip: string; port: number; username: string; authType: string; }; /** Fields a plugin may change on a host it already created or was granted access to. */ export type PluginHostUpdateInput = Partial< Omit >; /** 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; } /** * 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; /** * 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; /** 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; } /** * 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; [key: string]: unknown; } /** Picks keepalive and timeout defaults. "plugin" is the generic one. */ export type PluginSshPurpose = | "plugin" | "docker" | "docker-console" | "metrics" | "proxmox" | "fleet" | "remote-desktop" | "tunnel"; 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; } export type PluginKeyboardInteractiveDecision = | { kind: "auto"; responses: string[] } | { kind: "warpgate"; url: string | null; securityKey: string; instructions: string; } | { 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 } | { kind: "warpgate"; url: string | null; securityKey: string; instructions: string; }; /** Answers keyboard-interactive prompts. Resolve null to give up. */ export interface PluginSshPromptChannel { ask: (request: PluginSshPromptRequest) => Promise; } export interface PluginSshConnectOptions { purpose?: PluginSshPurpose; /** 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; } 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 PluginSsh { connect: ( host: number | PluginSshHost, options?: PluginSshConnectOptions, ) => 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 (an RDP port behind a bastion). */ jumpChain: ( jumpHosts: Array<{ hostId: number }>, options?: { /** Background work: resolve the hops as this host's owner. */ forHost?: PluginSshHost; }, ) => Promise>; /** The pool key withConnection would use, for clearing it. */ poolKey: (pool: string, host: PluginSshHost) => string; /** * 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; client: unknown; /** Server-side host id when it differs from host.id. */ serverHostId?: number; log?: (level: "info" | "warning" | "error", message: string) => void; }, ) => Promise<{ config: Record; outcome: PluginSshAuthOutcome; }>; /** Lower level: port knocking, jump hosts or proxy; sets config.sock. */ openTransport: ( host: PluginSshHost, config: Record, ) => Promise<{ jumpClient: unknown | null; via: string }>; /** 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; } 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; /** 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; } /** * 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; }; /** 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; } 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; } /** * 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; 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; } export interface PluginContext { readonly pluginId: string; readonly manifest: PluginManifest; readonly log: PluginLogger; readonly events: PluginEvents; readonly kv: PluginKeyValue; 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; /** 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. */ readonly auth: PluginAuth; /** * 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. * * This is the only way a plugin can name a user. Nothing else trusts a user * id that came from plugin code. */ 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";