feat: collapse plugin runtime to one tier behind a plugin SDK

This commit is contained in:
LukeGus committed 2026-09-22 14:11:50 -05:00
1 parent 6e058f6e9e
commit 4758016f41
96 files changed
+34631 -8551

No files matched your search

+470
View File
@@ -0,0 +1,470 @@
# Termix plugin architecture
This is the contract between Termix core and a plugin. It is the reference for
anyone writing a plugin and the specification the runtime in
`src/backend/plugins/` implements.
Status: the redesign lands across several steps. Everything marked **A1** is
built today. Later markers name the step that delivers the rest.
---
## The shape of it
### 1. One tier
Every plugin, official or community, runs in-process in the Termix server and
in the browser. There is no first-party allowlist, no worker tier, and no
special case keyed on a plugin id anywhere in core.
This is not a sandbox. See [What this protects](#what-this-protects-and-what-it-does-not).
### 2. SDK only
A plugin imports `@termix/plugin-sdk`, its own files and its own npm
dependencies. Entry points:
| Entry | Contents |
| --------------------------------- | ---------------------------------------------------------- |
| `@termix/plugin-sdk/backend` | `PluginContext`, `definePlugin()`, `PluginCapabilityError` |
| `@termix/plugin-sdk/frontend` | The `app` object types (stubs until A7) |
| `@termix/plugin-sdk/manifest` | Manifest types, validation, the JSON schema |
| `@termix/plugin-sdk/capabilities` | The capability catalog |
| `@termix/plugin-sdk/testing` | Test helpers, incl. `createFakeContext()` |
Plugins never import from `src/` or `@/`, and core never imports from
`plugins/`. Anything a plugin needs from core becomes a typed, documented SDK
API. Enforced by lint, with the exceptions recorded under
[legacy core imports](#legacy-core-imports-the-debt-d1-removes).
The SDK holds the contract: types, the catalog, and pure helpers. The ctx
implementation lives in core (`src/backend/plugins/ctx.ts`) and is injected at
activation, so a plugin cannot reach around it.
### 3. Capabilities are what a plugin's code may do
Declared in the manifest's `capabilities` array using `domain:action` names
from one catalog in the SDK. Every privileged SDK call checks the capability
and writes an audit line naming the plugin and the acting user.
Two things must both be true, and a grant for something the manifest never
declared is ignored rather than honoured:
1. the manifest declares it, and
2. it is granted in `plugin_permission_grants`.
That means widening a plugin's reach always requires a new manifest the user
can see. In 2.9.0 bundled plugins are granted everything they declare
automatically, with `source: "bundled"`.
Adding a capability is one catalog entry (id, risk, i18n title and consequence
text) plus the SDK method that checks it.
### 4. User permissions are what a user may do
These stay in core RBAC and keep their dotted spelling (`hosts.view`), so they
can never be confused with colon-separated capabilities.
A plugin declares short names in `contributes.permissionGroup`. Core registers
them, groups them under the plugin in the role editor, and only lets a plugin
set defaults for its own namespace: a `defaultForRole` entry naming a
permission the plugin does not declare is a validation error, not a silent
escalation. Permissions stored in a role survive the plugin being disabled.
Role defaults are applied once and the fact is recorded, so an admin who
revokes one does not get it handed back on the next restart.
### 5. Data
Plugins own their tables through SDK table definitions and ship migrations for
sqlite, pg and mysql. Table names are prefixed `p_<id with - as _>_`. Legacy
core tables are adopted by rename in the plugin's first migration. `ctx.kv` is
for small key/value state. Core `schema.ts` ends up holding only core tables.
**A3** delivers the table definitions and migration runner. A1 ships `ctx.kv`
only.
### 6. HTTP and WebSockets
Plugin routes live under `/plugin-api/<id>/` and sockets under
`/plugin-ws/<id>/`, served by the main backend with core auth. No plugin gets
its own port or nginx block.
**A4** delivers this. In A1 `/plugin-api` is mounted and auth-gated but has no
routes registered, so it returns 404. The bundled plugins still keep their
original URLs through the dispatchers in `src/backend/database/routes/*-dispatch.ts`,
and four still own ports of their own.
### 7. Settings
Plugins declare settings fields in the manifest with scope `admin`, `user` or
`host`. Core renders them from the schema (with an optional custom component),
stores values in `plugin_settings`, and encrypts secret fields. One Settings
screen has a Plugins section with a page per plugin; host settings render in
the host editor. **A6.**
### 8. UI
A plugin frontend is a built ESM bundle loaded at runtime by the core loader.
It registers everything through the `app` object: rail items, panels, tabs,
host editor sections, host actions and badges, context menu items, palette
entries, dashboard cards, homepage widgets, settings components, action slots,
login/2FA UI, SSH auth editors. The shell contains no plugin ids. **A7.**
### 9. Cross-plugin use
Through `ctx.services` (typed, versioned, RBAC-checked per call), `ctx.events`,
and frontend actions and slots. Dependencies are declared in the manifest:
`dependencies` are hard, `optionalDependencies` are soft. The loader activates
in dependency order. A plugin must keep working when an optional dependency is
missing.
### 10. Lifecycle
`activate(ctx)` creates all state. Everything registered through `ctx` or `app`
is disposed automatically on deactivate. See [Lifecycle rules](#lifecycle-rules).
### 11. Tests
Each plugin has `plugins/<id>/tests/backend` and `plugins/<id>/tests/frontend`,
run by its own vitest config built on the SDK preset (**A2**). Core tests
(`src/backend/tests`, `src/ui/tests`) never test plugin code; they may test the
plugin runtime with fixture plugins.
Until A2, plugin tests still live under `src/*/tests/plugins/`.
### 12. Auth
Core keeps password login, sessions, API keys, trusted proxy login, Electron
auto-session and trusted devices, plus the base SSH auth types password, key,
stored credential, agent and none. Every other login method, second factor and
SSH auth method is a plugin through `ctx.auth`. **A8.**
### 13. Where plugins live
Official plugins live in `plugins/<id>/` as npm workspace packages in this
monorepo for all of 2.9.0, and get split into their own repos later. Every
plugin is enabled by default in 2.9.0; there is no install UI until 3.0.0.
### 14. Upgrades must be lossless
Every move of data, settings or host columns into a plugin comes with a
migration for all three dialects and a test proving existing data survives.
---
## Manifest v2 reference
`plugins/<id>/manifest.json`. The directory name must equal `id`. Unknown
fields are rejected at every level: a typo'd `contribute` used to validate
clean and be silently dropped, which made a manifest describe something it did
not do.
```jsonc
{
"id": "example", // ^[a-z][a-z0-9-]{1,39}$, equals the directory name
"name": "Example",
"version": "1.0.0", // semver
"description": "What it does.",
"author": { "name": "You", "url": "https://..." },
"license": "MIT",
"repository": "https://...", // optional
"category": "Productivity", // see the enum below
"icon": "Sparkles", // optional: Lucide name or a bundled icon.svg
"engine": {
"termix": ">=2.9.0", // display only, never enforced
"api": "1", // the real compatibility gate
},
"capabilities": ["kv:own", "ui:surface"],
"dependencies": { "other-plugin": "^1.0.0" }, // missing one blocks activation
"optionalDependencies": { "maybe": "^1.0.0" }, // missing one is normal
"provides": [
{
"service": "example.thing",
"version": "1.0.0",
"permission": "example.use",
},
],
"requires": [
{ "service": "other.thing", "versionRange": "^1.0.0", "optional": true },
],
"providesSecret": [
{ "key": "api-key", "permission": "example.secrets.share" },
],
"requiresSecret": [
{ "plugin": "other-plugin", "key": "api-key", "optional": true },
],
"contributes": {
"tabs": [
{
"id": "example",
"titleKey": "nav.example",
"icon": "Sparkles",
"openFrom": ["rail"],
},
],
"actions": [
{
"id": "example.open",
"titleKey": "k",
"handler": "open",
"permission": "example.use",
},
],
"actionSlots": [{ "id": "example.toolbar", "accepts": ["button"] }],
"permissionGroup": {
"group": "example",
"permissions": ["example.use"],
"defaultForRole": { "admin": ["example.use"] }, // own namespace only
},
"hostCapability": {
"key": "enableExample",
"labelKey": "k",
"editorTab": "general",
},
},
"backend": "dist/backend.js", // default
"frontend": "dist/frontend.js", // default
"locales": "locales", // default
"platforms": ["linux", "win32", "darwin"], // optional
}
```
`category` is one of: Terminal, Files & Transfer, Infrastructure, Monitoring,
Networking, Access & Security, Productivity.
`openFrom` is one of: rail, host-context-menu, palette.
Cross-field rules the JSON schema cannot express, checked by `parseManifest`:
- `engine.api` must match the SDK major version this build implements.
- Every `provides[].permission`, `providesSecret[].permission` and
`contributes.actions[].permission` must appear in the plugin's own
`contributes.permissionGroup.permissions`. A permission the catalog never
sees is one no admin can grant, so the surface would be invisible rather
than denied.
- `requiresSecret[].plugin` and both dependency maps must not name the plugin
itself, and a plugin cannot be in both `dependencies` and
`optionalDependencies`.
- Every `defaultForRole` entry must be a permission the plugin declares.
`contributes.hostCapability`, `provides`, `requires`, `providesSecret` and
`requiresSecret` are carried forward from v1 unchanged because the service and
secret registries read them at activation. A5, A6 and A7 reshape them.
Removed in v2: `permissions` (replaced by `capabilities`), `sidecars`, the
`capabilities.{backend,frontend,electron,platforms}` object (replaced by the
`backend`/`frontend`/`platforms` fields), `contributes.dashboardCards`,
`contributes.settingsPanel`, `contributes.apiPrefix`, and
`process:transport-owner` (the marker for the tier that no longer exists).
---
## Capability catalog
Colon-separated, ordered worst first. The i18n keys resolve under
`plugins.capabilities.<id>.title` and `.consequence` in
`src/ui/locales/en.json`, and the copy describes the consequence to the person
deciding, not the mechanism.
| Capability | Risk | Meaning |
| -------------------- | -------- | -------------------------------------------------------------- |
| `credentials:read` | critical | See plaintext secrets for any reachable host |
| `ssh:connect` | high | Run commands on hosts it is connected to |
| `process:spawn` | high | Run programs on the Termix server |
| `users:write` | high | Create and change user accounts |
| `auth:provide` | high | Add a login method or second factor |
| `system:tls` | high | Request and replace the server certificate |
| `hosts:write` | medium | Create and change hosts |
| `credentials:use` | medium | Connect using a host's stored credentials, without seeing them |
| `network:outbound` | medium | Make outbound requests |
| `network:serve` | medium | Open a listening port |
| `users:read` | medium | See usernames and roles, never hashes or 2FA state |
| `events:core` | medium | Subscribe to and emit core events carrying other users' data |
| `notify:send` | medium | Send notifications through configured channels |
| `audit:read` | medium | Read the audit log |
| `desktop:window` | medium | Open desktop windows (Electron only) |
| `hosts:read` | low | See the host list it can already see |
| `db:own` | low | Own tables |
| `kv:own` | low | Small key/value state |
| `secrets:own` | low | Its own encrypted secrets |
| `settings:read-core` | low | Read core server settings |
| `ui:surface` | low | Contribute tabs, panels and settings |
### Rules
- One catalog, in `packages/plugin-sdk/src/capabilities.ts`. Nothing else
defines a capability.
- Adding one is an entry here plus the SDK method that checks it. A capability
nothing checks is decoration, and v1 shipped nineteen of those.
- A capability is checked where the privileged thing happens, not at
activation. Activation-time checks say what a plugin might do; call-time
checks say what it did.
- `events:core` is the exception that proves the shape: without it a plugin may
only emit under `plugin.<its id>.`, because otherwise it could publish
`host.status` and drive the automations engine as though core had.
---
## The ctx surface
Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| Member | Capability | Status |
| ------------------------------------------------ | -------------------------------- | ------ |
| `ctx.pluginId`, `ctx.manifest` | none | **A1** |
| `ctx.log.*` | none | **A1** |
| `ctx.events.emit` / `.on` | `events:core` for core topics | **A1** |
| `ctx.kv.get/set/delete/list` | `kv:own` | **A1** |
| `ctx.registry.*` | none | **A1** |
| `ctx.services.provide` / `.get` | per-service RBAC | **A1** |
| `ctx.secrets.offer` / `.withdraw` / `.getShared` | per-secret RBAC | **A1** |
| `ctx.disposables.add` | none | **A1** |
| `ctx.asUser(userId, fn)` | none, always audited | **A1** |
| `ctx.currentActor()` | none | **A1** |
| `ctx.db` | `db:own` | A3 |
| `ctx.http.route` / `ctx.ws` | `network:serve` | A4 |
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | A4 |
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | A4 |
| `ctx.settings.*` | `settings:read-core` | A6 |
| `ctx.notify.*` | `notify:send` | A6 |
| `ctx.auth.*` | `auth:provide` | A8 |
| `ctx.fetch` | `network:outbound` | A4 |
### The actor
Never comes from plugin code. It is held in `AsyncLocalStorage` and set two
ways, with no third:
- **A request.** A4's HTTP and WebSocket middleware runs the handler as the
user core already authenticated.
- **`ctx.asUser(userId, fn)`** for background work with no request behind it.
Always audited, and every core API inside still applies that user's RBAC.
A plugin cannot pass a user id to a guarded method and have it believed. This
is what closes the forged-caller bug the old worker broker had, where a worker
could set `callerUserId` on its own messages.
---
## Lifecycle rules
1. **Everything is created inside `activate`.** No servers, timers, listeners
or connections at module scope. ESM caches modules, so disable-then-enable
re-runs `activate` against the same module object: anything created at
module scope is already torn down by the second call and never comes back.
2. **Everything is released on deactivate.** Registrations made through `ctx`
are tracked automatically. Anything the plugin creates itself goes in
`ctx.disposables.add(fn)`. A plugin that keeps a port after deactivate makes
"disabled" a lie and makes re-enabling fail on a port it still holds.
3. **Disposal does not depend on the plugin behaving.** The runtime empties the
disposable bag even when the plugin's `deactivate()` throws or does not
exist, in reverse registration order, isolating each disposer.
4. **A failed `activate` is still cleaned up**, and the plugin's own
`deactivate()` is _not_ called: it never finished starting, so it has no
state to tear down.
5. **Plugins never touch process signals or `process.exit`.** Core calls
`deactivate` on shutdown: `gracefulShutdown` in `src/backend/starter.ts`
calls `shutdownPlugins()`, which stops every plugin in reverse activation
order. A plugin that installs its own `SIGTERM` handler and exits skips the
rest of that sequence, including other plugins and the database flush.
6. **Enable, disable, enable must work without a restart.**
### States
`enabled`, `disabled`, `blocked` (a hard dependency is missing, recoverable),
`failed` (activation threw, a dependency cycle, or the error budget tripped).
`lastError` carries the reason.
### Error budget
Core wraps plugin callbacks it invokes. Five failures inside 60 seconds (both
configurable) marks the plugin `failed` and deactivates it, because a plugin
throwing on every call is worse than one that is off.
`POST /plugins/:id/retry` clears the budget and starts it again.
---
## What this protects, and what it does not
Be precise about this, because it is easy to overclaim and the previous
architecture did.
**What the capability system gives you**
- Every privileged thing a plugin can do through `ctx` is declared in a
manifest you can read before installing it.
- Every privileged call is checked against what was granted, and refused if it
was not.
- Every privileged call, allowed or refused, leaves an audit line naming the
plugin and the acting user. You can answer "what did this plugin do, and on
whose behalf".
- A plugin cannot name a user and be believed, so it cannot act as someone it
was not invoked by.
- Removing a capability from a manifest revokes the grant on the next boot.
**What it does not give you**
- **Containment.** A plugin runs in the server process. It can
`import("node:fs")` and read whatever the server user can read, open its own
sockets, spawn processes, and reach core modules directly. Guarding `ctx`
does not change any of that.
- **Protection from malicious code.** A plugin that wants to bypass the gate
can. Nothing here stops it.
So the honest boundary is: capabilities stop accidental and casual overreach,
and make every privileged call auditable. What stops malicious code is
**signing, review and the kill list** - the same trust model as any other
software you choose to install on a server.
The old worker tier claimed more than this, and the claim was not true either:
`worker_threads` shares the process, so a worker plugin could already reach
anything core could. The honest options for real containment are a child
process with `--permission`, or a WASM isolate. Neither is on the roadmap, and
until one is, this document does not pretend otherwise.
---
## Legacy core imports: the debt D1 removes
The twelve bundled plugins predate the SDK. They still reach core by relative
path (`../../../src/backend/...`), which a build-time string rewrite in
`scripts/copy-bundled-plugins.cjs` patches to the compiled output path.
This is expected and temporary. The SDK does not yet expose what they need:
the SSH connection pool, host resolution, the repository layer, the shared
component library. D1 removes the debt once A3 through A8 have given them
supported APIs to move onto.
What the lint fence enforces today:
- Core must not import a plugin backend. **Error**, no offenders.
- A plugin backend must not import frontend code or use the `@/` alias.
**Error**, no offenders.
- The shell must not import plugin components. **Warning** with 21 offenders,
which is exactly the work A7 does. It becomes an error then.
- Plugin backends importing core by relative path is not yet flagged, because
there is nowhere for them to go.
Known specifics:
- **TODO(B18):** `plugins/ai/backend/tools/executor.ts` imports
`../../../automations/backend/routes.js` directly. It is declared as a hard
`dependencies` entry so the loader starts automations first and marks `ai`
blocked if automations is disabled. In B18 `ai` calls automations through an
`automations` service as an `optionalDependency` and the direct import goes
away. This is the only cross-plugin import in the repo.
- Eight plugins keep their original core URLs through the dispatchers in
`src/backend/database/routes/*-dispatch.ts` rather than `/plugin-api`. A4
moves them.
- Four plugins own ports directly (ssh-terminal 30002, host-metrics 30005,
docker 30007 and 30009, remote-desktop 30008). A4 moves them onto the main
server.
- `src/ui/shell/pluginLoader.ts` still hardcodes plugin ids in
`BUILT_IN_TABS_BY_PLUGIN` and `applyFirstPartyActions`. A7 removes both.
+41
View File
@@ -0,0 +1,41 @@
{
"name": "@termix/plugin-sdk",
"version": "1.0.0",
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "MIT",
"type": "module",
"exports": {
"./backend": {
"types": "./dist/backend.d.ts",
"default": "./dist/backend.js"
},
"./frontend": {
"types": "./dist/frontend.d.ts",
"default": "./dist/frontend.js"
},
"./manifest": {
"types": "./dist/manifest.d.ts",
"default": "./dist/manifest.js"
},
"./capabilities": {
"types": "./dist/capabilities.d.ts",
"default": "./dist/capabilities.js"
},
"./testing": {
"types": "./dist/testing.d.ts",
"default": "./dist/testing.js"
},
"./schema/manifest.schema.json": "./schema/manifest.schema.json"
},
"files": [
"dist",
"schema",
"ARCHITECTURE.md"
],
"scripts": {
"build": "tsc -p tsconfig.json"
},
"dependencies": {
"semver": "^7.6.3"
}
}
@@ -0,0 +1,317 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://termix.site/schemas/plugin-manifest.schema.json",
"title": "Termix Plugin Manifest v2",
"type": "object",
"additionalProperties": false,
"required": [
"id",
"name",
"version",
"description",
"author",
"license",
"engine",
"category",
"capabilities"
],
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]{1,39}$",
"description": "Unique plugin id. Lowercase, starts with a letter, 2-40 characters. Must equal the plugin's directory name."
},
"name": { "type": "string", "minLength": 1 },
"version": {
"type": "string",
"pattern": "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z-.]+)?(\\+[0-9A-Za-z-.]+)?$",
"description": "Semver version string."
},
"description": { "type": "string", "minLength": 1 },
"author": {
"type": "object",
"additionalProperties": false,
"required": ["name"],
"properties": {
"name": { "type": "string", "minLength": 1 },
"url": { "type": "string", "minLength": 1 }
}
},
"license": { "type": "string", "minLength": 1 },
"repository": { "type": "string", "minLength": 1 },
"category": {
"type": "string",
"enum": [
"Terminal",
"Files & Transfer",
"Infrastructure",
"Monitoring",
"Networking",
"Access & Security",
"Productivity"
]
},
"icon": {
"type": "string",
"minLength": 1,
"description": "A Lucide icon name, or a relative path to an icon.svg in the plugin root."
},
"engine": {
"type": "object",
"additionalProperties": false,
"required": ["termix", "api"],
"properties": {
"termix": {
"type": "string",
"minLength": 1,
"description": "Display string only. Never enforced."
},
"api": {
"type": "string",
"pattern": "^[0-9]+$",
"description": "Plugin SDK major version. The real compatibility gate."
}
}
},
"capabilities": {
"type": "array",
"description": "Catalog capability ids this plugin's code needs. See the SDK capability catalog.",
"items": {
"type": "string",
"enum": [
"credentials:read",
"ssh:connect",
"process:spawn",
"users:write",
"auth:provide",
"system:tls",
"hosts:write",
"credentials:use",
"network:outbound",
"network:serve",
"users:read",
"events:core",
"notify:send",
"audit:read",
"desktop:window",
"hosts:read",
"db:own",
"kv:own",
"secrets:own",
"settings:read-core",
"ui:surface"
]
}
},
"dependencies": {
"type": "object",
"description": "Plugin ids this plugin cannot run without, mapped to a semver range.",
"additionalProperties": { "type": "string", "minLength": 1 },
"propertyNames": { "pattern": "^[a-z][a-z0-9-]{1,39}$" }
},
"optionalDependencies": {
"type": "object",
"description": "Plugin ids this plugin uses when present. It must keep working without them.",
"additionalProperties": { "type": "string", "minLength": 1 },
"propertyNames": { "pattern": "^[a-z][a-z0-9-]{1,39}$" }
},
"provides": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["service", "version", "permission"],
"properties": {
"service": {
"type": "string",
"pattern": "^[a-z0-9-]+(\\.[a-z0-9-]+)+$"
},
"version": {
"type": "string",
"pattern": "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z-.]+)?(\\+[0-9A-Za-z-.]+)?$"
},
"permission": { "type": "string", "minLength": 1 }
}
}
},
"requires": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["service", "versionRange"],
"properties": {
"service": {
"type": "string",
"pattern": "^[a-z0-9-]+(\\.[a-z0-9-]+)+$"
},
"versionRange": { "type": "string", "minLength": 1 },
"optional": { "type": "boolean" }
}
}
},
"providesSecret": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["key", "permission"],
"properties": {
"key": { "type": "string", "pattern": "^[a-z0-9-]+$" },
"permission": { "type": "string", "minLength": 1 },
"descriptionKey": { "type": "string", "minLength": 1 }
}
}
},
"requiresSecret": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["plugin", "key"],
"properties": {
"plugin": { "type": "string", "pattern": "^[a-z][a-z0-9-]{1,39}$" },
"key": { "type": "string", "pattern": "^[a-z0-9-]+$" },
"optional": { "type": "boolean" }
}
}
},
"contributes": {
"type": "object",
"additionalProperties": false,
"properties": {
"tabs": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "titleKey", "icon", "openFrom"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"titleKey": { "type": "string", "minLength": 1 },
"icon": { "type": "string", "minLength": 1 },
"openFrom": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"enum": ["rail", "host-context-menu", "palette"]
}
}
}
}
},
"actions": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "titleKey", "handler"],
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z0-9-]+(\\.[a-zA-Z0-9-]+)+$"
},
"titleKey": { "type": "string", "minLength": 1 },
"handler": {
"type": "string",
"pattern": "^[A-Za-z_$][A-Za-z0-9_$]*$"
},
"icon": { "type": "string", "minLength": 1 },
"permission": { "type": "string", "minLength": 1 },
"slot": { "type": "string", "minLength": 1 },
"kind": { "type": "string", "enum": ["button"] }
}
}
},
"actionSlots": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "accepts"],
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z0-9-]+(\\.[a-zA-Z0-9-]+)+$"
},
"accepts": {
"type": "array",
"minItems": 1,
"items": { "type": "string", "enum": ["button"] }
},
"descriptionKey": { "type": "string", "minLength": 1 }
}
}
},
"permissionGroup": {
"type": "object",
"additionalProperties": false,
"required": ["group", "permissions"],
"properties": {
"group": { "type": "string", "minLength": 1 },
"permissions": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"pattern": "^[a-z0-9-]+(\\.[a-z0-9_-]+)+$"
}
},
"defaultForRole": {
"type": "object",
"description": "Role name -> permissions. Only permissions this plugin declares above are allowed.",
"additionalProperties": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
}
}
}
},
"hostCapability": {
"description": "A host-editor checkbox backed by a boolean column on the host record. Reshaped by a later step.",
"oneOf": [
{ "$ref": "#/$defs/hostCapability" },
{
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/hostCapability" }
}
]
}
}
},
"backend": {
"type": "string",
"minLength": 1,
"description": "Built backend entry, relative to the plugin root. Defaults to dist/backend.js."
},
"frontend": {
"type": "string",
"minLength": 1,
"description": "Built frontend entry, relative to the plugin root. Defaults to dist/frontend.js."
},
"locales": {
"type": "string",
"minLength": 1,
"description": "Locales directory, relative to the plugin root. Defaults to locales."
},
"platforms": {
"type": "array",
"minItems": 1,
"items": { "type": "string", "enum": ["linux", "win32", "darwin"] }
}
},
"$defs": {
"hostCapability": {
"type": "object",
"additionalProperties": false,
"required": ["key", "labelKey", "editorTab"],
"properties": {
"key": { "type": "string", "minLength": 1 },
"labelKey": { "type": "string", "minLength": 1 },
"editorTab": { "type": "string", "minLength": 1 }
}
}
}
}
+135
View File
@@ -0,0 +1,135 @@
/**
* 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.
*
* Members marked A3-A8 are not built yet. A plugin written against them today
* will type-check and fail at runtime, which is the intended signal.
*/
import type { PluginManifest } from "./manifest.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;
}
}
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.<id>.* 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<unknown>;
set: (key: string, value: unknown) => Promise<void>;
delete: (key: string) => Promise<boolean>;
list: () => Promise<string[]>;
}
export interface PluginRegistry {
provide: <T>(key: string, value: T) => void;
consume: <T>(key: string) => T | undefined;
revoke: (key: string, value?: unknown) => boolean;
}
export interface PluginServices {
provide: <T extends object>(service: string, implementation: T) => void;
get: <T extends object>(service: string, options?: { userId?: string }) => T;
}
export interface PluginSecrets {
offer: (
key: string,
resolve: (userId: string) => Promise<string | null> | string | null,
) => void;
withdraw: (key: string) => boolean;
getShared: (
pluginId: string,
key: string,
options?: { userId?: string },
) => Promise<string | null>;
}
/**
* 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>) => void;
}
export interface PluginContext {
readonly pluginId: string;
readonly manifest: PluginManifest;
readonly log: PluginLogger;
readonly events: PluginEvents;
readonly kv: PluginKeyValue;
readonly registry: PluginRegistry;
readonly services: PluginServices;
readonly secrets: PluginSecrets;
readonly disposables: PluginDisposables;
/**
* 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: <T>(userId: string, fn: () => Promise<T> | T) => Promise<T>;
/** The acting user for the current call, when there is one. */
currentActor: () => string | undefined;
}
export type PluginActivate = (ctx: PluginContext) => void | Promise<void>;
export type PluginDeactivate = () => void | Promise<void>;
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";
+97
View File
@@ -0,0 +1,97 @@
/**
* What a plugin's code may do.
*
* A capability is declared in the manifest and checked by the SDK method that
* needs it. It is not a user permission: those stay in core RBAC and are dotted
* (hosts.view), while capabilities are colon-separated (hosts:read) so the two
* can never be confused in a grant table or a log line.
*
* Adding one means an entry here plus the SDK method that checks it. The i18n
* keys resolve against core's en.json under plugins.capabilities.<id>.
*
* The copy those keys carry describes the consequence to the person deciding,
* not the mechanism. "Run commands on your servers", not "grants ssh:connect".
*/
export type CapabilityRisk = "low" | "medium" | "high" | "critical";
export interface CapabilityInfo {
id: string;
risk: CapabilityRisk;
/** i18n key for the short consequence, e.g. "Run commands on your servers". */
titleKey: string;
/** i18n key for the qualifying sentence under the title. */
consequenceKey: string;
}
function entry(id: string, risk: CapabilityRisk): CapabilityInfo {
return {
id,
risk,
titleKey: `plugins.capabilities.${id}.title`,
consequenceKey: `plugins.capabilities.${id}.consequence`,
};
}
/**
* Ordered critical -> low. The install and review UI reads this order directly
* so the worst line is never folded into a summary row.
*/
export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("credentials:read", "critical"),
entry("ssh:connect", "high"),
entry("process:spawn", "high"),
entry("users:write", "high"),
entry("auth:provide", "high"),
entry("system:tls", "high"),
entry("hosts:write", "medium"),
entry("credentials:use", "medium"),
entry("network:outbound", "medium"),
entry("network:serve", "medium"),
entry("users:read", "medium"),
entry("events:core", "medium"),
entry("notify:send", "medium"),
entry("audit:read", "medium"),
entry("desktop:window", "medium"),
entry("hosts:read", "low"),
entry("db:own", "low"),
entry("kv:own", "low"),
entry("secrets:own", "low"),
entry("settings:read-core", "low"),
entry("ui:surface", "low"),
] as const;
export const CAPABILITY_IDS: readonly string[] = CAPABILITY_CATALOG.map(
(capability) => capability.id,
);
/**
* Kept as a string rather than a union of the catalog ids: a capability is
* data the runtime checks at load time, and a union here would force every
* caller holding a manifest value to cast.
*/
export type Capability = string;
const BY_ID = new Map(
CAPABILITY_CATALOG.map((capability) => [capability.id, capability]),
);
export function isKnownCapability(capability: string): boolean {
return BY_ID.has(capability);
}
export function getCapabilityInfo(
capability: string,
): CapabilityInfo | undefined {
return BY_ID.get(capability);
}
export const CAPABILITY_RISK_ORDER: readonly CapabilityRisk[] = [
"critical",
"high",
"medium",
"low",
];
+40
View File
@@ -0,0 +1,40 @@
/**
* The frontend contract: what a plugin's frontend entry receives.
*
* Stubs only. A7 builds the real loader and fills these in, at which point a
* plugin registers every surface it contributes through this object and the
* shell stops carrying plugin ids of its own.
*/
import type { PluginManifest } from "./manifest.js";
export interface TermixAppInfo {
readonly pluginId: string;
readonly manifest: PluginManifest;
}
/**
* Registration surface for a plugin frontend.
*
* A7 adds: rail items, panels, tabs, host editor sections, host actions and
* badges, context menu items, palette entries, dashboard cards, homepage
* widgets, settings components, action slots, login/2FA UI and SSH auth
* editors. Everything registered here is removed when the plugin is disabled.
*/
export interface TermixApp extends TermixAppInfo {
/** Registered cleanup, run when the plugin is disabled. */
onDispose: (dispose: () => void) => void;
}
export type FrontendActivate = (app: TermixApp) => void | Promise<void>;
export interface FrontendModule {
activate: FrontendActivate;
deactivate?: () => void | Promise<void>;
}
export function definePluginFrontend(plugin: FrontendModule): FrontendModule {
return plugin;
}
export type { PluginManifest } from "./manifest.js";
+840
View File
@@ -0,0 +1,840 @@
/**
* Manifest v2: the inert JSON description of a plugin.
*
* Core reads this without running any plugin code, so everything that decides
* whether a plugin may load, what it may do and where its UI appears has to be
* expressible here.
*
* Unknown fields are rejected at every level. A typo'd "contribute" or
* "permissionGroups" used to validate clean and be silently dropped, which
* made a manifest look like it declared something it did not.
*/
import semver from "semver";
import { isKnownCapability } from "./capabilities.js";
/**
* The SDK major version this build implements. engine.api is the real
* compatibility gate; engine.termix is a display string and is never enforced.
*/
export const SUPPORTED_PLUGIN_API_VERSION = "1";
export const PLUGIN_CATEGORIES = [
"Terminal",
"Files & Transfer",
"Infrastructure",
"Monitoring",
"Networking",
"Access & Security",
"Productivity",
] as const;
export const PLUGIN_PLATFORMS = ["linux", "win32", "darwin"] as const;
export const ACTION_CONTRIBUTION_KINDS = ["button"] as const;
export type ActionContributionKind = (typeof ACTION_CONTRIBUTION_KINDS)[number];
const OPEN_FROM_VALUES = ["rail", "host-context-menu", "palette"] as const;
const ID_PATTERN = /^[a-z][a-z0-9-]{1,39}$/;
const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?(\+[0-9A-Za-z-.]+)?$/;
const API_VERSION_PATTERN = /^[0-9]+$/;
/** Service names are dotted, e.g. "ssh.transport". */
const SERVICE_PATTERN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/;
const SECRET_KEY_PATTERN = /^[a-z0-9-]+$/;
/** Action ids name a frontend function, so segments may be camelCase. */
const ACTION_ID_PATTERN = /^[a-z0-9-]+(\.[a-zA-Z0-9-]+)+$/;
const HANDLER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
const PERMISSION_PATTERN = /^[a-z0-9-]+(\.[a-z0-9_-]+)+$/;
export interface PluginAuthor {
name: string;
url?: string;
}
export interface PluginEngine {
/** Display string only, e.g. ">=2.9.0". Never enforced. */
termix: string;
/** SDK major version as a string. The real gate. */
api: string;
}
export interface HostCapabilityContribution {
key: string;
labelKey: string;
editorTab: string;
}
export interface PluginTabContribution {
id: string;
titleKey: string;
icon: string;
openFrom: string[];
}
export interface PluginPermissionGroup {
group: string;
permissions: string[];
/** Role name -> permissions. Only this plugin's own permissions are allowed. */
defaultForRole?: Record<string, string[]>;
}
export interface PluginActionContribution {
id: string;
titleKey: string;
/** Name of the frontend export implementing this action. */
handler: string;
icon?: string;
permission?: string;
slot?: string;
kind?: ActionContributionKind;
}
export interface PluginActionSlot {
id: string;
accepts: ActionContributionKind[];
descriptionKey?: string;
}
export interface PluginServiceProvide {
service: string;
version: string;
/** The role permission a calling user needs. */
permission: string;
}
export interface PluginServiceRequire {
service: string;
versionRange: string;
optional?: boolean;
}
export interface PluginSecretProvide {
key: string;
permission: string;
descriptionKey?: string;
}
export interface PluginSecretRequire {
plugin: string;
key: string;
optional?: boolean;
}
export interface PluginContributions {
tabs?: PluginTabContribution[];
actions?: PluginActionContribution[];
actionSlots?: PluginActionSlot[];
permissionGroup?: PluginPermissionGroup;
/**
* A host-editor checkbox backed by a boolean column on the host record.
* A5/A6/A7 reshape this; it stays in v2 because remote-desktop, docker,
* proxmox and web-endpoint ship it today.
*/
hostCapability?: HostCapabilityContribution | HostCapabilityContribution[];
}
export interface PluginManifest {
id: string;
name: string;
version: string;
description: string;
author: PluginAuthor;
license: string;
repository?: string;
category: string;
icon?: string;
engine: PluginEngine;
/** Catalog capability ids. */
capabilities: string[];
/** pluginId -> semver range. A missing one blocks activation. */
dependencies?: Record<string, string>;
/** pluginId -> semver range. A missing one is normal. */
optionalDependencies?: Record<string, string>;
provides?: PluginServiceProvide[];
requires?: PluginServiceRequire[];
providesSecret?: PluginSecretProvide[];
requiresSecret?: PluginSecretRequire[];
contributes?: PluginContributions;
/** Built backend entry, relative to the plugin root. */
backend?: string;
/** Built frontend entry, relative to the plugin root. */
frontend?: string;
/** Locales directory, relative to the plugin root. */
locales?: string;
platforms?: string[];
}
export const DEFAULT_BACKEND_ENTRY = "dist/backend.js";
export const DEFAULT_FRONTEND_ENTRY = "dist/frontend.js";
export const DEFAULT_LOCALES_DIR = "locales";
const REQUIRED_TOP_LEVEL = [
"id",
"name",
"version",
"description",
"author",
"license",
"engine",
"category",
"capabilities",
];
const ALLOWED_TOP_LEVEL = new Set([
...REQUIRED_TOP_LEVEL,
"repository",
"icon",
"dependencies",
"optionalDependencies",
"provides",
"requires",
"providesSecret",
"requiresSecret",
"contributes",
"backend",
"frontend",
"locales",
"platforms",
]);
const ALLOWED_CONTRIBUTES = new Set([
"tabs",
"actions",
"actionSlots",
"permissionGroup",
"hostCapability",
]);
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** Pushes an error for every key that is not in `allowed`. */
function rejectUnknown(
value: Record<string, unknown>,
allowed: Set<string> | string[],
where: string,
errors: string[],
): void {
const set = Array.isArray(allowed) ? new Set(allowed) : allowed;
for (const key of Object.keys(value)) {
if (!set.has(key)) {
errors.push(`Unknown field "${key}" in ${where}`);
}
}
}
function requireString(
value: unknown,
where: string,
errors: string[],
): value is string {
if (typeof value !== "string" || value.trim().length === 0) {
errors.push(`Field "${where}" must be a non-empty string`);
return false;
}
return true;
}
export function validateManifest(manifest: unknown): string[] {
const errors: string[] = [];
if (!isPlainObject(manifest)) {
return ["Manifest must be a JSON object"];
}
const m = manifest;
rejectUnknown(m, ALLOWED_TOP_LEVEL, "the manifest", errors);
for (const field of REQUIRED_TOP_LEVEL) {
if (!(field in m)) errors.push(`Missing required field: "${field}"`);
}
if ("id" in m) {
if (typeof m.id !== "string" || !ID_PATTERN.test(m.id)) {
errors.push(
`Field "id" must match ${ID_PATTERN} (lowercase, starts with a letter, 2-40 chars), got: ${JSON.stringify(m.id)}`,
);
}
}
if ("name" in m) requireString(m.name, "name", errors);
if ("description" in m) requireString(m.description, "description", errors);
if ("license" in m) requireString(m.license, "license", errors);
if ("version" in m) {
if (typeof m.version !== "string" || !SEMVER_PATTERN.test(m.version)) {
errors.push(
`Field "version" must be valid semver, got: ${JSON.stringify(m.version)}`,
);
}
}
if ("repository" in m) requireString(m.repository, "repository", errors);
if ("icon" in m) requireString(m.icon, "icon", errors);
if ("backend" in m) requireString(m.backend, "backend", errors);
if ("frontend" in m) requireString(m.frontend, "frontend", errors);
if ("locales" in m) requireString(m.locales, "locales", errors);
validateAuthor(m.author, errors);
validateEngine(m.engine, errors);
validateCategory(m.category, errors);
validateCapabilities(m.capabilities, errors);
validatePlatforms(m.platforms, errors);
validateDependencyMap(m.dependencies, "dependencies", errors);
validateDependencyMap(m.optionalDependencies, "optionalDependencies", errors);
validateProvides(m.provides, errors);
validateRequires(m.requires, errors);
validateProvidesSecret(m.providesSecret, errors);
validateRequiresSecret(m.requiresSecret, errors);
validateContributes(m.contributes, errors);
return errors;
}
function validateAuthor(author: unknown, errors: string[]): void {
if (author === undefined) return;
if (!isPlainObject(author)) {
errors.push('Field "author" must be an object');
return;
}
rejectUnknown(author, ["name", "url"], '"author"', errors);
requireString(author.name, "author.name", errors);
if ("url" in author) requireString(author.url, "author.url", errors);
}
function validateEngine(engine: unknown, errors: string[]): void {
if (engine === undefined) return;
if (!isPlainObject(engine)) {
errors.push('Field "engine" must be an object');
return;
}
rejectUnknown(engine, ["termix", "api"], '"engine"', errors);
requireString(engine.termix, "engine.termix", errors);
if (typeof engine.api !== "string" || !API_VERSION_PATTERN.test(engine.api)) {
errors.push(
`Field "engine.api" must be an integer string, got: ${JSON.stringify(engine.api)}`,
);
}
}
function validateCategory(category: unknown, errors: string[]): void {
if (category === undefined) return;
if (typeof category !== "string") {
errors.push('Field "category" must be a string');
return;
}
if (!PLUGIN_CATEGORIES.includes(category as never)) {
errors.push(
`Field "category" must be one of: ${PLUGIN_CATEGORIES.join(", ")}, got: "${category}"`,
);
}
}
function validateCapabilities(capabilities: unknown, errors: string[]): void {
if (capabilities === undefined) return;
if (!Array.isArray(capabilities)) {
errors.push('Field "capabilities" must be an array of capability ids');
return;
}
const seen = new Set<string>();
capabilities.forEach((capability, index) => {
if (typeof capability !== "string") {
errors.push(`capabilities[${index}] must be a string`);
return;
}
if (!isKnownCapability(capability)) {
errors.push(
`capabilities[${index}] is not a known capability: "${capability}"`,
);
return;
}
if (seen.has(capability)) {
errors.push(`capabilities[${index}] duplicates "${capability}"`);
}
seen.add(capability);
});
}
function validatePlatforms(platforms: unknown, errors: string[]): void {
if (platforms === undefined) return;
if (!Array.isArray(platforms) || platforms.length === 0) {
errors.push('Field "platforms" must be a non-empty array when present');
return;
}
platforms.forEach((platform, index) => {
if (!PLUGIN_PLATFORMS.includes(platform as never)) {
errors.push(
`platforms[${index}] must be one of: ${PLUGIN_PLATFORMS.join(", ")}, got: ${JSON.stringify(platform)}`,
);
}
});
}
function validateDependencyMap(
value: unknown,
field: string,
errors: string[],
): void {
if (value === undefined) return;
if (!isPlainObject(value)) {
errors.push(
`Field "${field}" must be an object of pluginId -> semver range`,
);
return;
}
for (const [pluginId, range] of Object.entries(value)) {
if (!ID_PATTERN.test(pluginId)) {
errors.push(`${field} key "${pluginId}" is not a valid plugin id`);
}
if (typeof range !== "string" || !semver.validRange(range)) {
errors.push(
`${field}["${pluginId}"] must be a valid semver range, got: ${JSON.stringify(range)}`,
);
}
}
}
function validateProvides(provides: unknown, errors: string[]): void {
if (provides === undefined) return;
if (!Array.isArray(provides)) {
errors.push('Field "provides" must be an array');
return;
}
const seen = new Set<string>();
provides.forEach((raw, index) => {
const where = `provides[${index}]`;
if (!isPlainObject(raw)) {
errors.push(`${where} must be an object`);
return;
}
rejectUnknown(raw, ["service", "version", "permission"], where, errors);
if (typeof raw.service !== "string" || !SERVICE_PATTERN.test(raw.service)) {
errors.push(`${where}.service must be a dotted lowercase name`);
} else {
if (seen.has(raw.service)) {
errors.push(`${where}.service duplicates "${raw.service}"`);
}
seen.add(raw.service);
}
if (typeof raw.version !== "string" || !SEMVER_PATTERN.test(raw.version)) {
errors.push(`${where}.version must be valid semver`);
}
requireString(raw.permission, `${where}.permission`, errors);
});
}
function validateRequires(requires: unknown, errors: string[]): void {
if (requires === undefined) return;
if (!Array.isArray(requires)) {
errors.push('Field "requires" must be an array');
return;
}
requires.forEach((raw, index) => {
const where = `requires[${index}]`;
if (!isPlainObject(raw)) {
errors.push(`${where} must be an object`);
return;
}
rejectUnknown(raw, ["service", "versionRange", "optional"], where, errors);
if (typeof raw.service !== "string" || !SERVICE_PATTERN.test(raw.service)) {
errors.push(`${where}.service must be a dotted lowercase name`);
}
if (
typeof raw.versionRange !== "string" ||
!semver.validRange(raw.versionRange)
) {
errors.push(`${where}.versionRange must be a valid semver range`);
}
if ("optional" in raw && typeof raw.optional !== "boolean") {
errors.push(`${where}.optional must be a boolean`);
}
});
}
function validateProvidesSecret(value: unknown, errors: string[]): void {
if (value === undefined) return;
if (!Array.isArray(value)) {
errors.push('Field "providesSecret" must be an array');
return;
}
const seen = new Set<string>();
value.forEach((raw, index) => {
const where = `providesSecret[${index}]`;
if (!isPlainObject(raw)) {
errors.push(`${where} must be an object`);
return;
}
rejectUnknown(raw, ["key", "permission", "descriptionKey"], where, errors);
if (typeof raw.key !== "string" || !SECRET_KEY_PATTERN.test(raw.key)) {
errors.push(`${where}.key must be a lowercase slug`);
} else {
if (seen.has(raw.key))
errors.push(`${where}.key duplicates "${raw.key}"`);
seen.add(raw.key);
}
requireString(raw.permission, `${where}.permission`, errors);
if ("descriptionKey" in raw) {
requireString(raw.descriptionKey, `${where}.descriptionKey`, errors);
}
});
}
function validateRequiresSecret(value: unknown, errors: string[]): void {
if (value === undefined) return;
if (!Array.isArray(value)) {
errors.push('Field "requiresSecret" must be an array');
return;
}
value.forEach((raw, index) => {
const where = `requiresSecret[${index}]`;
if (!isPlainObject(raw)) {
errors.push(`${where} must be an object`);
return;
}
rejectUnknown(raw, ["plugin", "key", "optional"], where, errors);
if (typeof raw.plugin !== "string" || !ID_PATTERN.test(raw.plugin)) {
errors.push(`${where}.plugin must be a valid plugin id`);
}
if (typeof raw.key !== "string" || !SECRET_KEY_PATTERN.test(raw.key)) {
errors.push(`${where}.key must be a lowercase slug`);
}
if ("optional" in raw && typeof raw.optional !== "boolean") {
errors.push(`${where}.optional must be a boolean`);
}
});
}
function validateContributes(contributes: unknown, errors: string[]): void {
if (contributes === undefined) return;
if (!isPlainObject(contributes)) {
errors.push('Field "contributes" must be an object');
return;
}
rejectUnknown(contributes, ALLOWED_CONTRIBUTES, '"contributes"', errors);
validateTabs(contributes.tabs, errors);
validatePermissionGroup(contributes.permissionGroup, errors);
validateActions(contributes.actions, errors);
validateActionSlots(contributes.actionSlots, errors);
validateHostCapability(contributes.hostCapability, errors);
}
function validateTabs(tabs: unknown, errors: string[]): void {
if (tabs === undefined) return;
if (!Array.isArray(tabs)) {
errors.push('Field "contributes.tabs" must be an array');
return;
}
const seen = new Set<string>();
tabs.forEach((raw, index) => {
const where = `contributes.tabs[${index}]`;
if (!isPlainObject(raw)) {
errors.push(`${where} must be an object`);
return;
}
rejectUnknown(raw, ["id", "titleKey", "icon", "openFrom"], where, errors);
if (requireString(raw.id, `${where}.id`, errors)) {
if (seen.has(raw.id)) errors.push(`${where}.id duplicates "${raw.id}"`);
seen.add(raw.id);
}
requireString(raw.titleKey, `${where}.titleKey`, errors);
requireString(raw.icon, `${where}.icon`, errors);
if (!Array.isArray(raw.openFrom) || raw.openFrom.length === 0) {
errors.push(`${where}.openFrom must be a non-empty array`);
return;
}
raw.openFrom.forEach((value: unknown, openIndex: number) => {
if (!OPEN_FROM_VALUES.includes(value as never)) {
errors.push(
`${where}.openFrom[${openIndex}] must be one of: ${OPEN_FROM_VALUES.join(", ")}`,
);
}
});
});
}
function validatePermissionGroup(group: unknown, errors: string[]): void {
if (group === undefined) return;
const where = "contributes.permissionGroup";
if (!isPlainObject(group)) {
errors.push(`${where} must be an object`);
return;
}
rejectUnknown(
group,
["group", "permissions", "defaultForRole"],
where,
errors,
);
requireString(group.group, `${where}.group`, errors);
if (!Array.isArray(group.permissions) || group.permissions.length === 0) {
errors.push(`${where}.permissions must be a non-empty array`);
} else {
group.permissions.forEach((permission: unknown, index: number) => {
if (
typeof permission !== "string" ||
!PERMISSION_PATTERN.test(permission)
) {
errors.push(
`${where}.permissions[${index}] must be a dotted permission name`,
);
}
});
}
if (group.defaultForRole === undefined) return;
if (!isPlainObject(group.defaultForRole)) {
errors.push(
`${where}.defaultForRole must be an object of role -> permissions`,
);
return;
}
const declared = new Set(
Array.isArray(group.permissions)
? (group.permissions as unknown[]).filter(
(value): value is string => typeof value === "string",
)
: [],
);
for (const [role, wanted] of Object.entries(group.defaultForRole)) {
if (!Array.isArray(wanted)) {
errors.push(`${where}.defaultForRole["${role}"] must be an array`);
continue;
}
wanted.forEach((permission: unknown, index: number) => {
if (typeof permission !== "string") {
errors.push(
`${where}.defaultForRole["${role}"][${index}] must be a string`,
);
return;
}
// A plugin may only suggest defaults for permissions it declares itself.
// Without this a manifest could add admin.* to any role at boot.
if (!declared.has(permission)) {
errors.push(
`${where}.defaultForRole["${role}"][${index}] is "${permission}", which this plugin does not declare in contributes.permissionGroup.permissions`,
);
}
});
}
}
function validateActions(actions: unknown, errors: string[]): void {
if (actions === undefined) return;
if (!Array.isArray(actions)) {
errors.push('Field "contributes.actions" must be an array');
return;
}
const seen = new Set<string>();
actions.forEach((raw, index) => {
const where = `contributes.actions[${index}]`;
if (!isPlainObject(raw)) {
errors.push(`${where} must be an object`);
return;
}
rejectUnknown(
raw,
["id", "titleKey", "handler", "icon", "permission", "slot", "kind"],
where,
errors,
);
if (typeof raw.id !== "string" || !ACTION_ID_PATTERN.test(raw.id)) {
errors.push(`${where}.id must be a dotted action id`);
} else {
if (seen.has(raw.id)) errors.push(`${where}.id duplicates "${raw.id}"`);
seen.add(raw.id);
}
requireString(raw.titleKey, `${where}.titleKey`, errors);
if (typeof raw.handler !== "string" || !HANDLER_PATTERN.test(raw.handler)) {
errors.push(`${where}.handler must be a JavaScript identifier`);
}
if ("icon" in raw) requireString(raw.icon, `${where}.icon`, errors);
if ("permission" in raw) {
requireString(raw.permission, `${where}.permission`, errors);
}
if ("slot" in raw) requireString(raw.slot, `${where}.slot`, errors);
if (
"kind" in raw &&
!ACTION_CONTRIBUTION_KINDS.includes(raw.kind as never)
) {
errors.push(
`${where}.kind must be one of: ${ACTION_CONTRIBUTION_KINDS.join(", ")}`,
);
}
});
}
function validateActionSlots(slots: unknown, errors: string[]): void {
if (slots === undefined) return;
if (!Array.isArray(slots)) {
errors.push('Field "contributes.actionSlots" must be an array');
return;
}
const seen = new Set<string>();
slots.forEach((raw, index) => {
const where = `contributes.actionSlots[${index}]`;
if (!isPlainObject(raw)) {
errors.push(`${where} must be an object`);
return;
}
rejectUnknown(raw, ["id", "accepts", "descriptionKey"], where, errors);
if (typeof raw.id !== "string" || !ACTION_ID_PATTERN.test(raw.id)) {
errors.push(`${where}.id must be a dotted slot id`);
} else {
if (seen.has(raw.id)) errors.push(`${where}.id duplicates "${raw.id}"`);
seen.add(raw.id);
}
if (!Array.isArray(raw.accepts) || raw.accepts.length === 0) {
errors.push(`${where}.accepts must be a non-empty array`);
} else {
raw.accepts.forEach((kind: unknown, kindIndex: number) => {
if (!ACTION_CONTRIBUTION_KINDS.includes(kind as never)) {
errors.push(
`${where}.accepts[${kindIndex}] must be one of: ${ACTION_CONTRIBUTION_KINDS.join(", ")}`,
);
}
});
}
if ("descriptionKey" in raw) {
requireString(raw.descriptionKey, `${where}.descriptionKey`, errors);
}
});
}
function validateHostCapability(value: unknown, errors: string[]): void {
if (value === undefined) return;
const entries = Array.isArray(value) ? value : [value];
if (Array.isArray(value) && value.length === 0) {
errors.push(
'Field "contributes.hostCapability" must not be an empty array',
);
return;
}
const seen = new Set<string>();
entries.forEach((raw, index) => {
const where = Array.isArray(value)
? `contributes.hostCapability[${index}]`
: "contributes.hostCapability";
if (!isPlainObject(raw)) {
errors.push(`${where} must be an object`);
return;
}
rejectUnknown(raw, ["key", "labelKey", "editorTab"], where, errors);
if (requireString(raw.key, `${where}.key`, errors)) {
if (seen.has(raw.key))
errors.push(`${where}.key duplicates "${raw.key}"`);
seen.add(raw.key);
}
requireString(raw.labelKey, `${where}.labelKey`, errors);
requireString(raw.editorTab, `${where}.editorTab`, errors);
});
}
export interface ParsedManifest {
manifest?: PluginManifest;
errors: string[];
}
/**
* Validates, then applies the cross-field rules a JSON schema cannot express
* and fills in the entry-point defaults.
*/
export function parseManifest(raw: unknown): ParsedManifest {
const errors = validateManifest(raw);
if (errors.length > 0) return { errors };
const manifest = raw as PluginManifest;
if (manifest.engine.api !== SUPPORTED_PLUGIN_API_VERSION) {
return {
errors: [
`Plugin targets SDK API version ${manifest.engine.api}, but this build implements ${SUPPORTED_PLUGIN_API_VERSION}`,
],
};
}
const declared = new Set(
manifest.contributes?.permissionGroup?.permissions ?? [],
);
// A permission the catalog never sees is one no admin can grant, so the
// service or action would be invisible rather than denied.
for (const provide of manifest.provides ?? []) {
if (!declared.has(provide.permission)) {
errors.push(
`provides["${provide.service}"].permission "${provide.permission}" is not declared in contributes.permissionGroup.permissions`,
);
}
}
for (const secret of manifest.providesSecret ?? []) {
if (!declared.has(secret.permission)) {
errors.push(
`providesSecret["${secret.key}"].permission "${secret.permission}" is not declared in contributes.permissionGroup.permissions`,
);
}
}
for (const action of manifest.contributes?.actions ?? []) {
if (action.permission && !declared.has(action.permission)) {
errors.push(
`contributes.actions["${action.id}"].permission "${action.permission}" is not declared in contributes.permissionGroup.permissions`,
);
}
}
for (const secret of manifest.requiresSecret ?? []) {
if (secret.plugin === manifest.id) {
errors.push(
`requiresSecret["${secret.key}"] points at this plugin; use ctx.secrets.get instead`,
);
}
}
for (const dependency of Object.keys(manifest.dependencies ?? {})) {
if (dependency === manifest.id) {
errors.push(`dependencies["${dependency}"] points at this plugin`);
}
if (manifest.optionalDependencies?.[dependency]) {
errors.push(
`"${dependency}" is listed in both dependencies and optionalDependencies`,
);
}
}
for (const dependency of Object.keys(manifest.optionalDependencies ?? {})) {
if (dependency === manifest.id) {
errors.push(
`optionalDependencies["${dependency}"] points at this plugin`,
);
}
}
if (errors.length > 0) return { errors };
return {
manifest: {
...manifest,
backend: manifest.backend ?? DEFAULT_BACKEND_ENTRY,
frontend: manifest.frontend ?? DEFAULT_FRONTEND_ENTRY,
locales: manifest.locales ?? DEFAULT_LOCALES_DIR,
},
errors: [],
};
}
+139
View File
@@ -0,0 +1,139 @@
/**
* Test helpers for plugin authors.
*
* A2 adds the shared vitest preset each plugin's config extends. For now this
* exposes the one thing a plugin test needs before that lands: a context
* double that satisfies the PluginContext contract without a running server.
*/
import type {
PluginContext,
PluginDisposables,
PluginLogger,
} from "./backend.js";
import type { PluginManifest } from "./manifest.js";
export interface FakeContextOptions {
pluginId?: string;
manifest?: Partial<PluginManifest>;
/** Acting user returned by currentActor and used by asUser. */
actor?: string;
}
export interface FakePluginContext {
ctx: PluginContext;
/** Everything the plugin registered through ctx.disposables. */
disposals: Array<() => void | Promise<void>>;
/** Topics emitted through ctx.events.emit, in order. */
emitted: Array<{ topic: string; payload: unknown }>;
/** Backing store behind ctx.kv. */
kv: Map<string, unknown>;
}
function noopLogger(): PluginLogger {
return {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
};
}
/**
* A context double good enough to call activate() against in a unit test.
* Nothing here is capability-checked: a test asserting a capability gate
* should use the real runtime, not this.
*/
export function createFakeContext(
options: FakeContextOptions = {},
): FakePluginContext {
const pluginId = options.pluginId ?? "test-plugin";
const disposals: Array<() => void | Promise<void>> = [];
const emitted: Array<{ topic: string; payload: unknown }> = [];
const kv = new Map<string, unknown>();
const listeners = new Map<string, Set<(payload: unknown) => void>>();
let actor = options.actor;
const manifest = {
id: pluginId,
name: pluginId,
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities: [],
...options.manifest,
} as PluginManifest;
const disposables: PluginDisposables = {
add: (dispose) => disposals.push(dispose),
};
const ctx: PluginContext = {
pluginId,
manifest,
log: noopLogger(),
events: {
emit: (topic, payload) => {
emitted.push({ topic, payload });
for (const listener of listeners.get(topic) ?? []) listener(payload);
},
on: (topic, listener) => {
let set = listeners.get(topic);
if (!set) {
set = new Set();
listeners.set(topic, set);
}
set.add(listener);
return () => set!.delete(listener);
},
},
kv: {
get: async (key) => (kv.has(key) ? kv.get(key) : null),
set: async (key, value) => {
kv.set(key, value);
},
delete: async (key) => kv.delete(key),
list: async () => [...kv.keys()],
},
registry: {
provide: () => {},
consume: () => undefined,
revoke: () => false,
},
services: {
provide: () => {},
get: () => {
throw new Error("createFakeContext does not implement services.get");
},
},
secrets: {
offer: () => {},
withdraw: () => false,
getShared: async () => null,
},
disposables,
asUser: async (userId, fn) => {
const previous = actor;
actor = userId;
try {
return await fn();
} finally {
actor = previous;
}
},
currentActor: () => actor,
};
return { ctx, disposals, emitted, kv };
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"tsBuildInfoFile": "../../node_modules/.tmp/tsconfig.plugin-sdk.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "nodenext",
"moduleResolution": "nodenext",
"skipLibCheck": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"esModuleInterop": true,
"declaration": true,
"noEmit": false,
"rootDir": "./src",
"outDir": "./dist",
"strict": false,
"resolveJsonModule": true
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts"]
}