release-2.9.0 (#1532)

* fix(macos): skip single-instance lock in Mac App Store builds (#1454)

fix(macos): skip single-instance lock in Mac App Store builds

* Fix/backend optional sharp (#1455)

Fix/backend optional sharp

* feat: issue #1218 (#1218)

https://github.com/Termix-SSH/Support/issues/1218

* feat: Add option to use saved global custom theme under Connection Defaults (#1209)

https://github.com/Termix-SSH/Support/issues/1209

* feat: Add Fleet Sharing functionality to the UI (#1228)

https://github.com/Termix-SSH/Support/issues/1228

* feat: Add a list view to the Docker management page (#1237)

https://github.com/Termix-SSH/Support/issues/1237

* feat: issue #1264 (#1264)

https://github.com/Termix-SSH/Support/issues/1264

* feat: 2FA With FortiToken (#1288)

https://github.com/Termix-SSH/Support/issues/1288

* feat: Allow to disable showing paths to folders (#1274)

https://github.com/Termix-SSH/Support/issues/1274

* feat: Sync Network Graph between Desktop and Remote Server (#1245)

https://github.com/Termix-SSH/Support/issues/1245

* feat: Version Number on offline servers (#1291)

https://github.com/Termix-SSH/Support/issues/1291

* feat: Support OrbStack Docker socket path on macOS / Fix "Docker is not installed" on mac... (#1302)

https://github.com/Termix-SSH/Support/issues/1302

* feat: Sidebar host click should focus existing tab instead of opening a new connection (#1289)

https://github.com/Termix-SSH/Support/issues/1289

* fix: Search in side bar shows overlapping hosts when grouped by tags (#1303)

https://github.com/Termix-SSH/Support/issues/1303

* fix: I can't send a file on its own. (#1304)

https://github.com/Termix-SSH/Support/issues/1304

* fix: sudo password not autofilling (#1248)

https://github.com/Termix-SSH/Support/issues/1248

* fix: node-MainThread (#1300)

https://github.com/Termix-SSH/Support/issues/1300

* fix: Tunnel authentication with shared credentials (#1295)

https://github.com/Termix-SSH/Support/issues/1295

* fix: host key updates not syncing, metrics 404 race on first connect

Host key writes never bumped updatedAt so sync never picked them up.
Also retry the first metrics fetch briefly instead of failing right away.

* chore: increment ver

* feat: add category and icon fields to plugin manifest schema

* ci(deps): bump the github-actions group with 2 updates (#1458)

Bumps the github-actions group with 2 updates: [crowdin/github-action](https://github.com/crowdin/github-action) and [actions/github-script](https://github.com/actions/github-script).


Updates `crowdin/github-action` from 2 to 3
- [Release notes](https://github.com/crowdin/github-action/releases)
- [Commits](https://github.com/crowdin/github-action/compare/v2...v3)

Updates `actions/github-script` from 7 to 9
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v9)

---
updated-dependencies:
- dependency-name: crowdin/github-action
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/github-script
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps-dev): bump the dev-patch-updates group with 13 updates (#1459)

Bumps the dev-patch-updates group with 13 updates:

| Package | From | To |
| --- | --- | --- |
| [@codemirror/commands](https://github.com/codemirror/commands) | `6.11.0` | `6.11.1` |
| [@codemirror/search](https://github.com/codemirror/search) | `6.7.1` | `6.7.2` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.9` | `6.43.12` |
| [@testing-library/dom](https://github.com/testing-library/dom-testing-library) | `10.4.1` | `10.4.2` |
| [@testing-library/react](https://github.com/testing-library/react-testing-library) | `16.3.2` | `16.3.3` |
| [@testing-library/user-event](https://github.com/testing-library/user-event) | `14.6.5` | `14.6.7` |
| [@types/ssh2](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/ssh2) | `1.15.5` | `1.15.6` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.1.0` | `6.1.1` |
| [cytoscape](https://github.com/cytoscape/cytoscape.js) | `3.34.1` | `3.34.3` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.4` | `0.5.7` |
| [i18next](https://github.com/i18next/i18next) | `26.4.0` | `26.4.2` |
| [prettier](https://github.com/prettier/prettier) | `3.9.6` | `3.9.8` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.12` | `17.0.14` |


Updates `@codemirror/commands` from 6.11.0 to 6.11.1
- [Changelog](https://github.com/codemirror/commands/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/commands/commits)

Updates `@codemirror/search` from 6.7.1 to 6.7.2
- [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/search/commits)

Updates `@codemirror/view` from 6.43.9 to 6.43.12
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)

Updates `@testing-library/dom` from 10.4.1 to 10.4.2
- [Release notes](https://github.com/testing-library/dom-testing-library/releases)
- [Changelog](https://github.com/testing-library/dom-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/dom-testing-library/compare/v10.4.1...v10.4.2)

Updates `@testing-library/react` from 16.3.2 to 16.3.3
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v16.3.2...v16.3.3)

Updates `@testing-library/user-event` from 14.6.5 to 14.6.7
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/user-event/compare/v14.6.5...v14.6.7)

Updates `@types/ssh2` from 1.15.5 to 1.15.6
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/ssh2)

Updates `@vitejs/plugin-react` from 6.1.0 to 6.1.1
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.1.1/packages/plugin-react)

Updates `cytoscape` from 3.34.1 to 3.34.3
- [Release notes](https://github.com/cytoscape/cytoscape.js/releases)
- [Commits](https://github.com/cytoscape/cytoscape.js/compare/v3.34.1...v3.34.3)

Updates `eslint-plugin-react-refresh` from 0.5.4 to 0.5.7
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.4...v0.5.7)

Updates `i18next` from 26.4.0 to 26.4.2
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.4.0...v26.4.2)

Updates `prettier` from 3.9.6 to 3.9.8
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.6...3.9.8)

Updates `react-i18next` from 17.0.12 to 17.0.14
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.12...v17.0.14)

---
updated-dependencies:
- dependency-name: "@codemirror/commands"
  dependency-version: 6.11.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/search"
  dependency-version: 6.7.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
  dependency-version: 6.43.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/dom"
  dependency-version: 10.4.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/react"
  dependency-version: 16.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/user-event"
  dependency-version: 14.6.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@types/ssh2"
  dependency-version: 1.15.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: cytoscape
  dependency-version: 3.34.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: eslint-plugin-react-refresh
  dependency-version: 0.5.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: i18next
  dependency-version: 26.4.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: prettier
  dependency-version: 3.9.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: react-i18next
  dependency-version: 17.0.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump the prod-patch-updates group with 6 updates (#1461)

Bumps the prod-patch-updates group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) | `3.14.10` | `3.14.13` |
| [compression](https://github.com/expressjs/compression) | `1.8.1` | `1.8.2` |
| [jose](https://github.com/panva/jose) | `6.2.9` | `6.2.12` |
| [jszip](https://github.com/Stuk/jszip) | `3.10.1` | `3.10.2` |
| [socks](https://github.com/JoshGlazebrook/socks) | `2.8.9` | `2.8.10` |
| [undici](https://github.com/nodejs/undici) | `8.10.0` | `8.10.2` |


Updates `@tanstack/react-virtual` from 3.14.10 to 3.14.13
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.14.13/packages/react-virtual)

Updates `compression` from 1.8.1 to 1.8.2
- [Release notes](https://github.com/expressjs/compression/releases)
- [Changelog](https://github.com/expressjs/compression/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/compression/compare/v1.8.1...v1.8.2)

Updates `jose` from 6.2.9 to 6.2.12
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v6.2.9...v6.2.12)

Updates `jszip` from 3.10.1 to 3.10.2
- [Changelog](https://github.com/Stuk/jszip/blob/main/CHANGES.md)
- [Commits](https://github.com/Stuk/jszip/compare/v3.10.1...v3.10.2)

Updates `socks` from 2.8.9 to 2.8.10
- [Release notes](https://github.com/JoshGlazebrook/socks/releases)
- [Commits](https://github.com/JoshGlazebrook/socks/compare/2.8.9...2.8.10)

Updates `undici` from 8.10.0 to 8.10.2
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.10.0...v8.10.2)

---
updated-dependencies:
- dependency-name: "@tanstack/react-virtual"
  dependency-version: 3.14.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: compression
  dependency-version: 1.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: jose
  dependency-version: 6.2.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: jszip
  dependency-version: 3.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: socks
  dependency-version: 2.8.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: undici
  dependency-version: 8.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat: add plugin worker protocol, bootstrap and plugin_storage table

Worker-side ctx proxy plus the message envelope it talks over, and the
per-plugin key/value table behind ctx.storage with migrations for all
three dialects. Rows cascade with the plugin so uninstalling leaves
nothing behind.

* feat: add allowlisted host view for plugins

An allowlist rather than a list of fields to strip. A blocklist rots:
the moment a new secret-bearing column lands on SSHHost it starts
leaking silently. With an allowlist a new field stays invisible to
plugins until someone adds it here on purpose.

* feat: add plugin loader with manifest validation and crash restart

Validates an unpacked plugin, spawns a worker_threads worker and manages
activate/deactivate/crash-restart with backoff, disabling after three
tries.

The crash counter clears only after a plugin stays up past a stability
window, not on activation. Clearing it on activation let a plugin that
activates cleanly and then dies restart forever, because every attempt
looked like the first.

loader.ts documents what the worker boundary does and does not buy you.
Node's permission model is process-wide, not per-worker, so it cannot be
enabled for plugin workers alone and is not claimed here.

* feat: route internal events through a shared plugin event bus

Formalises three ad-hoc publish paths that already existed:
automation-events.ts, metrics/automation-bridge.ts and the
hostSessionStatus singleton, now the host.session.status topic.

Both invariants the old code relied on are preserved. Publishing stays
fire-and-forget, so a failing subscriber cannot disturb a metrics poll or
a host delete. The automations engine subscribes to the bus at scheduler
start instead of being imported ad hoc, which keeps the edge
one-directional: automations may import repositories, hosts modules must
not import automations.

* feat: add plugin ctx broker with permission gate and audit

Every ctx call a plugin makes is checked against its granted
capabilities, performed by the main thread, then audited. A capability
must be both declared in the manifest and granted in the database, so
widening a plugin's reach always needs a new manifest the user can see,
never just a database row.

Audit attribution is set by the broker, not the plugin. The plugin
supplies only the details, so it cannot forge the actor.

fix: stop plugin workers seeing plaintext credentials in error messages

A failing ctx.ssh.connect handed the worker the raw error from deep in
the SSH stack, which embeds the connect config. The worker received, in
full:

  Authentication failed for root using password <SECRET>

where <SECRET> was the host's real plaintext password. Any plugin with
ssh.exec could read it by catching the error, defeating the whole point
of the handle-based ssh API.

Errors crossing the boundary are now sanitised. Only PluginFacingError
and PluginPermissionError, both authored here, reach a plugin verbatim;
anything else becomes a generic message and the real one goes to the
log. This uses a marker class rather than matching on message text,
because matching would start leaking again the moment an upstream error
happened to contain a familiar phrase.

Covered by credential-isolation.test.ts, which plants a sentinel in
every secret-bearing field and asserts it appears in no message the
worker ever receives.

* feat: dispatch plugin HTTP routes and wire the runtime into startup

ctx.http.route now registers a real router on the /plugin-api/:pluginId
dispatcher, replacing its stub 404. A plugin never touches the Express
req/res: it gets a plain summary and returns a plain object, so sockets
and session cookies stay on this side of the boundary. Authorization and
cookie headers are not forwarded.

Plugins load last in the start-up sequence, after the server is fully
wired, and are terminated before the database on shutdown so none can
outlive it. A plugin that fails to load cannot stop the backend.

fix: correct the nginx plugin-api location regex

The pattern was ^/plugin-api/(/.*)?$, which needs a double slash and so
never matched /plugin-api/<id>/<route>. Every other block in the file
uses ^/prefix(/.*)?$. Left alone, plugin routes would work in dev and
404 only behind Docker.

* fix: update stale SFTP transfer test for single file destination paths

The test still asserted transferToHost was called with the destination
directory "/srv". Commit 7727fa09 (#1304) deliberately changed a single
file transfer to send the full destination path including the filename,
so the correct expectation is "/srv/remote-1.txt". The code was right
and the test was left behind.

* feat: ship the ssh terminal as a first-party in-process plugin

Adds a hardcoded process:transport-owner tier so the terminal can own its
WebSocket server and ssh2 clients, which cannot cross the worker boundary.

* feat: ship docker as a first-party in-process plugin

Moves docker backend and frontend into plugins/docker/, alongside
ssh-terminal. Adds a host editor tab seam, a dashboard card registry,
and a build step to compile plugin backends into dist.

* feat: ship host metrics as a first-party in-process plugin

* fix: harden plugin auth and add per-request identity for ctx calls

Require auth on /plugin-api, gate plugin enable/grant/revoke routes
behind admin.plugins.manage, and thread the calling user through
worker ctx.hosts/ctx.ssh calls instead of always using the install
owner. Also adds the plugin permissions grant/revoke UI.

* feat: ship the AI assistant as a first-party in-process plugin

Moves src/backend/ai and its routes into plugins/ai, mounted through a
small /ai dispatcher instead of its own port. Moves the ai-enabled and
ai-private-endpoints settings routes out of user-settings-routes.ts
into the plugin too, so it has no leftover core dependents.

* fix: remove compiled plugin JS left behind in plugins/ after build

copy-bundled-plugins.cjs compiled and rewrote plugin backend TS in
place, copied it to dist/plugins, but never deleted the .js it had
just emitted next to the .ts sources. Those files shadowed the real
source for vitest/tsx and had import paths rewritten for the dist
layout, so tests failed with "Cannot find module" once a local build
had run. Affects docker, host-metrics and ai, not just this one.

* feat: add a permission-gated backend service registry for plugins

* feat: add a permission-gated UI action registry and action slots for plugins

Moves the terminal toolbar's hardwired AI button into a slot the ssh-terminal
plugin offers and the ai plugin fills, gated on ai.services.use.

* feat: add cross-plugin secret references gated by the provider's RBAC permission

* feat: move proxmox discovery/import/sync into a first-party plugin

* feat: move remote desktop (RDP/VNC/Telnet) into a first-party plugin

Relocates guacamole backend/frontend source and tests into
plugins/remote-desktop, following the ssh-terminal/docker pattern.

* feat: wire remote-desktop plugin into core and rename i18n keys

Repoints core consumers (dispatcher, collab, session-sharing, tab
utils) at the plugin, adds isTabTypeAvailable gating for rdp/vnc/
telnet, widens hostCapability to accept an array, and renames the
guacamole i18n namespace to remoteDesktop.

* feat: move fleets into a first-party plugin

* feat: move automations into a first-party plugin

* feat: remove legacy alert rules, superseded by automations

Alert rules already auto-migrate into automations on boot and the old
engine stands down after. Deletes the rule/firing tables, routes, UI,
and AI tools; keeps notification channels since automations still use
them. Also fixes automations' notify step, which still called the
deleted repository.

* feat: move network topology into a first-party plugin

* feat: move tailscale device discovery and settings into a plugin

Adds plugins/tailscale/ with a devices sidebar tab, admin settings
section, and re-auth dialog. Host Metrics tailscale manager card and
SSH re-auth banner parsing stay in core.

* feat: move workspaces into a first-party plugin

* feat: move web endpoint into a first-party plugin

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

* feat: make each plugin an npm workspace built by a termix-plugin CLI

* feat: give plugins their own tables, migrations and sync entities

* feat: serve plugin http and websockets from one mount instead of ports

* feat: namespace plugin permissions and keep them valid when a plugin is off

* feat: give plugins their own settings and one settings screen

* feat: load plugin frontends at runtime and remove plugin ids from the shell

* fix: mock the ai status fetch in its activate test

* feat: add auth extension points and one ssh connect pipeline

* feat: rebuild workspaces as the reference plugin

* fix: gate second factors after SSO/LDAP behind an admin setting

* feat: convert network-topology to a full plugin (B1)

* feat: move snippets backend and tables into a plugin (B2)

* feat: point core frontend snippets pickers at the plugin (B2)

* feat: add the snippets plugin (B2)

* feat: wire ai, automations, fleets and ssh-terminal to snippets (B2)

* feat: finish the tailscale plugin, including SSH auth (B3)

* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)

* chore: update proxmox and host-metrics locale files for B5

* chore: regenerate drizzle migration journals for B5

* feat: add ctx.hosts create/update to the SDK, host-metrics drops proxmox

* feat: move proxmox node stats and collectors into the proxmox plugin

* feat: drop proxmox host columns, use plugin settings instead

* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)

* feat: convert file manager into a plugin

* feat: convert ssh tunnels into a plugin

* feat: finish the web-endpoint plugin, add ctx.desktop (B8)

* feat: move the terminal fully into the ssh-terminal plugin (B9)

* feat: convert tmux monitoring into a plugin (B10)

* feat: convert serial console into the serial plugin

* feat: move session sharing and collab rooms into a plugin (B12)

* feat: turn session recording into a plugin (B13)

* feat: finish the remote-desktop plugin (B14)

* feat: finish the host-metrics plugin, move host status into core (B16)

* feat: finish the docker plugin (B15)

* feat: finish the automations plugin (B17)

* feat: finish the ai plugin (B18)

* feat: finish the homepage plugin, move service links and canvas out of core (B19)

* feat: convert wake-on-lan into a plugin (B20)

* feat: convert secret-sources into a plugin (B20)

* feat: move totp and passkeys into plugins (C1)

* feat: move sso and ldap logins into plugins (C2)

* feat: move opkssh and warpgate into plugins (C3)

* feat: move step-ca into a plugin (C4)

* feat: move vault ssh signing into a plugin (C5)

* feat: move termix identity into a plugin (C6)

* feat: move acme certificate automation into a plugin (C7)

* fix: finish 2.9.0 plugin moves and get every check green

* chore: lock the plugin boundary and remove 2.9.0 leftovers

* fix: harden the plugin runtime after the 2.9.0 security review

* feat: add plugin packing, signing, pinned trust and tmxplug bundling

* test: prove 2.8 to 2.9.0 upgrade is lossless and fix what it caught

* fix: give bundled plugin backends a real require for CJS deps

* feat: improve UI loading time and jitter

* feat: show credential sharing inline in the sidebar like host sharing

* feat: tailscale add host and copy ip, fix outbound fetch hang and plugin ESM warning

* feat: unify alerts into one plugin, channels, rules and inbox

* feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling

* fix: set blacksmith builder cache-key to stop broken gha cache fallback

* fix: link plugin-sdk workspace before npm ci in docker deps stage

* fix: link every plugin workspace before npm ci in docker deps stage

* fix(host-metrics): exclude image and firmware filesystems (#1469)

* fix(host-metrics): exclude image and firmware filesystems

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(hosts): normalize imported jump host references (#1470)

* fix(hosts): normalize imported jump host references

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(ci): build plugin SDK and repair MySQL settings migration (#1471)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): keep inline rename consistent (#1476)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): keep inline rename consistent

* fix(dashboard): reuse translated host status labels (#1475)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(dashboard): reuse translated host status labels

* docs(api): clarify local and remote version fields (#1474)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* docs(api): clarify local and remote version fields

* fix(file-manager): preserve selected backgrounds on hover (#1473)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): preserve selection feedback on hover

* test(ui): await dialog focus cleanup before teardown

* fix(file-manager): restore Ctrl/Cmd+F file search (#1472)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): focus file search with Ctrl or Cmd F

* fix: keep rounded-full circular in production builds

* fix(file-manager): cancel uploads and stop remote writes (#1477)

* fix(file-manager): cancel uploads and stop remote writes

* test(css): exercise the configured PostCSS plugin chain

* fix(file-manager): apply compact density to directory tree (#1478)

* fix(file-manager): apply compact density to directory tree

* test(css): exercise the configured PostCSS plugin chain

* fix(file-manager): preserve font sizes in compact mode (#1480)

* fix(file-manager): preserve font sizes in compact mode

* test(css): exercise the configured PostCSS plugin chain

* fix(file-manager): prioritize file names in narrow lists (#1483)

* fix(file-manager): prioritize file names in narrow lists

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* test(remote-desktop): always mutate ciphertext in tamper test

* fix(remote-desktop): probe the saved host guacd endpoint (#1481)

* fix(remote-desktop): probe the saved host guacd endpoint

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* fix(homepage): check allowlisted private service reachability (#1479)

* fix(homepage): check allowlisted private service reachability

* test(css): exercise the configured PostCSS plugin chain

* fix(remote-desktop): stop unsolicited clipboard read prompts (#1482)

* fix(remote-desktop): read clipboard only on explicit paste

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* test(remote-desktop): always mutate ciphertext in tamper test

* chore: simplify plugin readmes, changelogs and manifest descriptions

* fix: keep plugins loaded after signing in from the login screen

* fix(file-manager): accept OS file drops over existing rows (#1484)

* fix(file-manager): accept OS file drops over existing rows

* test(file-manager): target the file label in drop tests

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* test(remote-desktop): always mutate ciphertext in tamper test

* fix(file-manager): pipeline SFTP download reads (#1485)

* feat: add plugin sdk build to beta release

* fix: make link-store secret check immune to random base64 matches

* fix(ldap): preserve the directory-provided bind DN (#1486)

* fix: compute beta changes across dev branches and move the beta tag

* chore: untrack plugin sdk ARCHITECTURE.md

* chore: remove references to plugin sdk ARCHITECTURE.md

* feat: merge host editor terminal tabs into one owned by ssh-terminal

* feat: add descriptions to plugin host enable toggles

* fix(file-manager): show download progress in file viewers (#1487)

* fix(electron): signal backend readiness over IPC (#1488)

* fix(split-screen): allow layout creation without randomUUID (#1490)

* fix(split-screen): allow layout creation without randomUUID

* test(credentials): avoid flaky Ed25519 key generation

* fix(sidebar): remeasure visible rows after density changes (#1489)

* fix(sidebar): remeasure visible rows after density changes

* test(credentials): avoid flaky Ed25519 key generation

* feat(split-screen): redesign split screen with free-form panes and pane picker

* feat(sidebar): add host click behavior setting, default to always opening a new tab

* feat(snippets): redesign panel to match the host list

* fix(session-recording): stop session logs panel from refetching in a loop

* feat(snippets): bring back sharing, target hosts and drag reorder

* feat(ssh-terminal): add toolbar position, start state, display and fade host settings

* feat(ssh-terminal): add host setting to turn off password prompt auto-fill

* fix(ai): stop streams getting cut off and keep tool steps across messages

* feat: improve quick connect system using plugin api

* fix: proxmox guest sync, startup snippet sync, drop dead cloudflare ssh transport

* feat: move terminal settings, look and ssh tools into ssh-terminal plugin

* feat: move snippets, keybinding actions and macros out of core into plugins

* feat: store rdp, vnc and telnet logins in a generic per-protocol table

* feat: type the plugin host record, move plugin strings out of core and add lint checks

* feat: replace core homepage widget registry with generic extension points

* feat: plugins only see their own host settings in the browser

* feat: harden plugin api for separate repos, signed updates and generic external login

* fix: keep plugin css below core utilities and version plugin assets by their css

* fix: load and save plugin host settings in the editor and tighten settings row spacing

* feat: redesign user host defaults system

* fix(remote-desktop): release held keys on focus loss (#1498)

* fix(session-sharing): replace local presentation after takeover (#1500)

* fix(file-manager): refresh disk usage with directory listing (#1503)

* fix(ssh-terminal): preserve input order during local echo reconciliation (#1505)

* feat(remote-desktop): expose display zoom controls for RDP (#1514)

* fix(file-manager): restore text selection and copying in Markdown previews (#1518)

* fix(sidebar): keep compact host addresses visible (#1524)

* fix(hosts): preserve plugin settings when reopening the editor (#1499)

* fix(ssh-terminal): close owner sockets when presentations unmount (#1501)

* fix(session-sharing): synchronize shared SSH terminal dimensions (#1502)

* fix(session-sharing): show participant presence in member terminals (#1504)

* fix(ai): sync provider configuration and keys across linked devices (#1506)

* feat(ssh-terminal): add a duplicate tab action (#1510)

* feat(file-manager): allow disabling move-to-trash confirmation (#1511)

* feat(file-manager): default to the remote login directory (#1512)

* feat(homepage): synchronize canvas layouts across devices (#1515)

* fix(shortcuts): prevent accidental double-Shift palette activation (#1519)

* feat(tmux-monitor): add a unified multi-host view (#1520)

* fix(electron): allow explicit quit with active connections (#1522)

* feat: reconnect all disconnected terminal tabs (#1523)

* fix(hosts): preserve recording and sharing settings in the dev editor (#1509)

* fix(database): preserve RDP domain during startup backfill (#1497)

* feat(sidebar): add keyboard type-ahead search for hosts (#1516)

* feat(automations): add per-host maintenance mode and recurring schedules (#1521)

* feat(tmux): add a per-host mouse toggle and preserve remote configuration (#1513)

* fix(session-sharing): use the linked server for desktop meetings and links (#1507)

* fix(tunnels): support TOTP for client tunnels and jump hosts (#1508)

* fix: type errors and lint failures from merged plugin prs

* feat(installer): make the Windows desktop shortcut optional (#1517)

* fix: ship the plugin sdk and serialport in desktop builds

* fix: prefer ipv4 when resolving ssh host names (#1365)

* fix: readable terminal selection with the light theme (#1319)

* fix: build desktop session share links on the linked server (#1350)

* fix: show meeting guests to members and hide the roster from guests (#1353)

* fix: reliable file manager saves with sudo prompt and no base64 guessing (#1335)

* fix: show disk usage for the mount being browsed (#1347)

* fix: load the linux tray icon reliably and log tray failures (#1332)

* fix: cover the gnome close path in the desktop quit test (#1332)

* fix: prompt for jump host TOTP in terminal, file manager and metrics (#1312)

* feat: optional auto reconnect for dropped ssh sessions (#1321)

* feat: tighter compact host rows with inline tags (#1325)

* feat: bring back the alert feed homepage widget

* feat: pick target terminals for snippets again

* feat: share button on connection tabs again

* feat: recent servers list when linking the desktop app

* feat: list the fixed app shortcuts in keyboard shortcuts

* fix: translate hardcoded toasts and drop em dashes from ui text

* fix: translate leftover hardcoded ui text

* fix: toolbar files button opens at the shell directory again

* fix: show the remote desktop note under connection origin again

* chore: openapi docs for host metrics manager routes

* chore: openapi docs for undocumented routes and fix two broken doc blocks

* fix: declare terminal.listSessions use for snippet targets

* revert: drop the alert feed widget, the notification center replaces it

* feat: restore the alert feed homepage widget

* fix: quick connect fills the host address from plugin auth editors like tailscale

* fix: docker container logs use the base background color

* chore: fix dependabot alerts and apply safe npm upgrades

* fix: proxmox stats tab reads its switch from plugin host settings

* fix: count plugin ssh logins toward host status and stop showing reachable as a warning

* fix: hide the built-in source pill in the host editor

* fix: host status shows online when the port answers and only warns on a failed login

* fix: drop the top border on the split layout menu

* fix: host defaults tabs sit where the host editor puts them and skip plugins with nothing to default

* fix: remote desktop auth method labels and notes for vnc and telnet

* fix: add description to the web endpoint enable switch

* fix: crowdin sync and pretranslate for plugin locales, run plugin tests on release

* feat: move telemetry out of core into a usage statistics plugin

* fix: recent activity card no longer cuts off host names early

* fix: remove duplicate top border on split screen menu

* fix: redesign session log player controls, fix tooltip overlap and drop dot separators

* fix: file manager and sftp text showing raw i18n keys

* fix: onboarding rerunning for existing users after upgrading

* fix: fleet detail buttons overflowing the sidebar

* fix: session log player scrolling off screen horizontally

* fix: network graph lag when adding hosts and on every render

* fix: maintenance index name, shared host read only view and badge polling

* fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag

* chore: update release notes

* fix: remote desktop tabs reconnecting on every tab switch

* fix: build plugin sdk before linting in release workflow

* chore: lint, format, and bump version to 2.9.0

* chore: skip crowdin pre-translate in release for now

* chore: sync Crowdin translations for 2.9.0

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Koi <38512047+datlt4@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-01 16:20:16 -05:00
1 parent 49fa488093
commit 3643e7af97
4405 files changed
+1341026 -289722

No files matched your search

+48
View File
@@ -0,0 +1,48 @@
/**
* Who a plugin is acting as right now.
*
* Two ways a plugin gets an actor, and no third:
*
* - A request. A4's HTTP and WebSocket middleware runs the handler inside
* runAsActor with the user core already authenticated, so a route handler
* acts as the person who called it.
* - ctx.asUser(userId, fn), for background work with no request behind it.
* That call is always audited.
*
* Nothing else. A plugin cannot pass a user id to a guarded ctx method and
* have it believed, which is what stops the forged-caller class of bug the
* worker broker had.
*
* AsyncLocalStorage rather than a module variable because plugin work
* interleaves: two requests in flight would otherwise overwrite each other's
* identity between awaits.
*/
import { AsyncLocalStorage } from "node:async_hooks";
interface ActorStore {
userId: string;
/** Where the identity came from, for the audit line. */
source: "request" | "asUser" | "service";
/** The session a request came in on, so core can spare it when revoking. */
sessionId?: string;
}
const storage = new AsyncLocalStorage<ActorStore>();
export function runAsActor<T>(
userId: string,
source: ActorStore["source"],
fn: () => T,
sessionId?: string,
): T {
return storage.run({ userId, source, sessionId }, fn);
}
export function getActorSessionId(): string | undefined {
return storage.getStore()?.sessionId;
}
export function getActor(): string | undefined {
return storage.getStore()?.userId;
}
+284
View File
@@ -0,0 +1,284 @@
// Serves a plugin's built frontend: its bundle, stylesheet and locales.
//
// Unauthenticated on purpose, the same as /assets: import() cannot carry the
// bearer header, and nothing here is secret. It only ever serves files from
// the two directories a plugin ships for the browser, so a request can never
// reach the plugin's backend bundle, migrations or anything outside it, and
// only for a plugin that is running.
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import express, { type Request, type Response } from "express";
import {
DEFAULT_BACKEND_ENTRY,
DEFAULT_FRONTEND_ENTRY,
DEFAULT_LOCALES_DIR,
type PluginManifest,
} from "@termix/plugin-sdk/manifest";
import type { LoadedPlugin } from "./loader.js";
type AssetPlugin = Pick<LoadedPlugin, "id" | "dir" | "manifest"> &
Partial<Pick<LoadedPlugin, "state">>;
export interface PluginFrontendInfo {
frontend: boolean;
css: boolean;
/** Changes whenever the built bundle does, for cache busting. */
assetVersion: string | null;
/** Language codes with a locale file, "en" plus xx_YY names. */
locales: string[];
}
function frontendEntry(
plugin: AssetPlugin,
): { dir: string; file: string } | null {
const manifest = plugin.manifest as PluginManifest;
const entry = path.resolve(
plugin.dir,
manifest.frontend ?? DEFAULT_FRONTEND_ENTRY,
);
if (!isInside(plugin.dir, entry)) return null;
return { dir: path.dirname(entry), file: entry };
}
function localesDir(plugin: AssetPlugin): string | null {
const manifest = plugin.manifest as PluginManifest;
const dir = path.resolve(plugin.dir, manifest.locales ?? DEFAULT_LOCALES_DIR);
return isInside(plugin.dir, dir) ? dir : null;
}
function isInside(root: string, target: string): boolean {
const relative = path.relative(path.resolve(root), path.resolve(target));
return (
relative === "" ||
(!relative.startsWith("..") && !path.isAbsolute(relative))
);
}
function fileExists(file: string): boolean {
try {
return fs.statSync(file).isFile();
} catch {
return false;
}
}
export function describePluginFrontend(
plugin: AssetPlugin | undefined,
): PluginFrontendInfo {
const none: PluginFrontendInfo = {
frontend: false,
css: false,
assetVersion: null,
locales: [],
};
if (!plugin) return none;
const locales: string[] = [];
const dir = localesDir(plugin);
if (dir && fileExists(path.join(dir, "en.json"))) locales.push("en");
if (dir) {
try {
for (const name of fs.readdirSync(path.join(dir, "translated"))) {
if (/^[a-z]{2}_[A-Z]{2}\.json$/.test(name)) {
locales.push(name.slice(0, -".json".length));
}
}
} catch {
// No translations yet.
}
}
const entry = frontendEntry(plugin);
if (!entry || !fileExists(entry.file)) return { ...none, locales };
const cssFile = path.join(entry.dir, "frontend.css");
const css = fileExists(cssFile);
const assetVersion = contentVersion(
css ? [entry.file, cssFile] : [entry.file],
plugin.manifest.version,
);
return {
frontend: true,
css,
assetVersion,
locales: locales.sort(),
};
}
const versionCache = new Map<string, { stamp: string; version: string }>();
/**
* A hash of the bundle's bytes, not its mtime: assets are cached as immutable
* for a year, and a rebuilt .tmxplug has a fixed mtime, so a same-size rebuild
* would otherwise keep serving the stale copy. The CSS is hashed with the JS
* because both are served under the same version.
*/
function contentVersion(files: string[], version: string): string {
// ctime, unlike mtime, cannot be set by tar, so an unpack always changes it.
const stamp = [
version,
...files.map((file) => {
const stat = fs.statSync(file);
return `${file}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`;
}),
].join("|");
const key = files.join("|");
const cached = versionCache.get(key);
if (cached?.stamp === stamp) return cached.version;
const hash = crypto.createHash("sha256").update(version);
for (const file of files) hash.update(fs.readFileSync(file));
const hashed = hash.digest("hex").slice(0, 12);
versionCache.set(key, { stamp, version: hashed });
return hashed;
}
/**
* Maps a request path under /plugin-assets/<id>/ to a file, or null.
*
* `locales/...` reads the plugin's locales directory; anything else reads the
* directory the frontend bundle sits in. Only .js, .css, .json and .map are
* served, and never a path that escapes either directory.
*/
export function resolvePluginAsset(
plugin: AssetPlugin,
requestPath: string,
): string | null {
const clean = requestPath.replace(/^\/+/, "");
if (!clean || clean.includes("\0") || clean.includes("\\")) return null;
if (!/\.(js|css|json|map)$/.test(clean)) return null;
let root: string | null;
let rest: string;
if (clean.startsWith("locales/")) {
root = localesDir(plugin);
rest = clean.slice("locales/".length);
} else {
root = frontendEntry(plugin)?.dir ?? null;
rest = clean;
// The backend bundle sits beside the frontend one in dist/.
if (/^backend\.js(\.map)?$/.test(rest)) return null;
}
if (!root) return null;
const file = path.resolve(root, rest);
if (!isInside(root, file) || !fileExists(file)) return null;
// The text check above cannot see a symlink, so the real paths have to
// agree too: a link inside dist/ must not reach a file outside the plugin.
let real: string;
try {
real = fs.realpathSync(file);
if (!isInside(fs.realpathSync(plugin.dir), real)) return null;
} catch {
return null;
}
if (isServerOnly(plugin, real)) return null;
return file;
}
/** The backend entry and the migrations, wherever the manifest put them. */
function isServerOnly(plugin: AssetPlugin, real: string): boolean {
const manifest = plugin.manifest as PluginManifest;
const backend = path.resolve(
plugin.dir,
manifest.backend ?? DEFAULT_BACKEND_ENTRY,
);
const candidates = [backend, `${backend}.map`];
for (const candidate of candidates) {
try {
if (fs.realpathSync(candidate) === real) return true;
} catch {
// Not there.
}
}
const segments = path
.relative(fs.realpathSync(plugin.dir), real)
.split(path.sep);
return segments.includes("migrations");
}
const CONTENT_TYPES: Record<string, string> = {
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".map": "application/json; charset=utf-8",
};
export function createPluginAssetsRouter(
getPlugin: (id: string) => AssetPlugin | undefined,
): express.Router {
const router = express.Router();
/**
* @openapi
* /plugin-assets/{pluginId}/{path}:
* get:
* summary: Serve a plugin's frontend bundle, stylesheet or locale file
* description: >
* Public, like the app's own /assets, because a module import cannot
* send an auth header and plugin frontends are not secret. Serves only
* the directory holding the plugin's frontend bundle and its locales
* directory. A `v` query parameter (the assetVersion from GET
* /plugins) makes the response cacheable for a year.
* tags:
* - Plugins
* parameters:
* - in: path
* name: pluginId
* required: true
* schema:
* type: string
* - in: path
* name: path
* required: true
* schema:
* type: string
* - in: query
* name: v
* schema:
* type: string
* responses:
* 200:
* description: The file.
* 404:
* description: Unknown or stopped plugin, or no such file.
*/
router.get(
/^\/([a-z][a-z0-9-]{1,39})\/(.+)$/,
(req: Request, res: Response) => {
const params = req.params as unknown as Record<string, string>;
const pluginId = params[0];
const plugin = getPlugin(pluginId);
// A disabled or failed plugin serves nothing, so its bundle cannot be
// loaded around the shell's own enabled check.
const running = !!plugin && (!plugin.state || plugin.state === "active");
const file =
plugin && running ? resolvePluginAsset(plugin, params[1]) : null;
// Loaded as a module from Electron's file:// page and the Vite dev
// server, so any origin may read it. No credentials are involved.
res.setHeader("Access-Control-Allow-Origin", "*");
res.removeHeader("Access-Control-Allow-Credentials");
res.setHeader("X-Content-Type-Options", "nosniff");
if (!file) {
res.status(404).json({ error: "Not found" });
return;
}
res.setHeader(
"Cache-Control",
typeof req.query.v === "string" && req.query.v
? "public, max-age=31536000, immutable"
: "no-cache",
);
res.type(CONTENT_TYPES[path.extname(file)] ?? "application/octet-stream");
res.sendFile(file);
},
);
return router;
}
+24
View File
@@ -0,0 +1,24 @@
/**
* Registrations that clashed with another plugin's: a service provider name,
* a registry key or a permission namespace two plugins both claimed. Each is
* refused or logged where it happens; this keeps the list so an admin view or
* a test can see them all.
*/
export interface RegistrationConflict {
kind: "service" | "registry" | "permission" | "ws" | "id";
pluginId: string;
/** Who already held it, when known. */
heldBy?: string;
name: string;
}
const conflicts: RegistrationConflict[] = [];
export function recordConflict(conflict: RegistrationConflict): void {
conflicts.push(conflict);
}
export function listRegistrationConflicts(): RegistrationConflict[] {
return [...conflicts];
}
+314
View File
@@ -0,0 +1,314 @@
/**
* ctx.credentials: plaintext protocol credentials for a host, for a plugin
* that hands them to a program core does not run (remote desktop gives them
* to guacd). Needs credentials:read, the one critical capability, and every
* call is audited whether it succeeds or not.
*
* The logins themselves stay in core, next to the host, because core's
* sharing model (per-recipient secret snapshots, personal overrides) decides
* which ones a shared recipient may use. A plugin reads only the protocols
* its own manifest declares in contributes.protocols.
*
* registerSecretResolver is the other half: a plugin that resolves
* "<scheme>://..." references in a host's secret fields (secret-sources and
* "op://") instead of handing over a host's own stored secret. Needs
* auth:provide, like ctx.auth's registrations, and the scheme must be listed
* in contributes.auth.secretSchemes.
*
* listSshKeys and createSshKey reach the acting user's own saved keys: the
* public half only (credentials:use), and saving a new key pair
* (credentials:write). Termix Identity publishes and generates keys this way.
*/
import type {
PluginCredentials,
PluginSshKeyCredential,
} from "@termix/plugin-sdk/backend";
import ssh2 from "ssh2";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import { assertCapability, capabilityRefused } from "./permissions.js";
import { getActor } from "./actor.js";
import type { DisposableBag } from "./disposables.js";
import { registerSecretResolver } from "../hosts/connect/secret-resolver-registry.js";
import { findHostProtocol } from "../hosts/protocol-auth/registry.js";
type AuditFn = (
action: string,
details: string,
outcome: { success: boolean; errorMessage?: string },
) => Promise<void>;
interface Deps {
manifest: PluginManifest;
bag: DisposableBag;
audit: AuditFn;
}
type HostRow = Record<string, unknown>;
function str(value: unknown): string {
return typeof value === "string" ? value : "";
}
function parseJumpHosts(value: unknown): Array<{ hostId: number }> {
let parsed: unknown = value;
if (typeof value === "string") {
try {
parsed = JSON.parse(value);
} catch {
return [];
}
}
if (!Array.isArray(parsed)) return [];
return parsed
.map((hop) => Number((hop as { hostId?: unknown })?.hostId))
.filter((id) => Number.isInteger(id) && id > 0)
.map((hostId) => ({ hostId }));
}
function derivePublicKey(
privateKey: string | null | undefined,
passphrase: string | null | undefined,
): string | null {
if (!privateKey) return null;
try {
const parsed = ssh2.utils.parseKey(privateKey, passphrase || undefined);
const key = Array.isArray(parsed) ? parsed[0] : parsed;
if (!key || key instanceof Error) return null;
return `${key.type} ${key.getPublicSSH().toString("base64")}`;
} catch {
return null;
}
}
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function requireCorePermission(
userId: string,
permission: string,
): Promise<void> {
const { PermissionManager } = await import("../utils/permission-manager.js");
if (
!(await PermissionManager.getInstance().hasPermission(userId, permission))
) {
throw new Error(`The acting user lacks ${permission}`);
}
}
function actingUser(): string {
const userId = getActor();
if (!userId) {
throw new Error(
"ctx.credentials needs an acting user: call it inside a request or ctx.asUser",
);
}
return userId;
}
export function createPluginCredentials({
manifest,
bag,
audit,
}: Deps): PluginCredentials {
const pluginId = manifest.id;
const declared = manifest.capabilities;
return {
listSshKeys: async () => {
try {
await assertCapability(pluginId, "credentials:use", declared);
const userId = actingUser();
await requireCorePermission(userId, "credentials.view");
const { createCurrentCredentialRepository } =
await import("../database/repositories/factory.js");
const rows =
await createCurrentCredentialRepository().listDecryptedByUserId(
userId,
);
const keys: PluginSshKeyCredential[] = rows
.filter((row) => row.authType === "key")
.map((row) => ({
id: row.id,
name: row.name,
username: row.username ?? null,
publicKey:
(typeof row.publicKey === "string" && row.publicKey.trim()) ||
derivePublicKey(row.privateKey || row.key, row.keyPassword),
}));
await audit("credentials_list_keys", `${keys.length} keys`, {
success: true,
});
return keys;
} catch (error) {
await audit("credentials_list_keys", "saved SSH keys", {
success: false,
errorMessage: errorText(error),
});
throw error;
}
},
createSshKey: async (input) => {
const details = `SSH key "${input?.name ?? ""}"`;
try {
await assertCapability(pluginId, "credentials:write", declared);
const userId = actingUser();
await requireCorePermission(userId, "credentials.create");
if (!input?.name || !input.privateKey || !input.publicKey) {
throw new Error("name, privateKey and publicKey are required");
}
const { createCurrentCredentialRepository } =
await import("../database/repositories/factory.js");
const created =
await createCurrentCredentialRepository().createEncryptedForUser(
userId,
{
userId,
name: input.name,
description: input.description ?? null,
folder: null,
tags: "",
authType: "key",
username: input.username || null,
password: null,
key: input.privateKey,
privateKey: input.privateKey,
publicKey: input.publicKey,
keyPassword: null,
keyType: null,
detectedKeyType: input.keyType,
usageCount: 0,
lastUsed: null,
},
);
await audit("credentials_create_key", details, { success: true });
return { id: created.id };
} catch (error) {
await audit("credentials_create_key", details, {
success: false,
errorMessage: errorText(error),
});
throw error;
}
},
registerSecretResolver: (scheme, resolve) => {
if (!declared.includes("auth:provide")) {
throw capabilityRefused(pluginId, "auth:provide");
}
if (!manifest.contributes?.auth?.secretSchemes?.includes(scheme)) {
throw new Error(
`Plugin ${pluginId} cannot register secret scheme "${scheme}": it is not listed in contributes.auth.secretSchemes`,
);
}
const dispose = registerSecretResolver(
scheme,
{ pluginId, pluginName: manifest.name },
async (userId, reference) => {
await assertCapability(pluginId, "auth:provide", declared);
const details = `secret reference for ${userId}`;
try {
const value = await resolve(userId, reference);
await audit("secret_resolve", details, { success: true });
return value;
} catch (error) {
await audit("secret_resolve", details, {
success: false,
errorMessage:
error instanceof Error ? error.message : String(error),
});
throw error;
}
},
);
bag.add(dispose, `secret scheme "${scheme}"`);
},
resolveHostProtocol: async (hostId, protocol) => {
const details = `${protocol} credentials for host ${hostId}`;
try {
await assertCapability(pluginId, "credentials:read", declared);
// Another plugin that declared the same id first owns it.
const own =
manifest.contributes?.protocols?.some(
(entry) => entry.id === protocol,
) && findHostProtocol(protocol)?.pluginId === pluginId;
if (!own) {
throw new Error(
`Plugin ${pluginId} does not declare protocol "${String(protocol)}" in contributes.protocols`,
);
}
} catch (error) {
await audit("credentials_read", details, {
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
throw error;
}
const userId = getActor();
if (!userId) {
throw new Error(
"ctx.credentials needs an acting user: call it inside a request or ctx.asUser",
);
}
const { createCurrentHostResolutionRepository } =
await import("../database/repositories/factory.js");
const repository = createCurrentHostResolutionRepository();
// Shared hosts carry fields encrypted under the owner's key.
const ownerId = await repository.findHostOwnerId(hostId);
const host = ownerId
? ((await repository.findHostById(hostId, ownerId)) as HostRow | null)
: null;
if (!host) {
await audit("credentials_read", details, {
success: false,
errorMessage: "Host not found",
});
return null;
}
const shared = host.userId !== userId;
if (shared) {
const { PermissionManager } =
await import("../utils/permission-manager.js");
const access = await PermissionManager.getInstance().canAccessHost(
userId,
hostId,
"connect",
);
if (!access.hasAccess) {
await audit("credentials_read", details, {
success: false,
errorMessage: "No connect access",
});
return null;
}
}
const declaredProtocol = findHostProtocol(protocol)!;
const { resolveOwnerProtocolLogin, resolveRecipientProtocolLogin } =
await import("../hosts/protocol-auth/protocol-auth.js");
const auth = shared
? await resolveRecipientProtocolLogin(host, userId, declaredProtocol)
: await resolveOwnerProtocolLogin(host, declaredProtocol);
await audit("credentials_read", details, { success: true });
return {
host: {
id: hostId,
name: (host.name as string | null) ?? null,
ip: str(host.ip),
port: Number(host.port) || 0,
ownerUserId: host.userId as string,
jumpHosts: parseJumpHosts(host.jumpHosts),
},
shared,
auth,
};
},
};
}
+828
View File
@@ -0,0 +1,828 @@
/**
* ctx.hosts: the hosts a plugin's acting user can see, and the sharing
* operations that need core's RBAC and secret-snapshot machinery.
*
* list/get/checkAccess need hosts:read. share and the share-target pickers
* need hosts:write, because granting access to a host is a write on that
* host even though the plugin owns neither the host nor the grant.
*/
import type {
PluginHosts,
PluginHostSummary,
PluginHostRecord,
PluginHostCreateInput,
PluginHostUpdateInput,
PluginHostAccess,
PluginHostShareLevel,
PluginHostShareResult,
PluginShareTarget,
PluginShareableUser,
PluginShareableRole,
PluginHostJumpHost,
} from "@termix/plugin-sdk/backend";
import { PLUGIN_HOST_INPUT_KEYS } from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import { assertCapability, capabilityRefused } from "./permissions.js";
import { hostSessionStatus } from "../hosts/host-session-status.js";
import { getActor } from "./actor.js";
import { hostStatusService } from "../hosts/status/host-status-service.js";
import { parseSshOptions } from "../hosts/ssh-options.js";
import { hostTerminalExport } from "../database/routes/host-normalizers.js";
import type { DisposableBag } from "./disposables.js";
import { pluginLogger } from "../utils/logger.js";
type AuditFn = (
action: string,
details: string,
outcome: { success: boolean; errorMessage?: string },
) => Promise<void>;
interface Deps {
manifest: PluginManifest;
/** Where registerPort entries go, so deactivate removes them. */
bag?: DisposableBag;
audit: AuditFn;
}
function toSummary(host: {
id: number;
userId: string;
name: string | null;
ip: string;
port: number;
username: string;
tags: string | null;
folder: string | null;
authType: string;
}): PluginHostSummary {
return {
id: host.id,
userId: host.userId,
name: host.name,
ip: host.ip,
port: host.port,
username: host.username,
tags: host.tags,
folder: host.folder,
authType: host.authType,
};
}
/**
* Host fields that carry secret material. Stripped from anything ctx.hosts or
* ctx.ssh hands a plugin, unless the plugin holds credentials:read.
*/
export const HOST_SECRET_FIELDS = [
"password",
"key",
"keyPassword",
"privateKey",
"passphrase",
"sudoPassword",
"socks5Password",
] as const;
/** A copy of a host with every secret field removed, nested ones included. */
export function redactHostSecrets<T extends Record<string, unknown>>(
host: T,
): T {
const copy: Record<string, unknown> = { ...host };
for (const field of HOST_SECRET_FIELDS) delete copy[field];
const terminalConfig = copy.terminalConfig;
if (terminalConfig && typeof terminalConfig === "object") {
const { sudoPassword: _sudo, ...rest } = terminalConfig as Record<
string,
unknown
>;
copy.terminalConfig = rest;
}
if (Array.isArray(copy.socks5ProxyChain)) {
copy.socks5ProxyChain = copy.socks5ProxyChain.map((hop) =>
hop && typeof hop === "object"
? redactHostSecrets(hop as Record<string, unknown>)
: hop,
);
}
return copy as T;
}
function asNumber(value: unknown): number | null {
if (value === null || value === undefined || value === "") return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function asFlag(value: unknown, fallback: boolean): boolean {
if (value === null || value === undefined) return fallback;
return value === true || value === 1 || value === "1" || value === "true";
}
function parseJumpHosts(value: unknown): PluginHostJumpHost[] {
let list = value;
if (typeof list === "string") {
try {
list = JSON.parse(list);
} catch {
return [];
}
}
if (!Array.isArray(list)) return [];
return list
.map((entry) => asNumber((entry as { hostId?: unknown })?.hostId))
.filter((hostId): hostId is number => hostId !== null)
.map((hostId) => ({ hostId }));
}
/**
* A decrypted ssh_data row as PluginHostRecord. Built field by field: a
* column reaches plugins only once the SDK types it, and no secret column is
* ever copied.
*/
export function toRecord(
host: Record<string, unknown>,
pluginSettings: Record<string, unknown>,
): PluginHostRecord {
const id = Number(host.id);
const origin = host.connectionOrigin;
return {
id,
userId: String(host.userId),
syncId: (host.syncId as string | null) ?? null,
name: (host.name as string | null) ?? null,
ip: String(host.ip ?? ""),
port: asNumber(host.port) ?? 22,
username: String(host.username ?? ""),
authType: String(host.authType ?? ""),
credentialId: asNumber(host.credentialId),
overrideCredentialUsername: asFlag(host.overrideCredentialUsername, false),
connectionType: (host.connectionType as string | null) || "ssh",
tags: (host.tags as string | null) ?? null,
folder: (host.folder as string | null) ?? null,
parentHostId: asNumber(host.parentHostId),
pin: asFlag(host.pin, false),
notes: (host.notes as string | null) ?? null,
jumpHosts: parseJumpHosts(host.jumpHosts),
enableSsh: asFlag(host.enableSsh, true),
sshPort: asNumber(host.sshPort),
statusCheckEnabled: asFlag(host.statusCheckEnabled, true),
statusCheckInterval: asNumber(host.statusCheckInterval),
connectionOrigin: origin === "local" || origin === "remote" ? origin : null,
sshOptions: hostTerminalExport(host).sshOptions,
pluginSettings,
status: Number.isInteger(id) ? hostStatusService.get(id) : null,
localOnly: asFlag(host.localOnly, false),
createdAt: (host.createdAt as string | null) ?? null,
updatedAt: (host.updatedAt as string | null) ?? null,
};
}
/** Thrown when a plugin's host write names a field the SDK does not take. */
export class PluginHostInputError extends Error {
constructor(message: string) {
super(message);
this.name = "PluginHostInputError";
}
}
const INPUT_KEYS = new Set<string>(PLUGIN_HOST_INPUT_KEYS);
// Stops compiling when PluginHostCreateInput gains a key the list misses.
const inputKeysComplete: [
Exclude<keyof PluginHostCreateInput, (typeof PLUGIN_HOST_INPUT_KEYS)[number]>,
] extends [never]
? true
: never = true;
void inputKeysComplete;
/**
* The ssh_data columns for a create or update, plus the host settings to
* write afterwards. Refuses any key PluginHostCreateInput does not list, so a
* field that moved into a plugin cannot be written into nothing.
*/
export function toHostWrite(input: PluginHostUpdateInput): {
row: Record<string, unknown>;
settings: Record<string, Record<string, unknown>>;
} {
const unknown = Object.keys(input ?? {}).filter(
(key) => !INPUT_KEYS.has(key),
);
if (unknown.length > 0) {
throw new PluginHostInputError(
`ctx.hosts does not take ${unknown.join(", ")}; host settings go in pluginSettings, keyed by the plugin that declares them`,
);
}
const row: Record<string, unknown> = {};
const set = (key: string, value: unknown) => {
if (value !== undefined) row[key] = value;
};
set("name", input.name);
set("ip", input.ip);
set("port", input.port);
set("username", input.username);
set("authType", input.authType);
set("credentialId", input.credentialId);
set("overrideCredentialUsername", input.overrideCredentialUsername);
set("connectionType", input.connectionType);
set(
"tags",
Array.isArray(input.tags)
? input.tags
.map((tag) => String(tag).trim())
.filter(Boolean)
.join(",")
: input.tags,
);
set("folder", input.folder);
set("pin", input.pin);
set("notes", input.notes);
set(
"jumpHosts",
input.jumpHosts === undefined
? undefined
: input.jumpHosts === null
? null
: JSON.stringify(parseJumpHosts(input.jumpHosts)),
);
set("enableSsh", input.enableSsh);
set("sshPort", input.sshPort);
set("statusCheckEnabled", input.statusCheckEnabled);
set("statusCheckInterval", input.statusCheckInterval);
set(
"forceKeyboardInteractive",
input.forceKeyboardInteractive === undefined
? undefined
: input.forceKeyboardInteractive
? "true"
: "false",
);
set(
"sshOptions",
input.sshOptions === undefined
? undefined
: input.sshOptions === null
? null
: JSON.stringify(parseSshOptions(input.sshOptions)),
);
const settings: Record<string, Record<string, unknown>> = {};
if (input.pluginSettings !== undefined && input.pluginSettings !== null) {
if (
typeof input.pluginSettings !== "object" ||
Array.isArray(input.pluginSettings)
) {
throw new PluginHostInputError(
"pluginSettings must map plugin ids to their host settings",
);
}
for (const [pluginId, values] of Object.entries(input.pluginSettings)) {
if (!values || typeof values !== "object" || Array.isArray(values)) {
throw new PluginHostInputError(
`pluginSettings.${pluginId} must be an object`,
);
}
settings[pluginId] = values;
}
}
return { row, settings };
}
function actingUser(): string {
const actor = getActor();
if (!actor) {
throw new Error(
"ctx.hosts needs an acting user: call it inside a request or ctx.asUser",
);
}
return actor;
}
export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
const pluginId = manifest.id;
const declared = manifest.capabilities;
const requireRead = () => assertCapability(pluginId, "hosts:read", declared);
const requireReadSync = () => {
if (!declared.includes("hosts:read")) {
throw capabilityRefused(pluginId, "hosts:read");
}
};
const requireWrite = () =>
assertCapability(pluginId, "hosts:write", declared);
// With an actor, a host the actor cannot reach is out of bounds. With none,
// it is the plugin's own background work (a poller, a guacd session).
const actorMayReach = async (hostId: number): Promise<boolean> => {
const actor = getActor();
if (!actor) return true;
const { PermissionManager } =
await import("../utils/permission-manager.js");
const access = await PermissionManager.getInstance().canAccessHost(
actor,
hostId,
"connect",
);
return access.hasAccess;
};
// The calling plugin's own host settings, one query for the whole list.
const toRecords = async (
rows: Record<string, unknown>[],
): Promise<PluginHostRecord[]> => {
const { loadHostPluginSettings } =
await import("../database/routes/host-plugin-settings.js");
const settings = await loadHostPluginSettings(
rows.map((row) => Number(row.id)),
);
return rows.map((row) =>
toRecord(row, settings.get(Number(row.id))?.[pluginId] ?? {}),
);
};
const checkSettings = async (
settings: Record<string, Record<string, unknown>>,
) => {
if (Object.keys(settings).length === 0) return [];
const { checkHostPluginSettingsInput } =
await import("../database/routes/host-plugin-settings.js");
const { writes, skipped, errors } = checkHostPluginSettingsInput(settings);
if (errors.length > 0) throw new PluginHostInputError(errors.join("; "));
if (skipped.length > 0) {
pluginLogger.warn(
"Host settings for plugins that are not running were skipped",
{
operation: "plugin_hosts_write",
pluginId,
skipped,
},
);
}
return writes;
};
const writeSettings = async (
hostId: number,
writes: Awaited<ReturnType<typeof checkSettings>>,
) => {
if (writes.length === 0) return;
const { writeHostPluginSettings } =
await import("../database/routes/host-plugin-settings.js");
for (const { manifest: owner, values } of writes) {
await writeHostPluginSettings(owner, hostId, values);
}
};
/** Host settings a plugin writes are the host's own, not its defaults. */
const markSettingsOwn = async (
hostId: number,
writes: Awaited<ReturnType<typeof checkSettings>>,
) => {
if (writes.length === 0) return;
const { changeHostOverrides } =
await import("../hosts/defaults/overrides.js");
await changeHostOverrides([hostId], {
own: writes.flatMap(({ manifest: owner, values }) =>
Object.keys(values).map((key): [string, string] => [owner.id, key]),
),
});
};
return {
list: async () => {
await requireRead();
const userId = actingUser();
const {
createCurrentHostResolutionRepository,
createCurrentRoleRepository,
createCurrentRbacAccessRepository,
} = await import("../database/repositories/factory.js");
const repository = createCurrentHostResolutionRepository();
const owned = await repository.findHostsByUserId(userId);
const roleIds =
await createCurrentRoleRepository().listUserRoleIds(userId);
const grants =
await createCurrentRbacAccessRepository().listVisibleHostAccessEntries(
userId,
roleIds,
);
const sharedRows = await repository.listHostRowsForAccessList(
userId,
grants,
);
const byId = new Map<number, PluginHostSummary>();
for (const host of owned) byId.set(host.id, toSummary(host));
for (const host of sharedRows) {
if (!byId.has(host.id)) byId.set(host.id, toSummary(host));
}
return [...byId.values()];
},
get: async (hostId) => {
await requireRead();
const userId = actingUser();
const { PermissionManager } =
await import("../utils/permission-manager.js");
const access = await PermissionManager.getInstance().canAccessHost(
userId,
hostId,
"connect",
);
if (!access.hasAccess) return null;
const { createCurrentHostResolutionRepository } =
await import("../database/repositories/factory.js");
const ownerId =
(await createCurrentHostResolutionRepository().findHostOwnerId(
hostId,
)) ?? userId;
const host = await createCurrentHostResolutionRepository().findHostById(
hostId,
ownerId,
);
return host ? toSummary(host) : null;
},
checkAccess: async (
hostId: number,
level: PluginHostShareLevel,
): Promise<PluginHostAccess> => {
await requireRead();
const userId = actingUser();
const { PermissionManager } =
await import("../utils/permission-manager.js");
const access = await PermissionManager.getInstance().canAccessHost(
userId,
hostId,
level,
);
return {
hasAccess: access.hasAccess,
isOwner: access.isOwner,
isShared: access.isShared,
permissionLevel: access.permissionLevel as
PluginHostShareLevel | undefined,
expiresAt: access.expiresAt,
};
},
create: async (host: PluginHostCreateInput): Promise<PluginHostRecord> => {
try {
await requireWrite();
} catch (error) {
await audit("hosts_create", host?.name ?? host?.ip ?? "host", {
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
throw error;
}
const userId = actingUser();
const { row, settings } = toHostWrite(host);
const writes = await checkSettings(settings);
const { createCurrentHostRepository } =
await import("../database/repositories/factory.js");
const { applyHostDefaultsToWrite, applyDefaultsAfterHostWrite } =
await import("../hosts/defaults/index.js");
const columns: Record<string, unknown> = { ...row, userId };
columns.defaultOverrides = JSON.stringify(
await applyHostDefaultsToWrite({
ownerId: userId,
hostId: null,
columns,
body: host as unknown as Record<string, unknown>,
}),
);
const created =
await createCurrentHostRepository().createEncryptedForUser(
userId,
columns,
);
await applyDefaultsAfterHostWrite(created.id);
await writeSettings(created.id, writes);
await markSettingsOwn(created.id, writes);
await audit("hosts_create", `host ${created.id}`, { success: true });
return (
await toRecords([created as unknown as Record<string, unknown>])
)[0];
},
update: async (
hostId: number,
patch: PluginHostUpdateInput,
): Promise<PluginHostRecord | null> => {
try {
await requireWrite();
} catch (error) {
await audit("hosts_update", `host ${hostId}`, {
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
throw error;
}
const userId = actingUser();
const { row, settings } = toHostWrite(patch);
const writes = await checkSettings(settings);
const { createCurrentHostRepository } =
await import("../database/repositories/factory.js");
const { applyHostDefaultsToWrite, applyDefaultsAfterHostWrite } =
await import("../hosts/defaults/index.js");
const { createCurrentHostDefaultsRepository } =
await import("../database/repositories/factory.js");
const stored = (
await createCurrentHostDefaultsRepository().listHosts({
hostIds: [hostId],
})
)[0];
const columns: Record<string, unknown> = { ...row };
if (stored && stored.userId === userId) {
columns.defaultOverrides = JSON.stringify(
await applyHostDefaultsToWrite({
ownerId: userId,
hostId,
columns,
body: patch as unknown as Record<string, unknown>,
stored,
}),
);
}
const updated =
await createCurrentHostRepository().updateEncryptedForUser(
userId,
hostId,
columns,
);
if (updated) {
await writeSettings(hostId, writes);
await markSettingsOwn(hostId, writes);
await applyDefaultsAfterHostWrite(hostId, {
moved:
!!stored &&
((columns.folder !== undefined &&
(columns.folder ?? null) !== (stored.folder ?? null)) ||
(columns.parentHostId !== undefined &&
(columns.parentHostId ?? null) !==
(stored.parentHostId ?? null))),
ownerId: userId,
});
}
await audit("hosts_update", `host ${hostId}`, {
success: updated !== null,
});
return updated
? (await toRecords([updated as unknown as Record<string, unknown>]))[0]
: null;
},
delete: async (hostId: number): Promise<boolean> => {
try {
await requireWrite();
const userId = actingUser();
const { PermissionManager } =
await import("../utils/permission-manager.js");
if (
!(await PermissionManager.getInstance().hasPermission(
userId,
"hosts.delete",
))
) {
throw new Error("The acting user may not delete hosts");
}
const { deleteOwnedHost } = await import("../hosts/delete-host.js");
const deleted = await deleteOwnedHost(userId, hostId);
await audit("hosts_delete", `host ${hostId}`, {
success: deleted !== null,
});
return deleted !== null;
} catch (error) {
await audit("hosts_delete", `host ${hostId}`, {
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
throw error;
}
},
listOwned: async (): Promise<PluginHostRecord[]> => {
await requireWrite();
const userId = actingUser();
const { createCurrentHostRepository } =
await import("../database/repositories/factory.js");
const rows =
await createCurrentHostRepository().listDecryptedByUserId(userId);
await audit("hosts_list_owned", `${rows.length} host(s)`, {
success: true,
});
return toRecords(rows as unknown as Record<string, unknown>[]);
},
share: async (
hostId: number,
targets: PluginShareTarget[],
permissionLevel: PluginHostShareLevel,
durationHours?: number,
): Promise<PluginHostShareResult> => {
try {
await requireWrite();
} catch (error) {
await audit("hosts_share", `host ${hostId}`, {
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
throw error;
}
const userId = actingUser();
const { PermissionManager } =
await import("../utils/permission-manager.js");
const access = await PermissionManager.getInstance().canAccessHost(
userId,
hostId,
"manage",
);
if (!access.hasAccess) {
const result: PluginHostShareResult = {
hostId,
shared: false,
reason: "forbidden",
};
await audit("hosts_share", `host ${hostId}`, {
success: false,
errorMessage: "forbidden",
});
return result;
}
const {
createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository,
} = await import("../database/repositories/factory.js");
const ownerId =
(await createCurrentHostResolutionRepository().findHostOwnerId(
hostId,
)) ?? userId;
if (targets.some((t) => t.type === "user" && t.id === ownerId)) {
await audit("hosts_share", `host ${hostId}`, {
success: false,
errorMessage: "owner",
});
return { hostId, shared: false, reason: "owner" };
}
const expiresAt =
durationHours && durationHours > 0
? new Date(Date.now() + durationHours * 60 * 60 * 1000).toISOString()
: null;
const rbacAccessRepository = createCurrentRbacAccessRepository();
const { SharedHostSecretsManager } =
await import("../utils/shared-host-secrets-manager.js");
const secretsManager = SharedHostSecretsManager.getInstance();
for (const target of targets) {
const grant = await rbacAccessRepository.upsertHostAccess({
hostId,
grantedBy: userId,
permissionLevel,
expiresAt,
...(target.type === "user"
? { targetType: "user" as const, targetUserId: target.id as string }
: {
targetType: "role" as const,
targetRoleId: target.id as number,
}),
});
try {
if (target.type === "user") {
await secretsManager.snapshotForUser(
grant.id,
hostId,
target.id as string,
ownerId,
);
} else {
await secretsManager.snapshotForRole(
grant.id,
hostId,
target.id as number,
ownerId,
);
}
} catch {
// A snapshot failure never blocks the grant: the recipient can
// still be prompted, or the owner can retry sharing later.
}
}
await audit("hosts_share", `host ${hostId}`, { success: true });
return { hostId, shared: true };
},
listUsers: async (): Promise<PluginShareableUser[]> => {
try {
await requireWrite();
} catch (error) {
await audit("hosts_list_users", "share target picker", {
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
throw error;
}
const { createCurrentUserRepository } =
await import("../database/repositories/factory.js");
const users = await createCurrentUserRepository().listAll();
await audit("hosts_list_users", "share target picker", {
success: true,
});
return users.map((u) => ({ id: u.id, username: u.username }));
},
listRoles: async (): Promise<PluginShareableRole[]> => {
try {
await requireWrite();
} catch (error) {
await audit("hosts_list_roles", "share target picker", {
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
throw error;
}
const { createCurrentRoleRepository } =
await import("../database/repositories/factory.js");
const roles = await createCurrentRoleRepository().listRoles();
await audit("hosts_list_roles", "share target picker", {
success: true,
});
return roles
.filter((r) => !r.isSystem)
.map((r) => ({ id: r.id, name: r.name, displayName: r.displayName }));
},
// Synchronous so a transport can call it from an ssh2 "ready" handler, so
// only the declaration is checked up front, like ctx.http.router. The
// session only counts once the actor's access to the host is confirmed.
trackSession: (hostId: number) => {
if (!declared.includes("hosts:read")) {
throw capabilityRefused(pluginId, "hosts:read");
}
let release: (() => void) | null = null;
let stopped = false;
void actorMayReach(hostId)
.then((allowed) => {
if (allowed && !stopped) release = hostSessionStatus.register(hostId);
})
.catch(() => {});
return () => {
stopped = true;
release?.();
release = null;
};
},
recordActivity: async (hostId, type, hostName) => {
await requireRead();
const userId = actingUser();
if (!(await actorMayReach(hostId))) return;
const { recordRecentActivity } =
await import("../services/recent-activity.js");
await recordRecentActivity(userId, { type, hostId, hostName });
},
// Not audited: pollers call these on every sample.
status: {
get: async (hostId) => {
await requireRead();
if (!(await actorMayReach(hostId))) return null;
return hostStatusService.get(hostId);
},
check: async (hostId) => {
await requireRead();
if (!(await actorMayReach(hostId))) return null;
return hostStatusService.check(hostId);
},
// Synchronous for the same reason as trackSession.
reportLogin: (hostId, outcome) => {
requireReadSync();
void actorMayReach(hostId)
.then((allowed) => {
if (allowed) hostStatusService.reportLogin(hostId, outcome);
})
.catch(() => {});
},
registerPort: (connectionType, resolve) => {
requireReadSync();
const unregister = hostStatusService.registerPort(
connectionType,
resolve,
);
const drop = bag?.add(unregister, `status port for ${connectionType}`);
return () => {
drop?.();
unregister();
};
},
},
};
}
+194
View File
@@ -0,0 +1,194 @@
/**
* ctx.notify and ctx.fetch: alerts through whichever plugin serves as the
* hub, and core's guarded outbound HTTP. Core resolves who an alert is for,
* so a plugin names an audience and never a list of users it could not see.
*/
import type {
PluginFetch,
PluginNotification,
PluginNotificationAudience,
PluginNotify,
PluginNotifyResult,
} from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import { assertCapability, capabilityRefused } from "./permissions.js";
import { getActor } from "./actor.js";
import { activeNotifyHub, registerNotifyHub } from "./notify-hub.js";
type AuditFn = (
action: string,
details: string,
outcome: { success: boolean; errorMessage?: string },
) => Promise<void>;
interface Deps {
manifest: PluginManifest;
audit: AuditFn;
/** Where serve's revoke goes, so the hub goes away with the plugin. */
bag?: { add: (dispose: () => void, label: string) => void };
}
const EMPTY_RESULT: PluginNotifyResult = {
recipients: 0,
delivered: 0,
failures: [],
};
function actingUser(): string {
const actor = getActor();
if (!actor) {
throw new Error(
"ctx.notify needs an acting user: call it inside a request or ctx.asUser",
);
}
return actor;
}
/** Runs fn behind the capability check, with an audit line either way. */
async function audited<T>(
deps: Deps,
capability: string,
action: string,
details: string,
fn: () => Promise<T>,
): Promise<T> {
try {
await assertCapability(
deps.manifest.id,
capability,
deps.manifest.capabilities,
);
const result = await fn();
await deps.audit(action, details, { success: true });
return result;
} catch (error) {
await deps.audit(action, details, {
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
/** The user ids an audience names right now. */
export async function resolveAudience(
audience: PluginNotificationAudience | undefined,
): Promise<string[]> {
if (!audience) return [actingUser()];
const { createCurrentUserRepository } =
await import("../database/repositories/factory.js");
const users = createCurrentUserRepository();
if (audience === "admins") {
return (await users.listAll())
.filter((user) => user.isAdmin)
.map((user) => user.id);
}
if ("userId" in audience) {
return (await users.findById(audience.userId)) ? [audience.userId] : [];
}
const { PermissionManager } = await import("../utils/permission-manager.js");
const permissions = PermissionManager.getInstance();
const recipients: string[] = [];
for (const user of await users.listAll()) {
if (await permissions.hasPermission(user.id, audience.permission)) {
recipients.push(user.id);
}
}
return recipients;
}
function describeAudience(notification: PluginNotification): string {
const { audience } = notification;
if (!audience) return "acting user";
if (audience === "admins") return "admins";
if ("userId" in audience) return `user ${audience.userId}`;
return `permission ${audience.permission}`;
}
export function createPluginNotify(deps: Deps): PluginNotify {
const pluginId = deps.manifest.id;
return {
channels: () =>
audited(
deps,
"notify:send",
"notify_channels",
"listed channels",
async () => {
const userId = actingUser();
const hub = await activeNotifyHub();
return hub ? hub.channels(userId) : [];
},
),
send: (notification) =>
audited(
deps,
"notify:send",
"notify_send",
`${notification.category ?? pluginId} to ${describeAudience(notification)}`,
async () => {
const recipients = await resolveAudience(notification.audience);
const hub = await activeNotifyHub();
if (!hub || recipients.length === 0) return { ...EMPTY_RESULT };
return hub.deliver({ source: pluginId, recipients, notification });
},
),
serve: (hub) => {
if (!deps.manifest.capabilities.includes("notify:hub")) {
throw capabilityRefused(pluginId, "notify:hub", "notify_serve");
}
const revoke = registerNotifyHub({
pluginId,
declared: deps.manifest.capabilities,
hub,
});
deps.bag?.add(revoke, "alert hub");
void deps.audit("notify_serve", "serving alerts", { success: true });
return revoke;
},
};
}
export function createPluginFetch(deps: Deps): PluginFetch {
return (url, init = {}) =>
audited(deps, "network:outbound", "fetch", "outbound request", async () => {
const { safeOutboundFetch } =
await import("../utils/safe-outbound-fetch.js");
const controller = new AbortController();
const timer = setTimeout(
() => controller.abort(),
Math.max(init.timeoutMs ?? 30_000, 1),
);
// The caller's signal keeps working after the headers arrive, so it
// can stop a streamed body; the timeout only covers the wait for them.
const onAbort = () => controller.abort();
if (init.signal?.aborted) controller.abort();
init.signal?.addEventListener("abort", onAbort, { once: true });
try {
return await safeOutboundFetch(
url,
{
method: init.method ?? "GET",
redirect: init.redirect,
headers: init.headers,
body: init.body,
signal: controller.signal,
},
(init.allowPrivateHosts ?? []).map((host) => host.toLowerCase()),
{
...(init.tls?.ca ? { ca: init.tls.ca } : {}),
...(init.tls?.rejectUnauthorized === false
? { rejectUnauthorized: false }
: {}),
},
init.timeoutMs,
);
} finally {
clearTimeout(timer);
}
});
}
+29
View File
@@ -0,0 +1,29 @@
/**
* ctx.plugins: which plugins are installed and whether each runs, under
* plugins:read. Ids and versions only, nothing operational.
*/
import type { PluginPlugins } from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import { assertCapability } from "./permissions.js";
export function createPluginPlugins(manifest: PluginManifest): PluginPlugins {
return {
list: async () => {
await assertCapability(
manifest.id,
"plugins:read",
manifest.capabilities,
);
const { getPluginRuntime } = await import("./index.js");
return getPluginRuntime()
.loader.list()
.map((plugin) => ({
id: plugin.id,
version: plugin.manifest.version,
source: plugin.source,
state: plugin.state,
}));
},
};
}
+277
View File
@@ -0,0 +1,277 @@
/**
* ctx.process: programs on the Termix server, behind process:spawn.
*
* run starts a program without a shell and kills it on deactivate.
* ensureBinary gives a plugin a verified executable pinned to one SHA-256:
* a prebuilt copy (baked into the Docker image), the copy it downloaded
* before, or a fresh download.
*/
import { spawn } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import { createReadStream } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import type {
PluginBinarySpec,
PluginProcess,
PluginProcessHandle,
} from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import { assertCapability } from "./permissions.js";
import { getPluginDataDir } from "./paths.js";
import type { DisposableBag } from "./disposables.js";
type AuditFn = (
action: string,
details: string,
outcome: { success: boolean; errorMessage?: string },
) => Promise<void>;
interface Deps {
manifest: PluginManifest;
bag: DisposableBag;
audit: AuditFn;
/** Where downloaded binaries go. Defaults to <DATA_DIR>/plugin-data/<id>/bin. */
binDir?: () => string;
/** Test seam for the download. */
fetch?: typeof fetch;
}
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
const SAFE_NAME = /^[A-Za-z0-9._-]+$/;
async function sha256Of(file: string): Promise<string | null> {
try {
const hash = createHash("sha256");
for await (const chunk of createReadStream(file)) hash.update(chunk);
return hash.digest("hex");
} catch {
return null;
}
}
/**
* Downloads through the SSRF guard, following https redirects by hand
* because the guard refuses automatic ones (release assets redirect to a
* CDN).
*/
async function guardedDownload(
url: URL | string,
init: RequestInit,
): Promise<Response> {
const { safeOutboundFetch } = await import("../utils/safe-outbound-fetch.js");
let current = new URL(url.toString());
for (let hop = 0; hop < 5; hop++) {
const response = await safeOutboundFetch(current.toString(), {
...init,
redirect: "manual",
});
const location = response.headers.get("location");
if (response.status < 300 || response.status >= 400 || !location) {
return response;
}
current = new URL(location, current);
if (current.protocol !== "https:") {
throw new Error("Binary downloads must stay on https");
}
}
throw new Error("Too many redirects");
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export function createPluginProcess(deps: Deps): PluginProcess {
const pluginId = deps.manifest.id;
const declared = deps.manifest.capabilities;
const binDir =
deps.binDir ?? (() => path.join(getPluginDataDir(pluginId), "bin"));
const doFetch = deps.fetch ?? (guardedDownload as typeof fetch);
const audited = async <T>(
action: string,
details: string,
fn: () => Promise<T>,
): Promise<T> => {
try {
await assertCapability(pluginId, "process:spawn", declared);
const result = await fn();
await deps.audit(action, details, { success: true });
return result;
} catch (error) {
await deps.audit(action, details, {
success: false,
errorMessage: errorMessage(error),
});
throw error;
}
};
const run: PluginProcess["run"] = (file, args, options = {}) =>
audited("process_run", `ran ${path.basename(String(file))}`, async () => {
const child = spawn(file, [...args], {
cwd: options.cwd,
env: { ...baseEnvironment(), ...(options.env ?? {}) },
stdio: ["ignore", "pipe", "pipe"],
shell: false,
});
let running = true;
const kill = (signal: "SIGTERM" | "SIGKILL" = "SIGTERM") => {
if (!running) return;
try {
child.kill(signal);
} catch {
// already gone
}
};
const untrack = deps.bag.add(
() => kill("SIGKILL"),
`process ${path.basename(file)}`,
);
const timer =
options.timeoutMs && options.timeoutMs > 0
? setTimeout(() => kill("SIGKILL"), options.timeoutMs)
: null;
timer?.unref();
const exited = new Promise<{
code: number | null;
signal: string | null;
}>((resolve, reject) => {
child.once("error", (error) => {
running = false;
if (timer) clearTimeout(timer);
untrack();
reject(error);
});
child.once("close", (code, signal) => {
running = false;
if (timer) clearTimeout(timer);
untrack();
resolve({ code, signal });
});
});
// A caller that never awaits exited must not see an unhandled rejection.
exited.catch(() => {});
child.stdout?.setEncoding("utf8");
child.stderr?.setEncoding("utf8");
const handle: PluginProcessHandle = {
pid: child.pid,
onStdout: (listener) => {
child.stdout?.on("data", listener);
},
onStderr: (listener) => {
child.stderr?.on("data", listener);
},
exited,
kill,
};
return handle;
});
const download = async (
spec: PluginBinarySpec,
target: string,
): Promise<void> => {
await assertCapability(pluginId, "network:outbound", declared);
const url = new URL(spec.url);
if (url.protocol !== "https:") {
throw new Error("Binary downloads must use https");
}
const response = await doFetch(url, {
redirect: "follow",
headers: { "User-Agent": "Termix" },
});
if (!response.ok || !response.body) {
throw new Error(`Download failed with HTTP ${response.status}`);
}
const data = Buffer.from(await response.arrayBuffer());
const actual = createHash("sha256").update(data).digest("hex");
if (actual !== spec.sha256.toLowerCase()) {
throw new Error(
`Checksum mismatch for ${spec.name}: expected ${spec.sha256}, got ${actual}`,
);
}
const temp = `${target}.download-${randomUUID()}`;
await fs.writeFile(temp, data, { mode: 0o755 });
await fs.rename(temp, target);
};
const ensureBinary: PluginProcess["ensureBinary"] = (spec) =>
audited(
"process_ensure_binary",
`${spec.name} ${spec.version}`,
async () => {
if (!SAFE_NAME.test(spec.name)) {
throw new Error(`Invalid binary name "${spec.name}"`);
}
const expected = spec.sha256.toLowerCase();
if (!SHA256_PATTERN.test(expected)) {
throw new Error(`Invalid SHA-256 for ${spec.name}`);
}
for (const candidate of spec.prebuilt ?? []) {
if ((await sha256Of(candidate)) === expected) return candidate;
}
const dir = binDir();
await fs.mkdir(dir, { recursive: true });
const target = path.join(dir, spec.name);
if ((await sha256Of(target)) === expected) return target;
await download(spec, target);
return target;
},
);
return { run, ensureBinary };
}
/**
* What a plugin's program inherits from the server: enough to find binaries,
* a home and temp folder, the locale and the outbound proxy. Never the rest,
* which holds the JWT secret, database keys and OIDC client secrets.
*/
const INHERITED_ENV = [
"PATH",
"PATHEXT",
"HOME",
"USERPROFILE",
"APPDATA",
"LOCALAPPDATA",
"XDG_CONFIG_HOME",
"XDG_CACHE_HOME",
"TMP",
"TEMP",
"TMPDIR",
"SystemRoot",
"windir",
"ComSpec",
"LANG",
"LC_ALL",
"TZ",
"HTTP_PROXY",
"HTTPS_PROXY",
"NO_PROXY",
"http_proxy",
"https_proxy",
"no_proxy",
"SSL_CERT_FILE",
"SSL_CERT_DIR",
];
export function baseEnvironment(
env: NodeJS.ProcessEnv = process.env,
): NodeJS.ProcessEnv {
const inherited: NodeJS.ProcessEnv = {};
for (const name of INHERITED_ENV) {
if (env[name] !== undefined) inherited[name] = env[name];
}
return inherited;
}
+865
View File
@@ -0,0 +1,865 @@
/**
* ctx.ssh and ctx.auth.
*
* ctx.ssh is a thin, capability-checked door onto core's connect pipeline.
* ctx.auth registers login methods, second factors and SSH auth types into
* core's registries, scoped to the plugin's manifest and its disposable bag.
*/
import type {
PluginAuth,
PluginVerifiedIdentity,
PluginSsh,
PluginSshConnectOptions,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import { PluginSshInteractionError } from "@termix/plugin-sdk/backend";
import {
assertCapability,
capabilityRefused,
hasCachedCapability,
hasCapability,
warmPluginGrants,
} from "./permissions.js";
import { getActor, getActorSessionId } from "./actor.js";
import type { DisposableBag } from "./disposables.js";
import { redactHostSecrets } from "./ctx-hosts.js";
import {
getSshAuthProvider,
listSshAuthProviders,
registerKeyboardInteractiveInterceptor,
registerSshAuthProvider,
} from "../hosts/connect/auth-provider-registry.js";
import { classifyKeyboardInteractive } from "../hosts/connect/keyboard-interactive.js";
import { ensureCoreSshAuthProviders } from "../hosts/connect/core-providers.js";
import { isHostKeyVerificationError } from "../hosts/status/host-status.js";
import {
getLoginMethod,
registerLoginMethod,
registerSecondFactor,
} from "../auth/registry.js";
import type { VerifiedIdentity } from "../auth/types.js";
import type {
MutableConnectConfig,
SshAuthProvider,
SshConnectHost,
SshConnectProfile,
SshConnectPurpose,
} from "../hosts/connect/types.js";
type AuditFn = (
action: string,
details: string,
outcome: { success: boolean; errorMessage?: string },
) => Promise<void>;
interface Deps {
manifest: PluginManifest;
bag: DisposableBag;
audit: AuditFn;
}
const PLUGIN_PROFILES = new Set<SshConnectProfile>([
"terminal",
"session",
"stream",
"forward",
"background",
]);
function purposeOf(options?: { purpose?: string }): SshConnectPurpose {
return options?.purpose || "plugin";
}
// "jump" belongs to core's own jump chain; a plugin gets background.
function profileOf(options?: { profile?: string }): SshConnectProfile {
const profile = options?.profile as SshConnectProfile | undefined;
return profile && PLUGIN_PROFILES.has(profile) ? profile : "background";
}
/**
* The user a connection runs as: the actor, or for a host core resolved
* earlier, the user it was resolved for. A host object's own userId is plugin
* data and is never believed, or a plugin could borrow another user's jump
* hosts and stored credentials by naming them.
*/
function actingUser(resolvedFor?: string): string {
const actor = getActor() ?? resolvedFor;
if (actor) return actor;
throw new Error(
"ctx.ssh needs an acting user: call it inside a request or ctx.asUser",
);
}
function describeHost(host: number | PluginSshHost): string {
return typeof host === "number" ? `host ${host}` : `host ${host.id}`;
}
const LOGIN_FAILED =
/all configured authentication methods failed|permission denied|authentication failed/i;
export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
const pluginId = manifest.id;
const declared = manifest.capabilities;
const open = new Set<() => void>();
// Pool key -> host id, so dropPooled can find a host's keys after its
// address changed.
const poolKeys = new Map<string, number>();
bag.add(async () => {
for (const dispose of [...open]) dispose();
open.clear();
if (poolKeys.size > 0) {
const { connectionPool } =
await import("../hosts/ssh-connection-pool.js");
for (const key of poolKeys.keys()) {
connectionPool.clearKeyConnections(key);
}
poolKeys.clear();
}
}, "SSH connections");
const checkSsh = async (withCredentials: boolean) => {
await assertCapability(pluginId, "ssh:connect", declared);
if (withCredentials) {
await assertCapability(pluginId, "credentials:use", declared);
}
};
// Hosts core resolved, keyed by the redacted copy the plugin was given, so
// handing that copy back to connect still connects with the real secrets.
const resolvedHosts = new WeakMap<
object,
{ host: SshConnectHost; userId: string }
>();
const remember = (key: object, host: SshConnectHost, userId: string) =>
resolvedHosts.set(key, { host, userId });
const handOut = (host: SshConnectHost, userId: string): PluginSshHost => {
const redacted = redactHostSecrets(
host as unknown as Record<string, unknown>,
) as unknown as PluginSshHost;
remember(redacted, host, userId);
return redacted;
};
/** The acting user for a call about this host. */
const userFor = (host: number | PluginSshHost | undefined): string =>
actingUser(
host && typeof host === "object"
? resolvedHosts.get(host)?.userId
: undefined,
);
/**
* A host for core's pipeline. An id stays an id (core resolves it with an
* access check). An object core handed out maps back to what core resolved;
* any other object is the plugin's own, so it runs as the actor.
*/
const toConnectHost = (
host: number | PluginSshHost,
userId: string,
): number | SshConnectHost => {
if (typeof host === "number") return host;
const known = resolvedHosts.get(host);
if (known) return known.host;
return { ...(host as unknown as SshConnectHost), userId };
};
/**
* A login to a saved host core resolved feeds its status dot. Only a clear
* auth or host key failure counts: a timeout says nothing about the login.
*/
const reportLogin = (host: number | PluginSshHost, error?: unknown) => {
const hostId =
typeof host === "number" ? host : resolvedHosts.get(host)?.host.id;
if (!Number.isInteger(hostId) || hostId <= 0) return;
let outcome: { ok: boolean; hostKeyChanged?: boolean } = { ok: true };
if (error !== undefined) {
const message = error instanceof Error ? error.message : String(error);
const hostKeyChanged = isHostKeyVerificationError(error);
if (!hostKeyChanged && !LOGIN_FAILED.test(message)) return;
outcome = { ok: false, hostKeyChanged };
}
void import("../hosts/status/host-status-service.js")
.then(({ hostStatusService }) =>
hostStatusService.reportLogin(hostId, outcome),
)
.catch(() => {});
};
/** Checks, connects and audits one new connection. */
const connectOnce = async (
host: number | PluginSshHost,
options?: PluginSshConnectOptions,
) => {
try {
await checkSsh(true);
} catch (error) {
await audit("ssh_connect", describeHost(host), {
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
throw error;
}
let userId: string;
try {
userId = userFor(host);
} catch (error) {
await audit("ssh_connect", describeHost(host), {
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
throw error;
}
const { connectHost } = await import("../hosts/connect/connect-host.js");
try {
const connection = await connectHost(toConnectHost(host, userId), {
userId,
purpose: purposeOf(options),
profile: profileOf(options),
timeoutMs: options?.timeoutMs,
prompt: options?.prompt,
overrides: options?.overrides as Partial<MutableConnectConfig>,
sock: options?.sock as MutableConnectConfig["sock"],
});
await audit("ssh_connect", describeHost(host), { success: true });
reportLogin(host);
const dispose = () => {
open.delete(dispose);
connection.dispose();
};
open.add(dispose);
connection.client.once("close", () => open.delete(dispose));
return {
...connection,
host: handOut(connection.host as SshConnectHost, userId),
dispose,
};
} catch (error) {
await audit("ssh_connect", describeHost(host), {
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
reportLogin(host, error);
throw error;
}
};
const poolKey = (pool: string, host: PluginSshHost) =>
`${pool}:${host.userId}:${host.ip}:${host.port}:${host.username}${
host.useSocks5 ? `:socks5:${host.socks5Host}:${host.socks5Port}` : ""
}`;
return {
connect: async (host, options) => {
const connection = await connectOnce(host, options);
return {
client: connection.client as never,
jumpClient: connection.jumpClient as never,
host: connection.host,
dispose: connection.dispose,
};
},
withConnection: async (host, options, fn) => {
await checkSsh(true);
const { resolveConnectHost } =
await import("../hosts/connect/connect-host.js");
const userId = userFor(host);
const resolved = await resolveConnectHost(
toConnectHost(host, userId),
userId,
);
remember(resolved, resolved, userId);
const key = poolKey(options.pool, resolved as PluginSshHost);
poolKeys.set(key, resolved.id);
const { withConnection } =
await import("../hosts/ssh-connection-pool.js");
return withConnection(
key,
async () =>
(await connectOnce(resolved as PluginSshHost, options)).client,
(client) => fn(client as never),
);
},
jumpChain: async (jumpHosts, chainOptions) => {
await checkSsh(true);
const userId = userFor(chainOptions?.forHost);
const { createJumpHostChain } =
await import("../hosts/jump-host-chain.js");
const client = await createJumpHostChain(
jumpHosts,
userId,
chainOptions?.prompt as Parameters<typeof createJumpHostChain>[2],
);
const details = `jump chain of ${jumpHosts.length} hop(s)`;
if (!client) {
await audit("ssh_jump_chain", details, {
success: false,
errorMessage: "Jump host chain could not be established",
});
throw new Error("Jump host chain could not be established");
}
await audit("ssh_jump_chain", details, { success: true });
const dispose = () => {
open.delete(dispose);
client.end();
};
open.add(dispose);
client.once("close", () => open.delete(dispose));
// A jump chain ends at a forwarding client, not a single resolved SSH
// host: report the caller's own host if it gave one, else the last hop.
const host: PluginSshHost = chainOptions?.forHost
? (redactHostSecrets(
chainOptions.forHost as unknown as Record<string, unknown>,
) as unknown as PluginSshHost)
: {
id: jumpHosts[jumpHosts.length - 1]?.hostId ?? 0,
ip: "",
port: 22,
username: "",
};
return { client: client as never, jumpClient: null, host, dispose };
},
poolKey,
dropPooled: (pool, hostId) => {
const keys = [...poolKeys.entries()]
.filter(([key, id]) => id === hostId && key.startsWith(`${pool}:`))
.map(([key]) => key);
if (keys.length === 0) return;
for (const key of keys) poolKeys.delete(key);
void import("../hosts/ssh-connection-pool.js").then(
({ connectionPool }) => {
for (const key of keys) connectionPool.clearKeyConnections(key);
},
);
},
resolveHost: async (hostId, options) => {
await checkSsh(true);
const userId = actingUser();
const { resolveHostById, resolveHostBySyncId } =
await import("../hosts/host-resolver.js");
const resolved = options?.syncId
? await resolveHostBySyncId(options.syncId, userId)
: await resolveHostById(hostId, userId);
await audit("ssh_resolve_host", describeHost(hostId), {
success: !!resolved,
errorMessage: resolved ? undefined : "Host not found",
});
if (!resolved) return null;
// The secrets only go to a plugin that declared it reads them.
if (await hasCapability(pluginId, "credentials:read", declared)) {
await audit("credentials_read", describeHost(hostId), {
success: true,
});
const full = resolved as unknown as SshConnectHost;
remember(full, full, userId);
return full as unknown as PluginSshHost;
}
return handOut(resolved as unknown as SshConnectHost, userId);
},
// The config it returns carries the password and private key, so it
// needs credentials:read on top of the connect pair.
prepare: async (host, options) => {
await checkSsh(true);
await assertCapability(pluginId, "credentials:read", declared);
const userId = userFor(host);
const target = toConnectHost(host, userId) as SshConnectHost;
const { buildConnectConfig } =
await import("../hosts/connect/build-connect-config.js");
const built = await buildConnectConfig(target, {
userId,
purpose: purposeOf(options),
profile: profileOf(options),
client: options.client as never,
serverHostId: options.serverHostId,
log: options.log,
interactive: options.interactive,
hostKeySocket: (options.hostKeySocket ?? null) as never,
});
await audit("ssh_prepare", describeHost(host), {
success: built.outcome.status === "ready",
errorMessage:
built.outcome.status === "ready" ? undefined : built.outcome.message,
});
const provider = built.provider;
return {
config: built.config,
outcome: built.outcome,
authType: provider?.type ?? null,
onBanner: provider?.onBanner
? (banner: string) =>
provider.onBanner!(banner, target, built.env) as never
: undefined,
onAuthFailed: provider?.onAuthFailed
? (context) => provider.onAuthFailed!(target, built.env, context)
: undefined,
};
},
openTransport: async (host, config, transportOptions) => {
await checkSsh(true);
const target = toConnectHost(host, userFor(host)) as SshConnectHost;
const { openSshTransport } =
await import("../hosts/connect/transport.js");
let opened: Awaited<ReturnType<typeof openSshTransport>>;
try {
opened = await openSshTransport(
target,
config as MutableConnectConfig,
transportOptions,
);
} catch (error) {
await audit("ssh_open_transport", describeHost(host), {
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
throw error;
}
await audit("ssh_open_transport", describeHost(host), { success: true });
if (opened.jumpClient) {
const jumpClient = opened.jumpClient;
const dispose = () => {
open.delete(dispose);
jumpClient.end();
};
open.add(dispose);
jumpClient.once("close", () => open.delete(dispose));
}
return opened;
},
startInteraction: async (interaction, request) => {
await checkSsh(true);
const userId = actingUser();
ensureCoreSshAuthProviders();
const { PermissionManager } =
await import("../utils/permission-manager.js");
const access = await PermissionManager.getInstance().canAccessHost(
userId,
request.hostId,
"connect",
);
if (!access.hasAccess) {
await audit("ssh_start_interaction", describeHost(request.hostId), {
success: false,
errorMessage: "Host not found",
});
throw new PluginSshInteractionError("Host not found");
}
const { createCurrentHostResolutionRepository } =
await import("../database/repositories/factory.js");
const host = await createCurrentHostResolutionRepository().findHostById(
request.hostId,
userId,
);
if (!host) throw new PluginSshInteractionError("Host not found");
const own = getSshAuthProvider(host.authType as string);
const provider =
own?.interaction === interaction && own.startInteraction
? own
: listSshAuthProviders().find(
(candidate) =>
candidate.interaction === interaction &&
candidate.startInteraction,
);
if (!provider?.startInteraction) {
throw new PluginSshInteractionError(
`No enabled provider handles ${interaction} sign-in`,
);
}
await audit("ssh_start_interaction", describeHost(request.hostId), {
success: true,
});
await provider.startInteraction({
userId,
hostId: request.hostId,
host: {
name: host.name as string | null,
ip: host.ip as string,
username: host.username as string,
},
socket: request.socket as never,
requestOrigin: request.requestOrigin,
payload: request.payload ?? {},
});
},
cancelInteraction: async (interaction, request) => {
await checkSsh(false);
const userId = actingUser();
ensureCoreSshAuthProviders();
for (const provider of listSshAuthProviders()) {
if (provider.interaction !== interaction) continue;
if (!provider.cancelInteraction) continue;
await provider.cancelInteraction({ userId, ...request });
}
},
classifyKeyboardInteractive: (round, host) => {
ensureCoreSshAuthProviders();
return classifyKeyboardInteractive(round, host as SshConnectHost);
},
autoResponses: (prompts, password) =>
prompts.map((p) =>
/password/i.test(p.prompt) && password ? password : "",
),
requiresSecret: (authType) => {
ensureCoreSshAuthProviders();
return !!getSshAuthProvider(authType)?.requiresSecret;
},
supportsBackground: (authType) => {
ensureCoreSshAuthProviders();
const provider = getSshAuthProvider(authType);
return !!provider && provider.supportsBackground !== false;
},
};
}
/**
* Turns what a plugin method returned into core's identity. Only the fields
* the SDK documents are copied, so a plugin cannot set core-only extras, and
* its rate limit key is kept apart from core's and other plugins'.
*/
export function toCoreIdentity(
pluginId: string,
identity: PluginVerifiedIdentity,
): VerifiedIdentity {
if (identity.kind === "user") {
return {
kind: "user",
userId: identity.userId,
mfaSatisfied: identity.mfaSatisfied,
password: identity.password,
returnTo: identity.returnTo,
rememberMe: identity.rememberMe,
};
}
return {
kind: "external",
provider: identity.provider,
subject: identity.subject,
email: identity.email,
name: identity.name,
groups: identity.groups,
isAdmin: identity.isAdmin,
allowedUsers: identity.allowedUsers,
mfaSatisfied: identity.mfaSatisfied,
returnTo: identity.returnTo,
rememberMe: identity.rememberMe,
legacyIdentifier: identity.legacy?.identifier,
ssoProviderId:
identity.logoutClaims?.providerId ??
identity.legacy?.providerRowId ??
null,
oidcSub: identity.logoutClaims?.sub ?? null,
oidcSid: identity.logoutClaims?.sid ?? null,
roleSync: identity.roles
? {
desired: [...identity.roles.desired],
managed: [...identity.roles.managed],
}
: undefined,
rateLimitUsername: identity.rateLimitKey
? rateLimitKeyFor(pluginId, identity.rateLimitKey)
: undefined,
};
}
function rateLimitKeyFor(pluginId: string, key: string): string {
return `plugin:${pluginId}:${key}`;
}
export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
const pluginId = manifest.id;
const declared = manifest.capabilities;
const contributes = manifest.contributes?.auth ?? {};
// Registration is synchronous so a plugin can call it inline in activate,
// like ctx.http.router. The declaration is checked here; the grant is
// checked on every call into what was registered.
const requireDeclared = (
list: string[] | undefined,
id: string,
field: string,
) => {
if (!declared.includes("auth:provide")) {
throw capabilityRefused(pluginId, "auth:provide");
}
if (!list?.includes(id)) {
throw new Error(
`Plugin ${pluginId} cannot register "${id}": it is not listed in contributes.auth.${field}`,
);
}
};
const granted = () => assertCapability(pluginId, "auth:provide", declared);
// ssh2 calls some hooks synchronously, so those answer from the grant
// cache and do nothing until the grant is confirmed.
const grantedNow = () =>
hasCachedCapability(pluginId, "auth:provide", declared);
const record = (action: string, id: string) =>
void audit(action, `${pluginId} registered ${id}`, { success: true });
return {
registerSshAuthProvider: (provider) => {
requireDeclared(contributes.sshAuthTypes, provider.type, "sshAuthTypes");
const wrapped: SshAuthProvider = {
...(provider as unknown as SshAuthProvider),
pluginId,
prepare: async (config, host, env) => {
await granted();
return provider.prepare(
config,
host as PluginSshHost,
env as never,
) as never;
},
};
if (provider.onBanner) {
const onBanner = provider.onBanner;
wrapped.onBanner = (banner, host, env) =>
grantedNow()
? (onBanner(banner, host as PluginSshHost, env as never) as never)
: (undefined as never);
}
if (provider.onAuthFailed) {
const onAuthFailed = provider.onAuthFailed;
wrapped.onAuthFailed = (host, env, context) =>
grantedNow()
? (onAuthFailed(
host as PluginSshHost,
env as never,
context as never,
) as never)
: undefined;
}
warmPluginGrants(pluginId);
ensureCoreSshAuthProviders();
const disposers = [registerSshAuthProvider(wrapped)];
if (provider.onKeyboardInteractive) {
const detect = provider.onKeyboardInteractive;
disposers.push(
registerKeyboardInteractiveInterceptor({
id: `${pluginId}:${provider.type}`,
pluginId,
detect: (round, host) =>
host.authType === provider.type && grantedNow()
? (detect(round, host as PluginSshHost) as never)
: null,
}),
);
}
bag.add(
() => disposers.forEach((dispose) => dispose()),
`SSH auth type "${provider.type}"`,
);
record("auth_register_ssh", provider.type);
},
registerKeyboardInteractiveHandler: (handler) => {
requireDeclared(
contributes.keyboardInteractive,
handler.id,
"keyboardInteractive",
);
// Synchronous hooks inside ssh2's callback, so they answer from the
// grant cache. Deactivate removes the handler.
warmPluginGrants(pluginId);
const settingsFor = (host: SshConnectHost): Record<string, unknown> =>
host.pluginSettings?.[pluginId] ?? {};
ensureCoreSshAuthProviders();
const dispose = registerKeyboardInteractiveInterceptor({
id: handler.id,
pluginId,
detect: (round, host) => {
if (!grantedNow()) return null;
const detected = handler.detect(
round,
host as PluginSshHost,
settingsFor(host),
);
if (!detected) return null;
if (detected.kind === "browser") {
return { ...detected, id: handler.id, label: handler.label };
}
return detected;
},
autoAnswerPasswords: handler.autoAnswerPasswords
? (host) =>
grantedNow() &&
handler.autoAnswerPasswords!(
host as PluginSshHost,
settingsFor(host),
) === true
: undefined,
});
bag.add(dispose, `keyboard-interactive handler "${handler.id}"`);
record("auth_register_keyboard_interactive", handler.id);
},
registerLoginMethod: (method) => {
requireDeclared(contributes.loginMethods, method.id, "loginMethods");
{
const dispose = registerLoginMethod({
...method,
pluginId,
start: method.start
? async (...args) => {
await granted();
return method.start!(...args);
}
: undefined,
callback: method.callback
? async (...args) => {
await granted();
return toCoreIdentity(
pluginId,
await method.callback!(...args),
);
}
: undefined,
verify: method.verify
? async (...args) => {
await granted();
return toCoreIdentity(pluginId, await method.verify!(...args));
}
: undefined,
});
bag.add(dispose, `login method "${method.id}"`);
}
record("auth_register_login", method.id);
},
registerSecondFactor: (factor) => {
requireDeclared(contributes.secondFactors, factor.id, "secondFactors");
{
const dispose = registerSecondFactor({
...factor,
pluginId,
verify: async (userId, body) => {
await granted();
return factor.verify(userId, body);
},
});
bag.add(dispose, `second factor "${factor.id}"`);
}
record("auth_register_factor", factor.id);
},
recordEnrollment: async (userId, factorId) => {
requireDeclared(contributes.secondFactors, factorId, "secondFactors");
await granted();
const { assertSecondFactorEnrollmentAllowed } =
await import("../auth/core-auth.js");
assertSecondFactorEnrollmentAllowed();
const {
createCurrentSessionRepository,
createCurrentTrustedDeviceRepository,
createCurrentUserAuthRepository,
} = await import("../database/repositories/factory.js");
await createCurrentUserAuthRepository().recordSecondFactor(
userId,
pluginId,
factorId,
);
// A new factor signs the user out everywhere else and forgets trusted
// devices, so nothing that skipped it before keeps skipping it.
await createCurrentSessionRepository().revokeAllForUser(
userId,
getActor() === userId ? getActorSessionId() : undefined,
);
await createCurrentTrustedDeviceRepository().deleteByUserId(userId);
await audit("auth_factor_enrolled", `${factorId} for ${userId}`, {
success: true,
});
},
removeEnrollment: async (userId, factorId) => {
requireDeclared(contributes.secondFactors, factorId, "secondFactors");
await granted();
const { createCurrentUserAuthRepository } =
await import("../database/repositories/factory.js");
await createCurrentUserAuthRepository().removeSecondFactor(
userId,
pluginId,
factorId,
);
await audit("auth_factor_removed", `${factorId} for ${userId}`, {
success: true,
});
},
completeRedirectLogin: async (methodId, req, res) => {
requireDeclared(contributes.loginMethods, methodId, "loginMethods");
await granted();
const method = getLoginMethod(methodId);
if (!method || method.pluginId !== pluginId) {
throw new Error(
`Plugin ${pluginId} has no registered login method "${methodId}"`,
);
}
const { handleRedirectCallback } =
await import("../database/routes/auth-routes.js");
await handleRedirectCallback(method, req as never, res as never);
},
revokeSessions: async (match) => {
await granted();
if (!match.sub && !match.sid) return 0;
const { AuthManager } = await import("../utils/auth-manager.js");
const revoked =
await AuthManager.getInstance().revokeSessionsByExternalSession({
ssoProviderId: match.providerId ?? null,
sub: match.sub ?? null,
sid: match.sid ?? null,
});
await audit(
"auth_sessions_revoked",
`${revoked} session(s) for provider ${match.providerId ?? "none"}`,
{ success: true },
);
return revoked;
},
loginRateLimit: {
isLocked: async (ip, key) => {
await granted();
const { loginRateLimiter } =
await import("../utils/login-rate-limiter.js");
return loginRateLimiter.isLocked(ip, rateLimitKeyFor(pluginId, key));
},
recordFailure: async (ip, key) => {
await granted();
const { loginRateLimiter } =
await import("../utils/login-rate-limiter.js");
loginRateLimiter.recordFailedAttempt(
ip,
rateLimitKeyFor(pluginId, key),
);
},
},
countLinkedUsers: async (provider) => {
await granted();
const { createCurrentUserAuthRepository } =
await import("../database/repositories/factory.js");
return createCurrentUserAuthRepository().countUsersForProvider(provider);
},
};
}
+129
View File
@@ -0,0 +1,129 @@
/**
* ctx.system: the server certificate. Core serves it; a plugin under
* system:tls may replace it, reload it, answer ACME http-01 challenges and
* say that it renews it. Every call is audited.
*/
import type { PluginSystem } from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import { assertCapability } from "./permissions.js";
import type { DisposableBag } from "./disposables.js";
type AuditFn = (
action: string,
details: string,
outcome: { success: boolean; errorMessage?: string },
) => Promise<void>;
interface Deps {
manifest: PluginManifest;
bag: DisposableBag;
audit: AuditFn;
}
const CAPABILITY = "system:tls";
async function audited<T>(
deps: Deps,
action: string,
details: string,
fn: () => Promise<T>,
): Promise<T> {
try {
await assertCapability(
deps.manifest.id,
CAPABILITY,
deps.manifest.capabilities,
);
const result = await fn();
await deps.audit(action, details, { success: true });
return result;
} catch (error) {
await deps.audit(action, details, {
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
/**
* Puts an undo in the plugin's bag and hands back one that also takes it out
* again, so it runs once whether the plugin or deactivate gets there first.
*/
function tracked(deps: Deps, undo: () => void, label: string): () => void {
let done = false;
const run = () => {
if (done) return;
done = true;
undo();
};
const untrack = deps.bag.add(run, label);
return () => {
untrack();
run();
};
}
export function createPluginSystem(deps: Deps): PluginSystem {
return {
tlsStatus: () =>
audited(deps, "tls_status", "read certificate status", async () => {
const { getTlsStatus } = await import("../tls/tls-service.js");
return getTlsStatus();
}),
writeTlsCertificate: (certificatePem, privateKeyPem) =>
audited(
deps,
"tls_write",
"replaced the server certificate",
async () => {
const { writeTlsCertificate } = await import("../tls/tls-service.js");
return writeTlsCertificate(certificatePem, privateKeyPem);
},
),
reloadTls: () =>
audited(
deps,
"tls_reload",
"reloaded the server certificate",
async () => {
const { reloadTls } = await import("../tls/tls-service.js");
return reloadTls();
},
),
publishHttpChallenge: (token, content) =>
audited(
deps,
"tls_challenge",
"published an ACME challenge",
async () => {
const { publishChallenge } =
await import("../tls/acme-challenges.js");
return tracked(
deps,
publishChallenge(token, content),
"acme challenge",
);
},
),
registerTlsRenewer: () =>
audited(
deps,
"tls_renewer",
"renews the server certificate",
async () => {
const { registerTlsRenewer } = await import("../tls/tls-service.js");
const undo = registerTlsRenewer({
pluginId: deps.manifest.id,
pluginName: deps.manifest.name,
});
return tracked(deps, undo, "tls renewer");
},
),
};
}
File diff suppressed because it is too large. Load diff
+314
View File
@@ -0,0 +1,314 @@
/**
* The live database side of plugin-owned data.
*
* Holds the table definitions each plugin has registered, builds the runner
* that applies its migrations against the real database, and drops everything
* again on uninstall. The pure logic lives in migrations.ts so it can be
* tested without a database; this file is the part that needs one.
*/
import { sql } from "drizzle-orm";
import { pluginLogger } from "../utils/logger.js";
import { resolveDatabaseDialect } from "../database/db/dialect.js";
import type { DatabaseDialect } from "../database/db/dialect.js";
import type { PluginTableDefinition } from "@termix/plugin-sdk/db";
import { prefixedTableName, tablePrefix } from "@termix/plugin-sdk/db";
import { buildTable } from "./table-builder.js";
import {
applyPluginMigrations,
readMigrations,
type MigrationRunner,
} from "./migrations.js";
/** Definitions registered by a plugin, in the order it declared them. */
const registered = new Map<string, PluginTableDefinition[]>();
/** Built table objects, keyed by plugin id then definition name. */
const built = new Map<string, Map<string, unknown>>();
export function registerTable(
pluginId: string,
definition: PluginTableDefinition,
): unknown {
const definitions = registered.get(pluginId) ?? [];
const index = definitions.findIndex(
(entry) => entry.name === definition.name,
);
if (index >= 0) {
// Re-registering on re-activation is normal. The same definition keeps
// its table object; a changed one (an updated plugin) is rebuilt.
if (JSON.stringify(definitions[index]) === JSON.stringify(definition)) {
return getTable(pluginId, definition.name);
}
definitions[index] = definition;
} else {
definitions.push(definition);
}
registered.set(pluginId, definitions);
const table = buildTable(pluginId, definition);
const tables = built.get(pluginId) ?? new Map<string, unknown>();
tables.set(definition.name, table);
built.set(pluginId, tables);
return table;
}
function getTable(pluginId: string, name: string): unknown {
return built.get(pluginId)?.get(name);
}
function listTables(pluginId: string): PluginTableDefinition[] {
return [...(registered.get(pluginId) ?? [])];
}
/** Every registered definition, with the plugin that owns it. */
export function listAllTables(): Array<{
pluginId: string;
definition: PluginTableDefinition;
}> {
return [...registered.entries()].flatMap(([pluginId, definitions]) =>
definitions.map((definition) => ({ pluginId, definition })),
);
}
/** Forgets a plugin's definitions. Used by the tests and by uninstall. */
function forgetTables(pluginId: string): void {
registered.delete(pluginId);
built.delete(pluginId);
}
export function resetPluginData(): void {
registered.clear();
built.clear();
}
async function execute(statements: string[]): Promise<void> {
// getDb() has run() only on SQLite; runStatement picks execute() elsewhere.
const { runStatement } =
await import("../utils/crypto-migration/raw-rows.js");
for (const statement of statements) {
await runStatement(sql.raw(statement));
}
}
/**
* Postgres runs DDL inside a transaction, so one migration file applies
* whole or not at all. Through drizzle's transaction because a pool would
* spread a raw BEGIN and the statements over different connections.
*/
async function executeInTransaction(statements: string[]): Promise<void> {
const { getDb } = await import("../database/db/index.js");
const db = getDb() as unknown as {
transaction: (
fn: (tx: {
execute: (query: unknown) => Promise<unknown>;
}) => Promise<void>,
) => Promise<void>;
};
await db.transaction(async (tx) => {
for (const statement of statements) {
await tx.execute(sql.raw(statement));
}
});
}
/**
* SQLite has one connection, so a raw BEGIN covers the statements and the
* migration record together.
*/
async function sqliteTransaction(fn: () => Promise<void>): Promise<void> {
const { runStatement } =
await import("../utils/crypto-migration/raw-rows.js");
await runStatement(sql.raw("BEGIN"));
try {
await fn();
await runStatement(sql.raw("COMMIT"));
} catch (error) {
await runStatement(sql.raw("ROLLBACK")).catch(() => {});
throw error;
}
}
async function executeNamingFailure(statements: string[]): Promise<void> {
const { runStatement } =
await import("../utils/crypto-migration/raw-rows.js");
for (const [index, statement] of statements.entries()) {
try {
await runStatement(sql.raw(statement));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(
`statement ${index + 1} of ${statements.length} failed (earlier ones are already applied on MySQL): ${message}`,
);
}
}
}
/**
* Builds the runner that applies migrations against the live database.
*
* SQLite and Postgres apply each migration file atomically. MySQL commits
* implicitly on DDL, so there a failed file can leave earlier statements
* applied; the error names the statement so it can be fixed by hand.
*/
async function createMigrationRunner(
dialect: DatabaseDialect = resolveDatabaseDialect(),
): Promise<MigrationRunner> {
const { createCurrentPluginMigrationRepository } =
await import("../database/repositories/factory.js");
const repository = createCurrentPluginMigrationRepository();
return {
dialect,
listApplied: async (pluginId) => {
const rows = await repository.listByPlugin(pluginId);
return rows.map((row) => ({
migrationId: row.migrationId,
checksum: row.checksum,
}));
},
execute:
dialect === "postgres"
? executeInTransaction
: dialect === "mysql"
? executeNamingFailure
: execute,
transaction: dialect === "sqlite" ? sqliteTransaction : undefined,
record: async (pluginId, migration) => {
await repository.record(pluginId, migration.id, migration.checksum);
},
};
}
/**
* Applies a plugin's migrations. Throws so the loader can fail the plugin.
*
* Returns early when the plugin ships none, before anything asks for a
* database handle: most plugins own no tables, and activating one should not
* require a connection it never uses.
*/
export async function migratePlugin(
pluginId: string,
pluginDir: string,
options: { bundled?: boolean; dialect?: DatabaseDialect } = {},
): Promise<string[]> {
const dialect = options.dialect ?? resolveDatabaseDialect();
if (readMigrations(pluginDir, dialect).length === 0) return [];
const runner = await createMigrationRunner(dialect);
return applyPluginMigrations(pluginId, pluginDir, runner, {
bundled: options.bundled,
});
}
/**
* Removes everything a plugin owns.
*
* Tables are found in the database's own catalog by the plugin's prefix, not
* from what it registered this boot, so a plugin that never activated since
* the restart still loses its tables. A longer prefix that belongs to another
* known plugin is left alone. Disabling a plugin never calls this: data
* outlives being turned off, and only an explicit uninstall throws it away.
* Role permissions stay, so a role keeps them if the plugin comes back.
*/
export async function removePluginData(
pluginId: string,
options: { knownPluginIds?: string[]; dialect?: DatabaseDialect } = {},
): Promise<{ tables: string[]; kvKeys: number; migrations: number }> {
const dialect = options.dialect ?? resolveDatabaseDialect();
const tables = ownedTableNames(
pluginId,
await listDatabaseTables(dialect),
options.knownPluginIds ?? [],
);
// Registered order reversed first, so a referencing table goes before the
// one it points at; anything else after.
const registeredOrder = listTables(pluginId)
.map((definition) => prefixedTableName(pluginId, definition.name))
.reverse();
const ordered = [
...registeredOrder.filter((table) => tables.includes(table)),
...tables.filter((table) => !registeredOrder.includes(table)),
];
const dropped: string[] = [];
for (const table of ordered) {
await execute([dropTableByName(dialect, table)]);
dropped.push(table);
}
const {
createCurrentPluginStorageRepository,
createCurrentPluginMigrationRepository,
createCurrentPluginPermissionGrantRepository,
createCurrentPluginSettingsRepository,
} = await import("../database/repositories/factory.js");
const kvKeys =
await createCurrentPluginStorageRepository().deleteByPlugin(pluginId);
const migrations =
await createCurrentPluginMigrationRepository().deleteByPlugin(pluginId);
await createCurrentPluginPermissionGrantRepository().deleteByPlugin(pluginId);
// Settings and the plugin's encrypted secrets go with it.
await createCurrentPluginSettingsRepository().deleteByPlugin(pluginId);
forgetTables(pluginId);
pluginLogger.info(
`Removed data for plugin ${pluginId}: ${dropped.length} table(s), ${kvKeys} kv key(s)`,
{ operation: "plugin_remove_data" },
);
return { tables: dropped, kvKeys, migrations };
}
/**
* The tables in `all` that belong to this plugin: its prefix, minus any that
* sit under a longer prefix another plugin owns.
*/
export function ownedTableNames(
pluginId: string,
all: string[],
knownPluginIds: string[],
): string[] {
const prefix = tablePrefix(pluginId);
const longer = knownPluginIds
.filter((id) => id !== pluginId)
.map((id) => tablePrefix(id))
.filter((other) => other.startsWith(prefix));
return all.filter(
(table) =>
table.startsWith(prefix) &&
!longer.some((other) => table.startsWith(other)),
);
}
function dropTableByName(dialect: DatabaseDialect, table: string): string {
const quoted = dialect === "mysql" ? "`" + table + "`" : `"${table}"`;
return `DROP TABLE IF EXISTS ${quoted};`;
}
async function listDatabaseTables(dialect: DatabaseDialect): Promise<string[]> {
const { getDb } = await import("../database/db/index.js");
const db = getDb() as unknown as {
all?: (query: unknown) => Promise<Array<Record<string, unknown>>>;
execute?: (query: unknown) => Promise<unknown>;
};
if (dialect === "sqlite") {
const rows = await db.all!(
sql`SELECT name FROM sqlite_master WHERE type = 'table'`,
);
return rows.map((row) => String(row.name));
}
const query =
dialect === "postgres"
? sql`SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()`
: sql`SELECT table_name AS name FROM information_schema.tables WHERE table_schema = DATABASE()`;
const result = await db.execute!(query);
// node-postgres answers { rows }, mysql2 answers [rows, fields].
const rows = (
Array.isArray(result) ? result[0] : (result as { rows?: unknown[] }).rows
) as Array<Record<string, unknown>>;
return rows.map((row) => String(row.name ?? row.NAME));
}
+74
View File
@@ -0,0 +1,74 @@
/**
* Everything a plugin registered, so deactivate can undo all of it.
*
* The rule this enforces: a plugin is only as disableable as its cleanup. A
* listening port, a live SSH session or an interval that outlives deactivate
* turns "disabled" into a lie, and re-enabling then fails on a port that is
* still held.
*
* Disposal runs in reverse registration order, so a thing torn down last was
* set up first. Every disposer is isolated: one that throws is logged and the
* rest still run, because a half-disposed plugin is worse than a noisy log.
*/
import { pluginLogger } from "../utils/logger.js";
export type Disposer = () => void | Promise<void>;
interface Entry {
dispose: Disposer;
/** What this is, for the log line when it throws. */
label: string;
}
export class DisposableBag {
private entries: Entry[] = [];
private disposed = false;
constructor(private readonly pluginId: string) {}
/** Returns a function that drops the entry without running it. */
add(dispose: Disposer, label = "disposable"): () => void {
if (this.disposed) {
// Registering after teardown means the resource would never be cleaned
// up. Dispose it immediately rather than silently leaking it.
void this.run({ dispose, label });
return () => {};
}
const entry = { dispose, label };
this.entries.push(entry);
return () => {
this.entries = this.entries.filter((candidate) => candidate !== entry);
};
}
get size(): number {
return this.entries.length;
}
/**
* Runs every disposer, newest first. Safe to call more than once, and never
* throws: the caller is already tearing the plugin down.
*/
async disposeAll(): Promise<void> {
this.disposed = true;
const entries = this.entries.reverse();
this.entries = [];
for (const entry of entries) {
await this.run(entry);
}
}
private async run(entry: Entry): Promise<void> {
try {
await entry.dispose();
} catch (error) {
pluginLogger.error(
`Plugin ${this.pluginId} failed to dispose ${entry.label}`,
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_dispose" },
);
}
}
}
+111
View File
@@ -0,0 +1,111 @@
/**
* The process-wide event bus behind ctx.events.
*
* Core publishes here (hosts/internal-events.ts for generic named events,
* hosts/host-session-status.ts for "host.session.status", and the topics in
* TOPICS below) and plugins subscribe through ctx.events. Two rules:
*
* 1. Fire-and-forget. A failing subscriber must never disturb the caller. A
* metrics poll or a host delete does not fail because a listener threw.
* 2. No static import of a subscriber. Subscribers register themselves here
* instead of being reached into, so core never names a plugin.
*
* Topics are dotted strings. The internal ones the server itself publishes are
* listed in TOPICS; a plugin may emit and subscribe to any topic, but only
* topics it is allowed to see are pushed to it.
*/
import { pluginLogger } from "../utils/logger.js";
export const TOPICS = {
/** A terminal session opened or closed against a host. */
hostSessionStatus: "host.session.status",
/** A generic named internal event (host_deleted, user_login, ...). */
internalEvent: "internal.event",
/** A host's status dot changed (hosts/status/host-status-service.ts). */
hostStatus: "host.status",
/** A host's key was accepted again after it changed. */
hostKeyUpdated: "host.key.updated",
/** A host's connection details changed and pollers should re-read them. */
hostUpdated: "host.updated",
/** A host was deleted and any poller holding it should drop it. */
hostDeleted: "host.deleted",
/** Someone logged in over SSH to a host. */
hostLogin: "host.login",
/**
* A user's encrypted data was wiped because their DEK could not be
* recovered on password reset. The user row itself survives, unlike
* user.deleted, so a plugin with a refUser() cascade still needs this:
* the row is not gone, only unrecoverable, and should go with it.
*/
userDataWiped: "user.data_wiped",
/**
* A user account was deleted. For a plugin whose rows should outlive the
* account (evidence, audit trail) rather than cascade with a refUser()
* column: it keeps a plain userId column and anonymizes its own rows here,
* the way core's own session recordings did before this event existed.
*/
userDeleted: "user.deleted",
} as const;
export type EventListener = (payload: unknown) => void;
class PluginEventBus {
private readonly listeners = new Map<string, Set<EventListener>>();
on(topic: string, listener: EventListener): () => void {
let set = this.listeners.get(topic);
if (!set) {
set = new Set();
this.listeners.set(topic, set);
}
set.add(listener);
return () => {
set!.delete(listener);
if (set!.size === 0) this.listeners.delete(topic);
};
}
/**
* Fire-and-forget by contract. Never throws, never returns a promise the
* caller is expected to await, and one bad subscriber cannot stop the others.
*/
emit(topic: string, payload: unknown): void {
const set = this.listeners.get(topic);
if (!set || set.size === 0) return;
for (const listener of [...set]) {
try {
const result = listener(payload) as unknown;
// A listener may be async; its rejection must not become unhandled.
if (result && typeof (result as Promise<void>).catch === "function") {
void (result as Promise<void>).catch((error) =>
this.report(topic, error),
);
}
} catch (error) {
this.report(topic, error);
}
}
}
listenerCount(topic: string): number {
return this.listeners.get(topic)?.size ?? 0;
}
/** Test seam. */
clear(): void {
this.listeners.clear();
}
private report(topic: string, error: unknown): void {
pluginLogger.error(
`Event listener for "${topic}" failed`,
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_events" },
);
}
}
export const pluginEvents = new PluginEventBus();
+417
View File
@@ -0,0 +1,417 @@
/**
* HTTP for plugins: one mount point, one set of middleware, no ports.
*
* A plugin calls ctx.http.router() and gets an Express Router that core mounts
* at /plugin-api/<id>/. Everything a route needs in front of it runs here
* rather than in the plugin:
*
* 1. auth, unless the path was declared public;
* 2. the actor, so ctx and core APIs know who is calling;
* 3. an enabled check, so a disabled plugin answers 503 rather than 404;
* 4. body limits;
* 5. an error wrapper that logs against the plugin and leaks nothing.
*
* The wrapper never touches the response body, so SSE, file downloads and
* multipart uploads stream through untouched.
*/
import express, {
type NextFunction,
type Request,
type Response,
type Router,
} from "express";
import type {
PluginMiddleware,
PluginRouterOptions,
} from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import { pluginLogger } from "../utils/logger.js";
import { AuthManager } from "../utils/auth-manager.js";
import { runAsActor } from "./actor.js";
import { hasCapability } from "./permissions.js";
import { declaredPermissions, resolvePermission } from "./rbac.js";
/** Matches what core's own routes accept, so moving a route changes nothing. */
const DEFAULT_BODY_LIMIT = "2mb";
/** Routers by plugin id, read by the dispatcher in plugin-api-routes.ts. */
const routers = new Map<string, Router>();
/**
* Requests per minute one address may make to one plugin's public routes.
* They run without a login, so they are the cheap way to hammer a plugin.
*/
function publicRateLimit(): number {
const parsed = Number(process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 120;
}
const PUBLIC_RATE_WINDOW_MS = 60_000;
const publicHits = new Map<string, { count: number; resetAt: number }>();
/** True when this request is over the public route limit. */
export function overPublicRateLimit(
pluginId: string,
ip: string,
now = Date.now(),
): boolean {
if (publicHits.size > 10_000) {
for (const [hitKey, entry] of publicHits) {
if (entry.resetAt <= now) publicHits.delete(hitKey);
}
}
const hitKey = `${pluginId}|${ip}`;
const entry = publicHits.get(hitKey);
if (!entry || entry.resetAt <= now) {
publicHits.set(hitKey, { count: 1, resetAt: now + PUBLIC_RATE_WINDOW_MS });
return false;
}
entry.count++;
return entry.count > publicRateLimit();
}
/** Test seam. */
export function resetPublicRateLimits(): void {
publicHits.clear();
}
/** Declared public paths by plugin id, for the admin plugin details. */
const publicRoutes = new Map<string, string[]>();
export function getPluginPublicHttpRoutes(pluginId: string): string[] {
return [...(publicRoutes.get(pluginId) ?? [])];
}
export function getPluginRouter(pluginId: string): Router | undefined {
return routers.get(pluginId);
}
export function unregisterPluginHttp(pluginId: string): void {
publicRoutes.delete(pluginId);
if (!routers.delete(pluginId)) return;
pluginLogger.info(`Unmounted /plugin-api/${pluginId}`, {
operation: "plugin_http_unmount",
});
}
/**
* Whether the plugin is currently running.
*
* Injected rather than imported so this module does not depend on the loader,
* which imports ctx.ts, which would close a cycle.
*/
type IsEnabled = (pluginId: string) => boolean;
let isPluginEnabled: IsEnabled = () => true;
export function setPluginEnabledCheck(check: IsEnabled): void {
isPluginEnabled = check;
}
/**
* Whether a plugin with this id is installed at all, running or not. A
* disabled plugin has no router or socket routes left, so this is what tells
* "disabled" (503) apart from "no such plugin" (404).
*/
let isInstalled: IsEnabled = () => false;
export function setPluginInstalledCheck(check: IsEnabled): void {
isInstalled = check;
}
export function isPluginInstalled(pluginId: string): boolean {
return isInstalled(pluginId);
}
/** Test seam, so one test's stub does not leak into the next. */
export function resetPluginHttp(): void {
routers.clear();
publicRoutes.clear();
isPluginEnabled = () => true;
isInstalled = () => false;
}
function normalizePublicPath(path: string): string {
const trimmed = path.trim();
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
return withSlash.length > 1 && withSlash.endsWith("/")
? withSlash.slice(0, -1)
: withSlash;
}
/**
* Compiles a declared public path into a matcher.
*
* Express-style ":param" segments are supported, because the routes that have
* to be public are usually keyed by a token ("/webhook/:token"). A parameter
* matches exactly one segment and never a slash, so ":token" cannot swallow
* the rest of the path and turn one declared route into a prefix hole.
*
* A trailing "/*" is the one deliberate prefix: it matches the path itself and
* anything below it, for a proxied page that loads its own subpaths.
*/
function compilePublicPath(path: string): RegExp {
const prefix = path.endsWith("/*");
const escaped = (prefix ? path.slice(0, -2) : path)
.split("/")
.map((segment) => {
if (segment.startsWith(":")) return "[^/]+";
return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
})
.join("/");
return new RegExp(prefix ? `^${escaped}(?:/.*)?$` : `^${escaped}$`);
}
/**
* Matches the request path against the declared public paths.
*
* Full-path match only. A prefix match would turn one declared route into a
* hole covering everything below it, which is not what declaring a single
* public path should mean.
*/
function isPublicRequest(req: Request, publicPaths: RegExp[]): boolean {
if (publicPaths.length === 0) return false;
const path = normalizePublicPath(req.path || "/");
return publicPaths.some((pattern) => pattern.test(path));
}
export interface CreatePluginRouterArgs {
manifest: PluginManifest;
options?: PluginRouterOptions;
/** Records a runtime error against the plugin's error budget. */
reportError: (error: unknown) => void;
}
export function createPluginRouter({
manifest,
options,
reportError,
}: CreatePluginRouterArgs): Router {
const pluginId = manifest.id;
if (routers.has(pluginId)) {
throw new Error(
`Plugin ${pluginId} already has a router; call ctx.http.router() once`,
);
}
const declaredPublic = (options?.public ?? []).map((path) =>
normalizePublicPath(path),
);
const publicPaths = declaredPublic.map((path) => compilePublicPath(path));
publicRoutes.set(pluginId, declaredPublic);
if (declaredPublic.length > 0) {
// Audited once at registration: the set of unauthenticated routes a plugin
// has is worth seeing even if nobody ever calls them.
pluginLogger.warn(
`Plugin ${pluginId} serves ${declaredPublic.length} route(s) without authentication: ${declaredPublic.join(", ")}`,
{ operation: "plugin_http_public" },
);
void writePublicRouteAudit(manifest, declaredPublic);
}
const outer = express.Router();
const inner = express.Router();
outer.use((req: Request, res: Response, next: NextFunction) => {
if (isPluginEnabled(pluginId)) {
next();
return;
}
res.status(503).json({ error: "This feature is not available", pluginId });
});
// The grant lives in the database, so it cannot be checked in the
// synchronous router() call a plugin makes inside activate. Checked per
// request instead: a plugin whose network:serve was revoked keeps its mount
// point and refuses every request through it, which is the same outcome a
// revoke should have and takes effect without a restart.
outer.use((req: Request, res: Response, next: NextFunction) => {
void (async () => {
try {
const granted = await hasCapability(
pluginId,
"network:serve",
manifest.capabilities,
);
if (!granted) {
res.status(403).json({
error: "Plugin is not granted the network:serve capability",
pluginId,
});
return;
}
next();
} catch (error) {
next(error);
}
})();
});
const authenticate = AuthManager.getInstance().createAuthMiddleware();
outer.use((req: Request, res: Response, next: NextFunction) => {
if (isPublicRequest(req, publicPaths)) {
const ip = req.ip || req.socket?.remoteAddress || "unknown";
if (overPublicRateLimit(pluginId, ip)) {
res.status(429).json({ error: "Too many requests" });
return;
}
pluginLogger.info(`Unauthenticated request to ${pluginId}${req.path}`, {
operation: "plugin_http_public_request",
});
next();
return;
}
authenticate(req, res, next);
});
if (!options?.rawBody) {
const limit = options?.bodyLimit ?? DEFAULT_BODY_LIMIT;
// Both parsers ignore a body they do not claim, so multipart still reaches
// multer untouched. database.ts skips its own global parser for
// /plugin-api so this limit is the one that actually applies.
outer.use(express.json({ limit }));
outer.use(express.urlencoded({ limit, extended: true }));
}
// After auth, so the actor is the user core authenticated rather than
// whatever the request claimed.
outer.use((req: Request, _res: Response, next: NextFunction) => {
const { userId, sessionId } = req as Request & {
userId?: string;
sessionId?: string;
};
if (!userId) {
next();
return;
}
runAsActor(userId, "request", () => next(), sessionId);
});
outer.use(inner);
outer.use(
(
error: unknown,
req: Request,
res: Response,
_next: NextFunction,
): void => {
void _next;
const status = clientErrorStatus(error);
const authenticated = !!(req as Request & { userId?: string }).userId;
// A bad body or a 4xx is the caller's fault, and an anonymous caller
// must never be able to spend the plugin's error budget: five junk
// requests to a public route would otherwise switch the plugin off.
if (status === null && authenticated) reportError(error);
if (status !== null) {
if (!res.headersSent) {
res.status(status).json({ error: "Invalid request" });
} else {
res.end();
}
return;
}
pluginLogger.error(
`Plugin ${pluginId} route ${req.method} ${req.path} failed`,
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_http_error" },
);
if (res.headersSent) {
// Mid-stream: the only honest thing left is to end it.
res.end();
return;
}
res.status(500).json({ error: "Plugin request failed" });
},
);
routers.set(pluginId, outer);
pluginLogger.info(`Mounted /plugin-api/${pluginId}`, {
operation: "plugin_http_mount",
});
return inner;
}
/** The status of an error that is the caller's fault, or null. */
function clientErrorStatus(error: unknown): number | null {
const status =
(error as { status?: unknown; statusCode?: unknown } | null)?.status ??
(error as { statusCode?: unknown } | null)?.statusCode;
return typeof status === "number" && status >= 400 && status < 500
? status
: null;
}
async function writePublicRouteAudit(
manifest: PluginManifest,
paths: string[],
): Promise<void> {
try {
const { logAudit } = await import("../utils/audit-logger.js");
await logAudit({
userId: "system",
username: `plugin:${manifest.id}`,
action: "plugin_http_public_routes",
resourceType: "plugin",
resourceId: manifest.id,
resourceName: manifest.name,
details: `unauthenticated routes: ${paths.join(", ")}`,
success: true,
});
} catch {
// Auditing must never stop a plugin starting.
}
}
/**
* Builds ctx.rbac.require.
*
* A plugin may only gate on a permission it declares itself. Without that a
* route could require admin.users.manage and borrow someone else's authority,
* which is the escalation shape the manifest validator already blocks for
* role defaults.
*
* The 401/403 bodies match requirePermission(), so a plugin route denies the
* same way a core route does and a client needs one code path.
*/
export function createRbacMiddleware(
manifest: PluginManifest,
permission: string,
): PluginMiddleware {
const required = resolvePermission(manifest, permission);
const declared = declaredPermissions(manifest);
if (!declared.has(required)) {
throw new Error(
`Plugin ${manifest.id} cannot require permission "${permission}": it is not declared in contributes.permissions`,
);
}
return ((req: Request, res: Response, next: NextFunction) => {
void (async () => {
const userId = (req as Request & { userId?: string }).userId;
if (!userId) {
res.status(401).json({ error: "Not authenticated" });
return;
}
try {
const { PermissionManager } =
await import("../utils/permission-manager.js");
const allowed = await PermissionManager.getInstance().hasPermission(
userId,
required,
);
if (!allowed) {
res.status(403).json({ error: "Insufficient permissions", required });
return;
}
next();
} catch (error) {
next(error);
}
})();
}) as unknown as PluginMiddleware;
}
+587
View File
@@ -0,0 +1,587 @@
/**
* Composition root for the plugin runtime.
*
* Owns the loader, seeds the plugins table, keeps capability grants in step
* with each manifest, and registers the RBAC permissions a plugin contributes.
*
* The heavy dependencies are imported lazily inside each function rather than
* at module scope: several pull in the repository layer, and a static import
* here would close an import cycle through the repositories.
*/
import { pluginLogger } from "../utils/logger.js";
import {
setPluginEnabledCheck,
setPluginInstalledCheck,
unregisterPluginHttp,
} from "./http.js";
import { PluginLoader, type LoadedPlugin } from "./loader.js";
import type { PluginPermissionContribution } from "./manifest.js";
import { invalidatePluginPermissionCache } from "./permissions.js";
import { setSshAuthTypeOwnerSource } from "../hosts/connect/auth-provider-registry.js";
import { setHostProtocolSource } from "../hosts/protocol-auth/registry.js";
import { setSecretResolverOwnerSource } from "../hosts/connect/secret-resolver-registry.js";
import { recordConflict } from "./conflicts.js";
import { setPluginImpersonationCheck } from "../utils/auth-manager.js";
import { setKeybindingActionSource } from "../database/routes/keybinding-validation.js";
let loader: PluginLoader | null = null;
export function getPluginRuntime(): { loader: PluginLoader } {
if (!loader) {
loader = new PluginLoader({
onFailed: (plugin) => void handlePluginFailed(plugin),
});
// The 503-while-disabled answer comes from here rather than from the
// router being torn down, so a request that arrives mid-disable gets a
// truthful status instead of a 404.
setPluginEnabledCheck(
(pluginId) => loader?.get(pluginId)?.state === "active",
);
setPluginInstalledCheck((pluginId) => !!loader?.get(pluginId));
// An admin acting for another user reaches only the plugins that opted in.
setPluginImpersonationCheck((pluginId) => {
const plugin = loader?.get(pluginId);
return (
plugin?.state === "active" &&
plugin.manifest.contributes?.http?.adminImpersonation === true
);
});
// Saved keybindings are checked against every installed plugin's
// declarations, so a binding keeps validating while its plugin is off.
setKeybindingActionSource(() =>
(loader?.list() ?? []).flatMap((plugin) =>
(plugin.manifest.contributes?.keybindingActions ?? []).map(
(action) => ({ ...action, pluginId: plugin.id }),
),
),
);
// Protocol logins stay readable, shareable and syncable while their
// plugin is off.
setHostProtocolSource(() =>
(loader?.list() ?? []).flatMap((plugin) =>
(plugin.manifest.contributes?.protocols ?? []).map((protocol) => ({
...protocol,
pluginId: plugin.id,
pluginName: plugin.manifest.name,
})),
),
);
// Lets a host whose auth type belongs to a disabled plugin name it.
setSshAuthTypeOwnerSource(() =>
(loader?.list() ?? []).flatMap((plugin) =>
(plugin.manifest.contributes?.auth?.sshAuthTypes ?? []).map((type) => ({
type,
pluginId: plugin.id,
pluginName: plugin.manifest.name,
})),
),
);
// Lets a secret reference whose scheme belongs to a disabled plugin name it.
setSecretResolverOwnerSource(() =>
(loader?.list() ?? []).flatMap((plugin) =>
(plugin.manifest.contributes?.auth?.secretSchemes ?? []).map(
(scheme) => ({
scheme,
pluginId: plugin.id,
pluginName: plugin.manifest.name,
}),
),
),
);
}
return { loader };
}
/**
* A plugin that tripped the error budget is off until an admin retries it, so
* it has to look off everywhere: no routes, greyed permissions, and a failed
* row that survives a restart.
*/
async function handlePluginFailed(plugin: LoadedPlugin): Promise<void> {
unregisterPluginHttp(plugin.id);
try {
if (plugin.manifest.contributes?.permissions?.length) {
const { markPluginPermissionsDisabled } =
await import("../utils/permission-catalog.js");
markPluginPermissionsDisabled(plugin.id);
}
await persistRuntimeState([plugin]);
} catch (error) {
pluginLogger.error(
`Could not record that plugin ${plugin.id} failed`,
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_runtime" },
);
}
}
/**
* Gives every discovered plugin a row, and refreshes the stored manifest.
*
* Bundled plugins start enabled because they are part of the install; a
* plugin dropped into the data directory starts disabled, so arriving on disk
* is never the same as being allowed to run.
*
* `state` is only ever set on insert. Once the row exists whatever the user
* chose wins, because re-enabling a plugin they disabled on every restart
* would be a bug rather than a default.
*/
async function seedPlugins(loaded: LoadedPlugin[]): Promise<void> {
const { createCurrentPluginRepository } =
await import("../database/repositories/factory.js");
const repository = createCurrentPluginRepository();
for (const plugin of loaded) {
const manifestJson = JSON.stringify(plugin.manifest);
const existing = await repository.findById(plugin.id);
if (existing) {
// Unconditionally, not just on a version change: a manifest edited
// without a version bump used to leave the stored copy stale forever,
// and the API and the grant check both read the stored copy.
await repository.update(plugin.id, {
name: plugin.manifest.name,
version: plugin.manifest.version,
source: plugin.source,
manifestJson,
});
continue;
}
await repository.create({
id: plugin.id,
name: plugin.manifest.name,
version: plugin.manifest.version,
tier: plugin.source === "bundled" ? "bundled" : "community",
source: plugin.source,
state: plugin.source === "bundled" ? "enabled" : "disabled",
manifestJson,
});
pluginLogger.info(`Registered ${plugin.source} plugin ${plugin.id}`, {
operation: "plugin_seed",
});
}
}
/**
* Brings plugin_permission_grants in line with the manifests on disk.
*
* A capability the manifest no longer declares is revoked: leaving it granted
* would mean an upgrade silently kept a permission the new version never
* asked for. Bundled plugins have every declared capability granted
* automatically, since shipping in the install is the consent.
*/
async function syncCapabilityGrants(loaded: LoadedPlugin[]): Promise<void> {
const { createCurrentPluginPermissionGrantRepository } =
await import("../database/repositories/factory.js");
const repository = createCurrentPluginPermissionGrantRepository();
for (const plugin of loaded) {
const declared = new Set(plugin.manifest.capabilities);
const existing = await repository.listByPlugin(plugin.id);
for (const grant of existing) {
if (declared.has(grant.capability)) continue;
await repository.revoke(plugin.id, grant.capability);
pluginLogger.info(
`Revoked ${plugin.id} capability "${grant.capability}": the manifest no longer declares it`,
{ operation: "plugin_grants" },
);
}
if (plugin.source !== "bundled") continue;
const granted = new Set(existing.map((grant) => grant.capability));
for (const capability of declared) {
if (granted.has(capability)) continue;
await repository.grant({
pluginId: plugin.id,
capability,
grantedBy: null,
source: "bundled",
});
}
invalidatePluginPermissionCache(plugin.id);
}
}
/**
* Loads every plugin on disk and activates the ones marked enabled, in
* dependency order. Called once from the backend start-up sequence.
*/
export async function initializePlugins(): Promise<LoadedPlugin[]> {
const { loader: pluginLoader } = getPluginRuntime();
const loaded = await pluginLoader.loadAll();
if (loaded.length === 0) return [];
await seedPlugins(loaded);
await syncCapabilityGrants(loaded);
const { createCurrentPluginRepository } =
await import("../database/repositories/factory.js");
const records = await createCurrentPluginRepository().listAll();
const enabled = new Set(
records
.filter((record) => record.state === "enabled")
.map((record) => record.id),
);
const candidates = loaded
.filter((plugin) => enabled.has(plugin.id))
.map((plugin) => plugin.id);
for (const pluginId of candidates) {
const plugin = pluginLoader.get(pluginId);
if (plugin) await registerPluginPermissions(plugin);
}
const result = await pluginLoader.activateAll(candidates);
await persistRuntimeState(loaded);
if (result.blocked.size > 0 || result.failed.size > 0) {
pluginLogger.warn(
`${result.blocked.size} plugin(s) blocked, ${result.failed.size} failed to activate`,
{ operation: "plugin_init" },
);
}
return loaded;
}
/** Mirrors loader state into the database so the admin API can report it. */
async function persistRuntimeState(loaded: LoadedPlugin[]): Promise<void> {
const { createCurrentPluginRepository } =
await import("../database/repositories/factory.js");
const repository = createCurrentPluginRepository();
for (const plugin of loaded) {
if (plugin.state !== "blocked" && plugin.state !== "failed") continue;
try {
await repository.update(plugin.id, {
state: plugin.state,
lastError: plugin.lastError,
});
} catch {
// Reporting state must never stop the boot.
}
}
}
/**
* Puts a plugin's declared permissions into the role catalog.
*
* Each name is registered as `<pluginId>.<name>`, so a plugin cannot claim a
* core group or another plugin's namespace. Until this runs a plugin permission
* cannot be granted at all, because PUT /rbac/roles/:id rejects any string
* isValidPermission does not know.
*
* The qualified ids are also written to rbac_known_permissions, which is what
* keeps a role holding one valid after the plugin is disabled or removed.
*/
async function registerPluginPermissions(plugin: LoadedPlugin): Promise<void> {
const declared = plugin.manifest.contributes?.permissions;
if (!declared || declared.length === 0) return;
try {
const {
registerPluginPermissions: register,
PERMISSION_CATALOG,
getPermissionCatalog,
rememberPermissions,
} = await import("../utils/permission-catalog.js");
const { qualifyPermission, RESERVED_PERMISSION_PREFIXES } =
await import("./manifest.js");
// The manifest validator blocks core groups and the plugin's own id, but
// it cannot know which other plugins exist. Re-checked here because the
// stored manifest is data.
const coreGroups = new Set<string>([
...RESERVED_PERMISSION_PREFIXES,
...PERMISSION_CATALOG.map((entry) => entry.group),
]);
// Both the loaded plugins and any namespace already in the catalog: a
// disabled plugin still owns its permissions, so claiming them while it is
// off would let the namespace change hands.
const otherPluginIds = new Set(
[
...getPluginRuntime()
.loader.list()
.map((candidate) => candidate.id),
...getPermissionCatalog()
.filter((entry) => entry.pluginId !== undefined)
.map((entry) => entry.group),
].filter((id) => id !== plugin.id),
);
// The group is the plugin id, so a plugin named after a core group would
// register that group's permissions. The manifest validator refuses the
// id too; this holds for a manifest that skipped it.
if (coreGroups.has(plugin.id)) {
recordConflict({
kind: "permission",
pluginId: plugin.id,
heldBy: "core",
name: plugin.id,
});
pluginLogger.error(
`Ignoring every ${plugin.id} permission: its id is a core permission group`,
undefined,
{ operation: "plugin_permissions" },
);
return;
}
const accepted = declared.filter((permission) => {
const head = permission.name.split(".")[0];
if (coreGroups.has(head) || otherPluginIds.has(head)) {
recordConflict({
kind: "permission",
pluginId: plugin.id,
heldBy: coreGroups.has(head) ? "core" : head,
name: permission.name,
});
pluginLogger.error(
`Ignoring ${plugin.id} permission "${permission.name}": it starts with "${head}", which belongs to someone else`,
undefined,
{ operation: "plugin_permissions" },
);
return false;
}
return true;
});
if (accepted.length === 0) return;
const items = accepted.map((permission) => ({
permission: qualifyPermission(plugin.id, permission.name),
titleKey: permission.titleKey,
descriptionKey: permission.descriptionKey,
}));
register({
group: plugin.id,
pluginId: plugin.id,
label: plugin.manifest.name,
icon: plugin.manifest.icon,
enabled: true,
permissions: items.map((item) => item.permission),
items,
});
rememberPermissions(items.map((item) => item.permission));
const { createCurrentRbacPermissionRepository } =
await import("../database/repositories/factory.js");
await createCurrentRbacPermissionRepository().recordKnown(
items.map((item) => ({
permission: item.permission,
pluginId: plugin.id,
})),
);
await applyRoleDefaults(plugin.id, accepted);
} catch (error) {
pluginLogger.error(
`Failed to register permissions for ${plugin.id}`,
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_permissions" },
);
}
}
/**
* Applies a plugin's declared role suggestion, once.
*
* Only the seeded system roles are eligible, and only permissions the plugin
* declares itself. Each default is applied at most once and the fact is
* recorded in rbac_applied_defaults, so an admin who later revokes it does not
* get it handed back on the next restart. That ledger lives in core rather than
* in plugin_storage, which cascades with the plugin: uninstall-then-reinstall
* used to silently re-add a permission that had been deliberately removed.
*/
async function applyRoleDefaults(
pluginId: string,
declared: PluginPermissionContribution[],
): Promise<void> {
const wanted = declared.filter(
(permission) => (permission.defaultRoles ?? []).length > 0,
);
if (wanted.length === 0) return;
const { createCurrentRoleRepository, createCurrentRbacPermissionRepository } =
await import("../database/repositories/factory.js");
const { PermissionManager } = await import("../utils/permission-manager.js");
const { qualifyPermission, SYSTEM_ROLE_NAMES } =
await import("./manifest.js");
const roleRepository = createCurrentRoleRepository();
const rbacRepository = createCurrentRbacPermissionRepository();
const alreadyApplied = new Set(
(await rbacRepository.listAppliedDefaults()).map(
(row) => `${row.roleName}:${row.permission}`,
),
);
const byRole = new Map<string, string[]>();
for (const permission of wanted) {
for (const role of permission.defaultRoles ?? []) {
if (!(SYSTEM_ROLE_NAMES as readonly string[]).includes(role)) continue;
const id = qualifyPermission(pluginId, permission.name);
byRole.set(role, [...(byRole.get(role) ?? []), id]);
}
}
const recorded: { roleName: string; permission: string }[] = [];
for (const [roleName, permissions] of byRole) {
try {
const role = await roleRepository.findRoleByName(roleName);
if (!role) continue;
let current: unknown;
try {
current = role.permissions ? JSON.parse(role.permissions) : [];
} catch {
continue;
}
if (!Array.isArray(current)) continue;
const missing = permissions.filter((permission) => {
// Applied before means the admin has had the chance to remove it, so
// its absence now is a decision rather than a gap.
if (alreadyApplied.has(`${roleName}:${permission}`)) return false;
return !coveredBy(current as string[], permission);
});
if (missing.length > 0) {
await roleRepository.updateRole(role.id, {
permissions: JSON.stringify([...(current as string[]), ...missing]),
});
}
// Only once the role really has them, so a failed update is retried
// on the next boot instead of being marked done.
for (const permission of permissions) {
recorded.push({ roleName, permission });
}
if (missing.length === 0) continue;
for (const memberId of await roleRepository.listRoleUserIds(role.id)) {
PermissionManager.getInstance().invalidateUserPermissionCache(memberId);
}
} catch (error) {
pluginLogger.warn(
`Could not apply ${pluginId} role defaults for ${roleName}: ${
error instanceof Error ? error.message : String(error)
}`,
{ operation: "plugin_permissions" },
);
}
}
try {
await rbacRepository.recordAppliedDefaults(recorded);
} catch {
// Losing the record only means a default may be re-offered once.
}
}
function coveredBy(permissions: string[], permission: string): boolean {
if (permissions.includes("*") || permissions.includes(permission)) {
return true;
}
const parts = permission.split(".");
for (let i = parts.length; i > 0; i--) {
if (permissions.includes(`${parts.slice(0, i).join(".")}.*`)) return true;
}
return false;
}
export async function activatePlugin(pluginId: string): Promise<void> {
const { loader: pluginLoader } = getPluginRuntime();
// Before activate: a plugin's own activate() may already want to check one
// of its permissions.
const plugin = pluginLoader.get(pluginId);
if (plugin) await registerPluginPermissions(plugin);
await pluginLoader.activate(pluginId);
// A plugin enabled without a restart still needs its 2.8 data copied.
const { runPluginDataMigrations } =
await import("../upgrade/boot-migrations.js");
await runPluginDataMigrations();
}
export async function deactivatePlugin(pluginId: string): Promise<void> {
const { loader: pluginLoader } = getPluginRuntime();
const plugin = pluginLoader.get(pluginId);
await pluginLoader.deactivate(pluginId);
unregisterPluginHttp(pluginId);
// The group stays in the catalog, greyed: a role holding one of these is not
// wrong just because the plugin is off, and removing them made every role
// that held one unsaveable.
if (plugin?.manifest.contributes?.permissions?.length) {
const { markPluginPermissionsDisabled } =
await import("../utils/permission-catalog.js");
markPluginPermissionsDisabled(pluginId);
}
}
/** Switches a plugin on or off and records it, the same as the admin route. */
export async function setPluginEnabled(
pluginId: string,
enabled: boolean,
): Promise<void> {
const { createCurrentPluginRepository } =
await import("../database/repositories/factory.js");
await createCurrentPluginRepository().update(pluginId, {
state: enabled ? "enabled" : "disabled",
lastError: null,
});
if (enabled) await activatePlugin(pluginId);
else await deactivatePlugin(pluginId);
}
/**
* Loads a user plugin from a .tmxplug in the plugins directory and gives it
* a row. It starts disabled, like any plugin that arrives on disk. A copy
* already loaded under the same id is stopped and replaced.
*/
export async function installPluginArtifact(
file: string,
): Promise<LoadedPlugin> {
const { loader: pluginLoader } = getPluginRuntime();
const plugin = await pluginLoader.loadArtifact(
file,
pluginLoader.bundledIds(),
);
await seedPlugins([plugin]);
await syncCapabilityGrants([plugin]);
return plugin;
}
/** Stops a plugin and forgets it, before a new version is installed. */
export async function unloadPlugin(pluginId: string): Promise<void> {
const { loader: pluginLoader } = getPluginRuntime();
if (!pluginLoader.get(pluginId)) return;
await deactivatePlugin(pluginId);
pluginLoader.forget(pluginId);
}
export async function shutdownPlugins(): Promise<void> {
if (!loader) return;
for (const plugin of loader.list()) unregisterPluginHttp(plugin.id);
await loader.shutdown();
}
/** Test seam. */
export function resetPluginRuntime(): void {
loader = null;
}
+832
View File
@@ -0,0 +1,832 @@
/**
* Discovers plugins, resolves their dependency order, and owns their
* lifecycle.
*
* One tier. Every plugin, bundled or installed, is imported into the server
* process and handed a ctx built by ctx.ts. There is no worker boundary and
* no allowlist, because the worker tier had no users and the allowlist was a
* hardcoded set of twelve ids that every real plugin was already on.
*
* What that means honestly: a plugin has the same reach as core. The
* capability gate on ctx makes privileged calls declared and auditable, not
* impossible.
*/
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import semver from "semver";
import * as tar from "tar";
import { pluginLogger } from "../utils/logger.js";
import { isTermixCompatible, parseManifest } from "./manifest.js";
import { getLocalVersion } from "../utils/app-version.js";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import {
getBundledPluginsDir,
getPluginBackendEntry,
getPluginManifestPath,
getPluginsDir,
getUnpackedPluginsDir,
} from "./paths.js";
import { requireSignedPlugins, verifyPluginArtifact } from "./trust.js";
import {
createPluginContext,
createPluginHandle,
disposePluginHandle,
type PluginHandle,
type PluginModule,
} from "./ctx.js";
import { resolveRequirements } from "./service-registry.js";
import { resolveSecretRequirements } from "./secret-registry.js";
import { recordConflict } from "./conflicts.js";
import { tablePrefix } from "@termix/plugin-sdk/db";
export type PluginState =
| "loaded"
| "activating"
| "active"
| "stopping"
| "stopped"
| "blocked"
| "failed";
export type PluginSource = "bundled" | "user";
const ACTIVATION_TIMEOUT_MS = 30_000;
/** Defaults for the runtime error budget. Both are configurable. */
const DEFAULT_ERROR_THRESHOLD = 5;
const DEFAULT_ERROR_WINDOW_MS = 60_000;
export interface LoadedPlugin {
id: string;
dir: string;
source: PluginSource;
manifest: PluginManifest;
state: PluginState;
handle: PluginHandle | null;
lastError: string | null;
/** Timestamps of recent runtime errors, trimmed to the window. */
errorTimestamps: number[];
/**
* The .tmxplug this copy was unpacked from. Set for user plugins and for a
* signed update that replaced a bundled plugin.
*/
artifact?: string;
/** Key id that signed the artifact, when a trusted key signed it. */
signedBy?: string;
}
export interface PluginLoaderOptions {
errorThreshold?: number;
errorWindowMs?: number;
/** Called when a plugin trips the error budget and is torn down. */
onFailed?: (plugin: LoadedPlugin) => void;
}
export class PluginLoader {
private readonly plugins = new Map<string, LoadedPlugin>();
/** Version of each bundled plugin as shipped, the floor for any update. */
private readonly bundledVersions = new Map<string, string>();
/** Activation order, so shutdown can run it backwards. */
private activationOrder: string[] = [];
constructor(private readonly options: PluginLoaderOptions = {}) {}
list(): LoadedPlugin[] {
return [...this.plugins.values()];
}
get(pluginId: string): LoadedPlugin | undefined {
return this.plugins.get(pluginId);
}
/** Ids of the plugins that ship with this install, loaded or replaced. */
bundledIds(): Set<string> {
return new Set([
...this.bundledVersions.keys(),
...this.list()
.filter((plugin) => plugin.source === "bundled")
.map((plugin) => plugin.id),
]);
}
/** Drops a stopped plugin from the list, so a new copy can load in its place. */
forget(pluginId: string): void {
const plugin = this.plugins.get(pluginId);
if (plugin?.state === "active" || plugin?.state === "activating") {
throw new Error(`Plugin ${pluginId} is still running`);
}
this.plugins.delete(pluginId);
}
/** Reads and validates one plugin directory. Starts nothing. */
async load(dir: string, source: PluginSource): Promise<LoadedPlugin> {
const manifestPath = getPluginManifestPath(dir);
let raw: unknown;
try {
raw = JSON.parse(await fs.promises.readFile(manifestPath, "utf8"));
} catch (error) {
throw new Error(
`Could not read plugin manifest at ${manifestPath}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
const { manifest, errors } = parseManifest(raw);
if (!manifest) {
throw new Error(
`Invalid plugin manifest at ${manifestPath}:\n - ${errors.join("\n - ")}`,
);
}
if (!isTermixCompatible(manifest.engine.termix, getLocalVersion())) {
throw new Error(
`Plugin ${manifest.id} needs Termix ${manifest.engine.termix}, but this is ${getLocalVersion()}`,
);
}
if (path.basename(dir) !== manifest.id) {
throw new Error(
`Plugin directory "${path.basename(dir)}" does not match manifest id "${manifest.id}"`,
);
}
const entry = getPluginBackendEntry(dir, manifest);
if (!fs.existsSync(entry)) {
throw new Error(
`Plugin ${manifest.id} backend entry ${entry} is missing`,
);
}
// A symlink can point anywhere, so the real path has to stay inside too.
if (!isRealPathInside(dir, entry)) {
throw new Error(
`Plugin ${manifest.id} backend entry resolves outside the plugin directory`,
);
}
if (this.plugins.has(manifest.id)) {
throw new Error(`another plugin already uses the id "${manifest.id}"`);
}
const overlap = [...this.plugins.values()].find((other) =>
prefixesOverlap(other.id, manifest.id),
);
if (overlap) {
recordConflict({
kind: "id",
pluginId: manifest.id,
heldBy: overlap.id,
name: tablePrefix(manifest.id),
});
throw new Error(
`Plugin id "${manifest.id}" overlaps "${overlap.id}": their table names would share the prefix ${tablePrefix(overlap.id)}`,
);
}
const plugin: LoadedPlugin = {
id: manifest.id,
dir,
source,
manifest,
state: "loaded",
handle: null,
lastError: null,
errorTimestamps: [],
};
this.plugins.set(manifest.id, plugin);
pluginLogger.info(`Loaded plugin ${manifest.id}@${manifest.version}`, {
operation: "plugin_load",
});
return plugin;
}
/**
* Scans both roots. A user plugin folder can never shadow a bundled one: the
* collision is rejected with an error rather than silently skipped.
*
* The one way to replace a bundled plugin is a .tmxplug signed by a trusted
* key with a higher version (see loadArtifact). That is how one official
* plugin ships an update without a core release.
*/
async loadAll(): Promise<LoadedPlugin[]> {
this.plugins.clear();
this.bundledVersions.clear();
const loaded: LoadedPlugin[] = [];
// Every bundled folder name, loaded or not: a bundled plugin that fails
// to load must not leave its id free for a user plugin to take.
const bundledIds = new Set<string>();
const roots: Array<{ root: string; source: PluginSource }> = [
{ root: getBundledPluginsDir(), source: "bundled" },
{ root: getPluginsDir(), source: "user" },
];
for (const { root, source } of roots) {
if (!fs.existsSync(root)) continue;
const entries = await fs.promises.readdir(root, { withFileTypes: true });
if (source === "bundled") {
for (const entry of entries) {
if (entry.isDirectory()) bundledIds.add(entry.name);
}
}
for (const entry of entries) {
const isArtifact =
source === "user" &&
entry.isFile() &&
entry.name.endsWith(".tmxplug");
if (!entry.isDirectory() && !isArtifact) continue;
if (entry.isDirectory() && entry.name.startsWith(".")) continue;
const dir = path.join(root, entry.name);
try {
if (isArtifact) {
const plugin = await this.loadArtifact(dir, bundledIds);
const index = loaded.findIndex((other) => other.id === plugin.id);
if (index >= 0) loaded[index] = plugin;
else loaded.push(plugin);
continue;
}
if (source === "user" && bundledIds.has(entry.name)) {
throw new Error(
`a bundled plugin already uses the id "${entry.name}"; only a signed .tmxplug with a higher version can replace it`,
);
}
// A folder carries no signature, so with signing required only a
// signed .tmxplug can install a plugin. Bundled plugins are trusted
// by shipping in the image.
if (source === "user" && requireSignedPlugins()) {
throw new Error(
"TERMIX_REQUIRE_SIGNED_PLUGINS is on and a plugin folder carries no signature; install it as a signed .tmxplug",
);
}
const plugin = await this.load(dir, source);
if (source === "bundled") {
this.bundledVersions.set(plugin.id, plugin.manifest.version);
}
loaded.push(plugin);
} catch (error) {
pluginLogger.error(
`Skipping plugin directory ${entry.name} in ${root}`,
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_load" },
);
}
}
}
return loaded;
}
/**
* Loads a user-installed <name>.tmxplug. A .sig next to it must verify
* against a pinned key; with TERMIX_REQUIRE_SIGNED_PLUGINS=true it must
* also exist. The archive is unpacked fresh on every load, so the file
* stays the only source of truth.
*
* An artifact with a bundled plugin's id is an update to it. It replaces
* the bundled copy only when a trusted key signed it and its version is
* higher; if it then fails to load the bundled copy stays.
*/
async loadArtifact(
file: string,
bundledIds: ReadonlySet<string> = new Set(),
): Promise<LoadedPlugin> {
const buffer = await fs.promises.readFile(file);
const sigFile = `${file}.sig`;
let signedBy: string | undefined;
if (fs.existsSync(sigFile)) {
const sha256 = crypto.createHash("sha256").update(buffer).digest("hex");
const result = verifyPluginArtifact(
buffer,
sha256,
await fs.promises.readFile(sigFile, "utf8"),
);
if (result.ok === false) {
throw new Error(`signature check failed: ${result.reason}`);
}
signedBy = result.keyId;
} else if (requireSignedPlugins()) {
throw new Error(
`TERMIX_REQUIRE_SIGNED_PLUGINS is on and ${path.basename(file)} has no .sig next to it`,
);
}
const unpackedRoot = getUnpackedPluginsDir();
const staging = path.join(
unpackedRoot,
`.staging-${crypto.randomBytes(6).toString("hex")}`,
);
await fs.promises.mkdir(staging, { recursive: true });
try {
await tar.x({
file,
cwd: staging,
strict: true,
// Plain files and folders only. tar already refuses absolute and
// ".." paths unless preservePaths is set.
filter: (_entryPath, entry) =>
"type" in entry &&
(entry.type === "File" || entry.type === "Directory"),
});
const raw = JSON.parse(
await fs.promises.readFile(getPluginManifestPath(staging), "utf8"),
);
const id = typeof raw?.id === "string" ? raw.id : "";
if (!PLUGIN_ID_PATTERN.test(id)) {
throw new Error(`${path.basename(file)} has no valid manifest id`);
}
const isUpdate = bundledIds.has(id);
const replaced = isUpdate ? this.plugins.get(id) : undefined;
if (isUpdate) {
assertBundledUpdate(
id,
raw?.version,
signedBy,
this.bundledVersions.get(id) ?? replaced?.manifest.version,
);
} else if (this.plugins.has(id)) {
throw new Error(`another plugin already uses the id "${id}"`);
}
if (replaced && replaced.state !== "loaded") {
throw new Error(`plugin ${id} is running; stop it before updating`);
}
const target = path.join(unpackedRoot, id);
await fs.promises.rm(target, { recursive: true, force: true });
await fs.promises.rename(staging, target);
if (replaced) this.plugins.delete(id);
try {
// A signed update keeps the bundled trust it replaces.
const plugin = await this.load(target, isUpdate ? "bundled" : "user");
plugin.artifact = file;
plugin.signedBy = signedBy;
if (replaced) {
pluginLogger.info(
`Plugin ${id} updated from ${replaced.manifest.version} to ${plugin.manifest.version}`,
{ operation: "plugin_load" },
);
}
return plugin;
} catch (error) {
if (replaced) this.plugins.set(id, replaced);
throw error;
}
} finally {
await fs.promises.rm(staging, { recursive: true, force: true });
}
}
/**
* Orders plugins so a dependency always activates before its dependents.
*
* Returns the order plus the plugins that cannot run: one with a missing
* hard dependency is blocked (recoverable, the dependency may be installed
* later), one in a cycle is failed (nothing to wait for).
*/
resolveOrder(candidates: string[]): {
order: string[];
blocked: Map<string, string>;
cycles: Map<string, string>;
} {
const wanted = new Set(candidates);
const blocked = new Map<string, string>();
const cycles = new Map<string, string>();
const order: string[] = [];
const state = new Map<string, "visiting" | "done">();
const visit = (id: string, trail: string[]): boolean => {
if (state.get(id) === "done") return true;
if (state.get(id) === "visiting") {
const cycle = [...trail.slice(trail.indexOf(id)), id].join(" -> ");
for (const member of trail.slice(trail.indexOf(id))) {
cycles.set(member, `dependency cycle: ${cycle}`);
}
return false;
}
const plugin = this.plugins.get(id);
if (!plugin) return false;
state.set(id, "visiting");
for (const [dependencyId, range] of Object.entries(
plugin.manifest.dependencies ?? {},
)) {
const dependency = this.plugins.get(dependencyId);
if (!dependency) {
blocked.set(
id,
`requires plugin "${dependencyId}", which is not installed`,
);
state.set(id, "done");
return false;
}
if (!semver.satisfies(dependency.manifest.version, range)) {
blocked.set(
id,
`requires "${dependencyId}" ${range}, but ${dependency.manifest.version} is installed`,
);
state.set(id, "done");
return false;
}
if (!wanted.has(dependencyId)) {
blocked.set(
id,
`requires plugin "${dependencyId}", which is not enabled`,
);
state.set(id, "done");
return false;
}
if (!visit(dependencyId, [...trail, id])) {
if (!cycles.has(id)) {
blocked.set(
id,
`requires plugin "${dependencyId}", which could not start`,
);
}
state.set(id, "done");
return false;
}
}
// Optional dependencies only affect ordering. A missing one is normal,
// and the plugin must keep working without it.
for (const dependencyId of Object.keys(
plugin.manifest.optionalDependencies ?? {},
)) {
if (wanted.has(dependencyId) && this.plugins.has(dependencyId)) {
visit(dependencyId, [...trail, id]);
}
}
state.set(id, "done");
if (!blocked.has(id) && !cycles.has(id)) order.push(id);
return !blocked.has(id) && !cycles.has(id);
};
for (const id of candidates) visit(id, []);
return { order, blocked, cycles };
}
async activate(pluginId: string): Promise<void> {
const plugin = this.requirePlugin(pluginId);
if (plugin.state === "active" || plugin.state === "activating") return;
plugin.state = "activating";
plugin.lastError = null;
const entryPath = getPluginBackendEntry(plugin.dir, plugin.manifest);
// Node caches ESM by URL, so a plugin replaced without a restart would
// otherwise keep running the old code.
const entry = `${pathToFileUrlString(entryPath)}?v=${encodeURIComponent(
`${plugin.manifest.version}-${fileStamp(entryPath)}`,
)}`;
try {
const imported = (await import(entry)) as {
activate?: PluginModule["activate"];
deactivate?: PluginModule["deactivate"];
default?: PluginModule;
};
const activate = imported.activate ?? imported.default?.activate;
const deactivate = imported.deactivate ?? imported.default?.deactivate;
if (typeof activate !== "function") {
throw new Error(
`Plugin ${plugin.id} backend entry does not export an activate(ctx) function`,
);
}
// Structural only: is the service present at a satisfying version. A
// user's permission is checked per call instead, because activation is
// per-instance and permissions are per-user.
const resolution = resolveRequirements(plugin.manifest);
if (!resolution.satisfied) {
throw new Error(`Plugin ${plugin.id} ${resolution.errors.join("; ")}`);
}
for (const service of resolution.missingOptional) {
pluginLogger.info(
`Plugin ${plugin.id} optional service "${service}" is not available`,
{ operation: "plugin_activate" },
);
}
// A borrowed secret resolves per call and returns null when absent, so a
// provider installed later starts working with no restart. Refusing to
// start here would turn a recoverable gap into an install ordering rule.
for (const reference of resolveSecretRequirements(plugin.manifest)
.unavailable) {
pluginLogger.info(
`Plugin ${plugin.id} shared secret "${reference}" is not currently offered; reads will resolve to null`,
{ operation: "plugin_activate" },
);
}
// Before activate, not inside it: a plugin's first line may query its
// own tables, and a migration that fails should stop it starting rather
// than leave it half-running against a schema that is not there. A
// throw here lands in the catch below, which fails this plugin only.
const { migratePlugin } = await import("./data.js");
const applied = await migratePlugin(plugin.id, plugin.dir, {
bundled: plugin.source === "bundled",
});
// A new table may be where core data is waiting to move.
if (applied.length > 0) {
const { runPluginDataMoves } =
await import("../upgrade/plugin-data-moves.js");
await runPluginDataMoves();
}
const handle = createPluginHandle(plugin.id, { activate, deactivate });
const ctx = createPluginContext(plugin.manifest, handle);
// Attached before activate runs, not after: a plugin that registers a
// few things and then throws still has to be cleaned up, and the catch
// below can only do that if it can reach the handle.
plugin.handle = handle;
await withTimeout(
Promise.resolve(activate(ctx)),
ACTIVATION_TIMEOUT_MS,
`Plugin ${plugin.id} did not activate within ${ACTIVATION_TIMEOUT_MS}ms`,
);
plugin.state = "active";
plugin.errorTimestamps = [];
if (!this.activationOrder.includes(plugin.id)) {
this.activationOrder.push(plugin.id);
}
pluginLogger.success(`Activated plugin ${plugin.id}`, {
operation: "plugin_activate",
});
} catch (error) {
plugin.lastError = error instanceof Error ? error.message : String(error);
plugin.state = "failed";
// Activation may have registered things before it threw. Dispose them
// rather than leaving a half-started plugin holding resources, but do
// not call the plugin's own deactivate: it never finished starting.
if (plugin.handle) {
await disposePluginHandle(plugin.handle, plugin.id, {
runDeactivate: false,
});
plugin.handle = null;
}
throw error instanceof Error ? error : new Error(plugin.lastError);
}
}
/** Activates a set of plugins in dependency order. */
async activateAll(pluginIds: string[]): Promise<{
activated: string[];
blocked: Map<string, string>;
failed: Map<string, string>;
}> {
const { order, blocked, cycles } = this.resolveOrder(pluginIds);
const activated: string[] = [];
const failed = new Map<string, string>(cycles);
for (const [id, reason] of blocked) {
const plugin = this.plugins.get(id);
if (plugin) {
plugin.state = "blocked";
plugin.lastError = reason;
}
pluginLogger.warn(`Plugin ${id} is blocked: ${reason}`, {
operation: "plugin_activate",
});
}
for (const [id, reason] of cycles) {
const plugin = this.plugins.get(id);
if (plugin) {
plugin.state = "failed";
plugin.lastError = reason;
}
pluginLogger.error(`Plugin ${id} cannot start`, new Error(reason), {
operation: "plugin_activate",
});
}
for (const id of order) {
try {
await this.activate(id);
activated.push(id);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
failed.set(id, message);
pluginLogger.error(
`Failed to activate plugin ${id}`,
error instanceof Error ? error : new Error(message),
{ operation: "plugin_activate" },
);
}
}
return { activated, blocked, failed };
}
async deactivate(pluginId: string): Promise<void> {
const plugin = this.requirePlugin(pluginId);
this.activationOrder = this.activationOrder.filter((id) => id !== pluginId);
if (!plugin.handle) {
plugin.state = "stopped";
return;
}
plugin.state = "stopping";
const handle = plugin.handle;
plugin.handle = null;
// Disposal never throws, so a plugin whose deactivate() misbehaves still
// ends up stopped rather than stuck in "stopping" forever.
await disposePluginHandle(handle, plugin.id);
plugin.state = "stopped";
}
/**
* Records a runtime error against a plugin. Past the threshold inside the
* window the plugin is marked failed and torn down, because a plugin
* throwing on every call is worse than one that is off.
*/
async reportError(pluginId: string, error: unknown): Promise<void> {
const plugin = this.plugins.get(pluginId);
if (!plugin) return;
const message = error instanceof Error ? error.message : String(error);
plugin.lastError = message;
pluginLogger.error(
`Plugin ${pluginId} threw at runtime`,
error instanceof Error ? error : new Error(message),
{ operation: "plugin_runtime" },
);
const windowMs = this.options.errorWindowMs ?? DEFAULT_ERROR_WINDOW_MS;
const threshold = this.options.errorThreshold ?? DEFAULT_ERROR_THRESHOLD;
const now = Date.now();
plugin.errorTimestamps = [
...plugin.errorTimestamps.filter((at) => now - at < windowMs),
now,
];
if (plugin.errorTimestamps.length < threshold) return;
pluginLogger.error(
`Plugin ${pluginId} failed ${plugin.errorTimestamps.length} times in ${windowMs}ms and has been stopped`,
new Error(message),
{ operation: "plugin_runtime" },
);
await this.deactivate(pluginId);
plugin.state = "failed";
plugin.lastError = message;
plugin.errorTimestamps = [];
this.options.onFailed?.(plugin);
}
/** Clears the error budget and starts the plugin again. */
async retry(pluginId: string): Promise<void> {
const plugin = this.requirePlugin(pluginId);
if (plugin.state === "activating") {
throw new Error(`Plugin ${pluginId} is still activating`);
}
plugin.errorTimestamps = [];
plugin.lastError = null;
plugin.state = "loaded";
await this.activate(pluginId);
}
/** Stops everything, reverse activation order. */
async shutdown(): Promise<void> {
for (const id of [...this.activationOrder].reverse()) {
try {
await this.deactivate(id);
} catch (error) {
pluginLogger.error(
`Failed to deactivate plugin ${id} during shutdown`,
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_shutdown" },
);
}
}
this.activationOrder = [];
}
private requirePlugin(pluginId: string): LoadedPlugin {
const plugin = this.plugins.get(pluginId);
if (!plugin) throw new Error(`Plugin ${pluginId} is not loaded`);
return plugin;
}
}
function withTimeout<T>(
promise: Promise<T>,
ms: number,
message: string,
): Promise<T> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(message)), ms);
promise.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error) => {
clearTimeout(timer);
reject(error);
},
);
});
}
const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
/**
* Throws unless an artifact may replace the bundled plugin with the same id:
* a trusted key signed it and its version is strictly higher, so an old
* signed build can never be replayed as a downgrade.
*/
export function assertBundledUpdate(
id: string,
version: unknown,
signedBy: string | undefined,
bundledVersion: string | undefined,
): void {
if (!signedBy) {
throw new Error(
`a bundled plugin already uses the id "${id}"; only a signed update can replace it`,
);
}
if (typeof version !== "string" || !semver.valid(version)) {
throw new Error(`update for "${id}" has no valid version`);
}
if (bundledVersion && !semver.gt(version, bundledVersion)) {
throw new Error(
`update for "${id}" is ${version}, which is not newer than the bundled ${bundledVersion}`,
);
}
}
function fileStamp(filePath: string): string {
try {
const stat = fs.statSync(filePath);
return `${stat.size}-${Math.round(stat.mtimeMs)}`;
} catch {
return "0";
}
}
function pathToFileUrlString(filePath: string): string {
const resolved = path.resolve(filePath).replace(/\\/g, "/");
return `file://${resolved.startsWith("/") ? "" : "/"}${resolved}`;
}
/**
* True when one id's table prefix starts with the other's: "foo" owns
* p_foo_*, which already covers every table "foo-bar" (p_foo_bar_*) creates.
*/
export function prefixesOverlap(a: string, b: string): boolean {
if (a === b) return false;
const left = tablePrefix(a);
const right = tablePrefix(b);
return left.startsWith(right) || right.startsWith(left);
}
/** Whether `target` really lives under `root`, following symlinks. */
export function isRealPathInside(root: string, target: string): boolean {
try {
const realRoot = fs.realpathSync(root);
const relative = path.relative(realRoot, fs.realpathSync(target));
return (
relative === "" ||
(!relative.startsWith("..") && !path.isAbsolute(relative))
);
} catch {
return false;
}
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Manifest validation, re-exported from the SDK.
*
* The contract lives in @termix/plugin-sdk/manifest so the server, the
* authoring script and plugin authors all read the same rules. This file used
* to carry its own copy of the enums and validators, which meant three places
* had to be edited together and quietly drifted when they were not.
*/
export {
validateManifest,
parseManifest,
SUPPORTED_PLUGIN_API_VERSION,
PLUGIN_API_VERSION,
isApiCompatible,
isTermixCompatible,
SYSTEM_ROLE_NAMES,
RESERVED_PERMISSION_PREFIXES,
qualifyPermission,
} from "@termix/plugin-sdk/manifest";
export type {
PluginManifest,
PluginAuthor,
PluginEngine,
PluginContributions,
PluginTabContribution,
PluginActionContribution,
PluginActionSlot,
PluginPermissionContribution,
SystemRoleName,
PluginServiceProvide,
PluginServiceRequire,
PluginSecretProvide,
PluginSecretRequire,
HostCapabilityContribution,
ActionContributionKind,
ParsedManifest,
} from "@termix/plugin-sdk/manifest";
export type {
Capability,
CapabilityInfo,
CapabilityRisk,
} from "@termix/plugin-sdk/capabilities";
+257
View File
@@ -0,0 +1,257 @@
/**
* Applies a plugin's migrations before it activates.
*
* A plugin ships plain SQL per dialect in migrations/<dialect>/NNNN_name.sql.
* Not drizzle-kit: core's own SQLite schema is hand-written DDL and only the
* client-server engines use drizzle migrations, so a plugin that used
* drizzle-kit would still need a hand-rolled SQLite path. One runner for all
* three keeps a plugin's tables identical everywhere.
*
* Failures are contained. An edited migration that already ran blocks that
* plugin; a migration that throws marks it failed. Core keeps booting either
* way, because one plugin's bad SQL is not a reason to take the server down.
*/
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { sql } from "drizzle-orm";
import { pluginLogger } from "../utils/logger.js";
import type { DatabaseDialect } from "../database/db/dialect.js";
import { LEGACY_TABLE_OWNERS } from "@termix/plugin-sdk/db";
import {
collectOwnedIndexes,
findUnownedTableWrites,
splitStatements,
} from "@termix/plugin-sdk/ddl";
// The splitter is shared with createTestDb in the SDK, so tests apply a
// migration exactly the way this runner does.
export { LEGACY_TABLE_OWNERS, splitStatements };
const FILE_PATTERN = /^(\d{4})_([a-z0-9_]+)\.sql$/;
export interface PluginMigration {
/** The file's stem, e.g. "0001_init". Stable across dialects. */
id: string;
sequence: number;
file: string;
sql: string;
checksum: string;
}
/** Raised when an already-applied migration no longer matches its checksum. */
export class PluginMigrationChecksumError extends Error {
readonly code = "EPLUGINMIGRATIONCHECKSUM";
constructor(pluginId: string, migrationId: string) {
super(
`Plugin "${pluginId}" migration "${migrationId}" has changed since it was applied. ` +
`An applied migration is immutable: add a new one instead of editing this file.`,
);
this.name = "PluginMigrationChecksumError";
}
}
export function checksum(contents: string): string {
// Normalised so a line-ending change between platforms is not a mismatch.
const normalised = contents.replace(/\r\n/g, "\n").trimEnd();
return crypto.createHash("sha256").update(normalised).digest("hex");
}
function migrationsDir(pluginDir: string, dialect: DatabaseDialect): string {
return path.join(pluginDir, "migrations", dialect);
}
/** Reads the migrations a plugin ships for one dialect, in sequence order. */
export function readMigrations(
pluginDir: string,
dialect: DatabaseDialect,
): PluginMigration[] {
const dir = migrationsDir(pluginDir, dialect);
if (!fs.existsSync(dir)) return [];
// A symlinked folder or file could feed in SQL from anywhere on disk.
const realRoot = fs.realpathSync(pluginDir);
const inside = (target: string) => {
const relative = path.relative(realRoot, fs.realpathSync(target));
return !relative.startsWith("..") && !path.isAbsolute(relative);
};
if (!inside(dir)) {
throw new Error("The migrations folder resolves outside the plugin");
}
const migrations: PluginMigration[] = [];
for (const file of fs.readdirSync(dir).sort()) {
const match = FILE_PATTERN.exec(file);
if (!match) {
if (file.endsWith(".sql")) {
throw new Error(
`Migration "${file}" must be named NNNN_name.sql, lower snake_case`,
);
}
continue;
}
if (!inside(path.join(dir, file))) {
throw new Error(`Migration "${file}" resolves outside the plugin`);
}
const contents = fs.readFileSync(path.join(dir, file), "utf8");
migrations.push({
id: file.replace(/\.sql$/, ""),
sequence: Number(match[1]),
file,
sql: contents,
checksum: checksum(contents),
});
}
migrations.sort((a, b) => a.sequence - b.sequence);
return migrations;
}
/**
* Checks that every table a migration writes to belongs to this plugin.
*
* A plugin owns the p_<id>_ namespace and nothing else. Enforced here as well
* as in the CLI, because a plugin installed from a tarball never ran the CLI.
* A legacy core table is allowed only for the bundled plugin that adopts it:
* a user plugin with the same id gets no exemption.
*/
export function assertOwnedTables(
pluginId: string,
sql: string,
options: { bundled?: boolean; ownedIndexes?: ReadonlySet<string> } = {},
): void {
const legacy = new Set(
options.bundled
? Object.entries(LEGACY_TABLE_OWNERS)
.filter(([, owner]) => owner === pluginId)
.map(([table]) => table)
: [],
);
const problems = findUnownedTableWrites(
pluginId,
sql,
legacy,
options.ownedIndexes,
).map((problem) => {
// Name the owner when the table is another plugin's legacy table.
const table = /writes to "([^"]+)"/.exec(problem)?.[1];
const owner = table ? LEGACY_TABLE_OWNERS[table] : undefined;
return owner && owner !== pluginId
? `may not touch "${table}": it belongs to the "${owner}" plugin`
: problem;
});
if (problems.length > 0) {
throw new Error(
`Plugin "${pluginId}" migration refused: ${problems.join("; ")}`,
);
}
}
export interface AppliedMigration {
migrationId: string;
checksum: string;
}
export interface MigrationRunner {
dialect: DatabaseDialect;
listApplied: (pluginId: string) => Promise<AppliedMigration[]>;
execute: (statements: string[]) => Promise<void>;
record: (pluginId: string, migration: PluginMigration) => Promise<void>;
/** Runs fn inside a transaction where the dialect supports DDL in one. */
transaction?: (fn: () => Promise<void>) => Promise<void>;
}
/**
* Brings one plugin's tables up to date.
*
* Returns the ids applied, so a caller can log what changed. Throws on a
* checksum mismatch or a failing statement; the loader turns either into a
* failed plugin without disturbing the rest of the server.
*/
export async function applyPluginMigrations(
pluginId: string,
pluginDir: string,
runner: MigrationRunner,
options: { bundled?: boolean } = {},
): Promise<string[]> {
const migrations = readMigrations(pluginDir, runner.dialect);
if (migrations.length === 0) return [];
const applied = new Map(
(await runner.listApplied(pluginId)).map((row) => [
row.migrationId,
row.checksum,
]),
);
const pending: PluginMigration[] = [];
for (const migration of migrations) {
const previous = applied.get(migration.id);
if (previous === undefined) {
pending.push(migration);
continue;
}
if (previous !== migration.checksum) {
throw new PluginMigrationChecksumError(pluginId, migration.id);
}
}
if (pending.length === 0) return [];
const ownedIndexes = collectOwnedIndexes(
pluginId,
migrations.map((migration) => migration.sql),
);
const run = async () => {
for (const migration of pending) {
assertOwnedTables(pluginId, migration.sql, { ...options, ownedIndexes });
const statements = splitStatements(migration.sql);
await runner.execute(statements);
await runner.record(pluginId, migration);
}
};
if (runner.transaction) {
await runner.transaction(run);
} else {
await run();
}
pluginLogger.info(
`Applied ${pending.length} migration(s) for plugin ${pluginId}: ${pending
.map((m) => m.id)
.join(", ")}`,
{ operation: "plugin_migrate" },
);
return pending.map((migration) => migration.id);
}
/**
* Renames a legacy core table into a plugin's namespace, or creates it fresh.
*
* Adoption is how a feature keeps its data when it moves out of core. The
* table is renamed rather than copied so nothing is duplicated and nothing has
* to be migrated row by row.
*/
export function adoptLegacyTableSql(
dialect: DatabaseDialect,
legacyName: string,
newName: string,
): string {
if (dialect === "mysql") {
return `RENAME TABLE \`${legacyName}\` TO \`${newName}\`;`;
}
return `ALTER TABLE "${legacyName}" RENAME TO "${newName}";`;
}
/** Whether a table exists, asked in the dialect's own catalog. */
export function tableExistsSql(dialect: DatabaseDialect, table: string) {
if (dialect === "sqlite") {
return sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${table}`;
}
if (dialect === "postgres") {
return sql`SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = ${table}`;
}
return sql`SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ${table}`;
}
+45
View File
@@ -0,0 +1,45 @@
/**
* The one plugin that stores alerts and delivers them to channels. Core only
* resolves who an alert is for and hands it over; with no hub running an
* alert reaches nobody.
*/
import type { PluginNotifyHub } from "@termix/plugin-sdk/backend";
import { hasCapability } from "./permissions.js";
interface HubRegistration {
pluginId: string;
declared: readonly string[];
hub: PluginNotifyHub;
}
let current: HubRegistration | null = null;
export function registerNotifyHub(registration: HubRegistration): () => void {
if (current && current.pluginId !== registration.pluginId) {
throw new Error(
`Plugin ${registration.pluginId} cannot serve alerts: ${current.pluginId} already does`,
);
}
current = registration;
return () => {
if (current === registration) current = null;
};
}
/** The running hub, or null while none is registered or its grant is gone. */
export async function activeNotifyHub(): Promise<PluginNotifyHub | null> {
const registration = current;
if (!registration) return null;
const granted = await hasCapability(
registration.pluginId,
"notify:hub",
registration.declared,
);
return granted ? registration.hub : null;
}
/** Test seam. */
export function clearNotifyHub(): void {
current = null;
}
+81
View File
@@ -0,0 +1,81 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
DEFAULT_BACKEND_ENTRY,
type PluginManifest,
} from "@termix/plugin-sdk/manifest";
/**
* Where user-installed plugins live. Resolved per call rather than captured at
* module scope: tests set DATA_DIR in beforeEach, and a module-scope constant
* would freeze whatever the first import saw.
*/
export function getPluginsDir(): string {
return path.join(process.env.DATA_DIR || "./db/data", "plugins");
}
/**
* A plugin's own files (ctx.files.dataDir(), downloaded binaries). Kept out of
* getPluginsDir(), where the loader would read every data folder as a user
* plugin that clashes with the bundled one of the same name.
*/
export function getPluginDataDir(pluginId: string): string {
return path.join(
process.env.DATA_DIR || "./db/data",
"plugin-data",
pluginId,
);
}
/**
* Where user .tmxplug files are unpacked. A dot directory inside the user
* plugins dir, which the loader's directory scan skips.
*/
export function getUnpackedPluginsDir(): string {
return path.join(getPluginsDir(), ".unpacked");
}
/**
* Where the plugins that ship with Termix live.
*
* Separate from getPluginsDir() because that one is user data: a bundled
* plugin is part of the install and must not be deletable or shadowable by
* whatever happens to be in the data directory.
*
* Resolved from this module's location rather than cwd, because the build
* emits to dist/backend/backend/plugins/ and the bundled plugins sit beside
* dist/backend.
*/
export function getBundledPluginsDir(): string {
const override = process.env.TERMIX_BUNDLED_PLUGINS_DIR;
if (override) return override;
const here = path.dirname(fileURLToPath(import.meta.url));
const candidates = [
// Built server: dist/backend/backend/plugins -> dist/plugins
// Source tree: src/backend/plugins -> plugins
// Both are three levels up, which is why there is one entry, not two.
path.resolve(here, "../../../plugins"),
// Built server invoked from a nested layout: .../dist/backend/backend/plugins
path.resolve(here, "../../../../plugins"),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) return candidate;
}
return candidates[0];
}
/** The backend entry, from the manifest's own `backend` field. */
export function getPluginBackendEntry(
pluginDir: string,
manifest?: Pick<PluginManifest, "backend">,
): string {
return path.join(pluginDir, manifest?.backend ?? DEFAULT_BACKEND_ENTRY);
}
export function getPluginManifestPath(pluginDir: string): string {
return path.join(pluginDir, "manifest.json");
}
+127
View File
@@ -0,0 +1,127 @@
/**
* The grant check every guarded ctx call goes through.
*
* Two things have to be true for a capability to count as granted: the
* manifest declares it (the plugin asked for it up front) and it is granted in
* plugin_permission_grants (someone, or the bundling process, said yes). A
* grant for something the manifest never declared is ignored rather than
* honoured, so widening a plugin's reach always requires a new manifest the
* user can see.
*
* The cache is invalidated explicitly on every grant change rather than by
* TTL: a revoke that takes effect a minute later is not a revoke.
*
* Every refusal is audited here, so a guard added anywhere in the runtime
* leaves a trail without each call site remembering to write one.
*/
import { createCurrentPluginPermissionGrantRepository } from "../database/repositories/factory.js";
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
import { getActor } from "./actor.js";
const cache = new Map<string, Set<string>>();
export function invalidatePluginPermissionCache(pluginId?: string): void {
if (pluginId) cache.delete(pluginId);
else cache.clear();
}
async function loadGrants(pluginId: string): Promise<Set<string>> {
const cached = cache.get(pluginId);
if (cached) return cached;
const rows =
await createCurrentPluginPermissionGrantRepository().listByPlugin(pluginId);
const capabilities = new Set(rows.map((row) => row.capability));
cache.set(pluginId, capabilities);
return capabilities;
}
export async function hasCapability(
pluginId: string,
capability: string,
declared: readonly string[],
): Promise<boolean> {
if (!declared.includes(capability)) return false;
return (await loadGrants(pluginId)).has(capability);
}
/**
* For hooks ssh2 calls synchronously. Answers from the cache only and fails
* closed when the grants are not loaded yet, starting the load for next time.
*/
export function hasCachedCapability(
pluginId: string,
capability: string,
declared: readonly string[],
): boolean {
if (!declared.includes(capability)) return false;
const cached = cache.get(pluginId);
if (!cached) {
void loadGrants(pluginId).catch(() => {});
return false;
}
return cached.has(capability);
}
/** Loads a plugin's grants ahead of a synchronous hasCachedCapability. */
export function warmPluginGrants(pluginId: string): void {
void loadGrants(pluginId).catch(() => {});
}
/**
* Throws PluginCapabilityError unless granted, after writing the refusal's
* audit line. `action` names the call ("kv_set"), so the line says what was
* refused and not only which capability was missing.
*/
export async function assertCapability(
pluginId: string,
capability: string,
declared: readonly string[],
action = "capability_refused",
): Promise<void> {
if (await hasCapability(pluginId, capability, declared)) return;
const error = new PluginCapabilityError(pluginId, capability);
await auditRefusal(pluginId, capability, action, error);
throw error;
}
/**
* The error for a capability a sync call never declared, with its audit
* line written in the background.
*/
export function capabilityRefused(
pluginId: string,
capability: string,
action = "capability_refused",
): PluginCapabilityError {
const error = new PluginCapabilityError(pluginId, capability);
void auditRefusal(pluginId, capability, action, error);
return error;
}
async function auditRefusal(
pluginId: string,
capability: string,
action: string,
error: Error,
): Promise<void> {
try {
const { logAudit } = await import("../utils/audit-logger.js");
await logAudit({
// Attribution comes from the runtime, never from the plugin.
userId: getActor() ?? "system",
username: `plugin:${pluginId}`,
action: `plugin_${action}`,
resourceType: "plugin",
resourceId: pluginId,
resourceName: pluginId,
details: `refused: ${capability}`,
success: false,
errorMessage: error.message,
});
} catch {
// Auditing must never change what the caller sees.
}
}
+63
View File
@@ -0,0 +1,63 @@
/**
* How a plugin names a core role permission.
*
* A plugin writes its own permissions short ("services.use") and core registers
* them as `<pluginId>.<name>`. A cross-plugin check still has to be
* expressible, so a string that already names another plugin or a core group is
* taken as given. Everything ctx.rbac exposes, and the per-route gate in
* http.ts, resolves through here so the four paths cannot disagree.
*/
import {
PERMISSION_CATALOG,
getPermissionCatalog,
} from "../utils/permission-catalog.js";
import type { PluginManifest } from "./manifest.js";
function coreGroups(): Set<string> {
return new Set(PERMISSION_CATALOG.map((entry) => entry.group));
}
/** The ids this plugin declares, in their registered `<pluginId>.<name>` form. */
export function declaredPermissions(manifest: PluginManifest): Set<string> {
return new Set(
(manifest.contributes?.permissions ?? []).map(
(permission) => `${manifest.id}.${permission.name}`,
),
);
}
/**
* Turns whatever a plugin passed into the id core stores.
*
* A bare name takes this plugin's prefix. A string whose first segment is a
* core group, or this plugin's own id, is already a full id.
*/
export function resolvePermission(
manifest: PluginManifest,
permission: string,
): string {
const head = permission.split(".")[0];
if (!head) return permission;
if (head === manifest.id) return permission;
// A name this plugin declares is its own, even if another plugin's id
// happens to match its first segment.
if (
(manifest.contributes?.permissions ?? []).some(
(declared) => declared.name === permission,
)
) {
return `${manifest.id}.${permission}`;
}
if (coreGroups().has(head)) return permission;
// Another plugin's registered group, which is the cross-plugin check.
const otherPlugin = getPermissionCatalog().some(
(entry) => entry.pluginId !== undefined && entry.group === head,
);
if (otherPlugin) return permission;
// Nothing claims that first segment, so it is a short name of our own.
return `${manifest.id}.${permission}`;
}
+51
View File
@@ -0,0 +1,51 @@
/**
* A small in-process key/value registry for live objects plugins hand each
* other (a guest-link resolver, a host import normalizer). Services with
* versions and permissions go through service-registry.ts instead.
*
* ctx.registry only lets a plugin provide or revoke keys under its own id
* ("<pluginId>.something"); anyone may consume. Core also reads a few
* well-known per-plugin keys, such as "<pluginId>.hostImportNormalizer".
*
* `consume` returns undefined rather than throwing when nothing is registered.
* A provider can disappear when its plugin is disabled, and callers are
* expected to handle that rather than assume it is always there.
*/
import { pluginLogger } from "../utils/logger.js";
const providers = new Map<string, unknown>();
export function provide<T>(key: string, value: T): void {
if (providers.has(key)) {
pluginLogger.warn(
`Service "${key}" is already registered and is being replaced`,
{ operation: "plugin_registry" },
);
}
providers.set(key, value);
}
export function consume<T>(key: string): T | undefined {
return providers.get(key) as T | undefined;
}
/**
* Removes a provider, but only if `value` is the one currently registered.
* A plugin that crashed and restarted must not revoke the replacement its own
* restart installed, so revocation is identity-checked rather than by key.
*/
export function revoke(key: string, value?: unknown): boolean {
if (!providers.has(key)) return false;
if (value !== undefined && providers.get(key) !== value) return false;
return providers.delete(key);
}
export function has(key: string): boolean {
return providers.has(key);
}
/** Test seam. */
export function clearRegistry(): void {
providers.clear();
}
+81
View File
@@ -0,0 +1,81 @@
/**
* ctx.schedule: timers a plugin owns. They live in the plugin's disposable
* bag, so deactivate clears every one of them.
*/
import type { PluginSchedule } from "@termix/plugin-sdk/backend";
import type { DisposableBag } from "./disposables.js";
type Log = (message: string, error: unknown) => void;
export function createPluginSchedule(
bag: DisposableBag,
logError: Log,
): PluginSchedule {
const run = async (fn: () => void | Promise<void>) => {
try {
await fn();
} catch (error) {
logError("Scheduled job failed", error);
}
};
return {
every: (intervalMs, fn, options) => {
let running = false;
let stopped = false;
let interval: NodeJS.Timeout | undefined;
const tick = () => {
if (running || stopped) return;
running = true;
void run(fn).finally(() => {
running = false;
});
};
const jitter = Math.max(0, Math.floor(options?.jitterMs ?? 0));
const start = setTimeout(
() => {
if (stopped) return;
interval = setInterval(tick, Math.max(1, intervalMs));
if (jitter > 0) tick();
},
jitter > 0 ? Math.floor(Math.random() * jitter) : 0,
);
if (options?.runNow) tick();
let drop = () => {};
const stop = () => {
if (stopped) return;
stopped = true;
clearTimeout(start);
if (interval) clearInterval(interval);
drop();
};
drop = bag.add(stop, "scheduled job");
return stop;
},
after: (delayMs, fn) => {
let done = false;
let drop = () => {};
const timer = setTimeout(
() => {
done = true;
drop();
void run(fn);
},
Math.max(0, delayMs),
);
const cancel = () => {
if (done) return;
done = true;
clearTimeout(timer);
drop();
};
drop = bag.add(cancel, "scheduled job");
return cancel;
},
};
}
+362
View File
@@ -0,0 +1,362 @@
/**
* Cross-plugin secret references.
*
* A plugin that holds a secret (the AI assistant and its provider API key) can
* offer it to other plugins by name, without ever handing over a copy. A
* requiring plugin declares `requiresSecret` in its manifest and calls
* ctx.secrets.getShared(pluginId, key) per request; the value is resolved from
* the provider at that moment and returned for that one call.
*
* ## Why a resolver and not a stored value
*
* The registration holds a `resolve` callback owned by the providing plugin,
* never the secret itself. That is the whole point of the design:
*
* - The provider can rotate or clear the underlying secret at any time. The
* next getShared() runs the new resolver and sees the new value, with no
* invalidation step to forget and no cached copy to go stale.
* - There is nothing for the requiring plugin to persist. It gets a string
* back from one call. It could of course write that string somewhere
* itself, exactly as it could with any value it is legitimately given --
* what it cannot do is hold a handle that keeps working after the provider
* revokes, because every call re-resolves through the provider.
* - Uninstalling the provider deletes the registration, so every later
* getShared() returns null rather than throwing on a dangling handle. A
* missing dependency is a normal condition here, unlike a service call,
* which is why this returns null instead of the service registry's
* PluginServiceUnavailableError.
*
* ## Authorization is the provider's own permission
*
* No fourth grant mechanism. The provider declares which of its own role
* permissions gates sharing (for example "<plugin>.secrets.share"),
* and getShared runs the acting user through the same PermissionManager check
* that service-registry.ts and requirePermission() use. So:
*
* - An admin can let one user's requests reuse the shared key and deny
* another's, purely in the role editor, with no reinstall.
* - Revoking the RBAC permission takes effect on the very next call, exactly
* like revoking the secret itself.
* - The check is per-user and per-call, while `requiresSecret` in the
* manifest is per-install. They are independent on purpose: whether the
* plugin may ask is a deploy-time decision, whether this user's request
* gets a value is a runtime one.
*
* A denial is an error (the caller asked for something it is not allowed to
* have), while an absent secret is null (the caller asked for something that
* legitimately is not there). Collapsing the two would let a consumer treat
* "you may not" as "not configured" and fall back to something weaker.
*/
import { pluginLogger } from "../utils/logger.js";
import type { PluginManifest, PluginSecretRequire } from "./manifest.js";
export interface SecretRegistration {
/** The plugin offering the secret. */
pluginId: string;
/** Display name, for audit lines. */
pluginName: string;
/** The secret's name within that plugin, e.g. "api-key". */
key: string;
/** Role permission the acting user needs. Declared by the provider. */
permission: string;
/**
* Returns the current value, or null when the provider has nothing set.
* Called on every read so a rotation is picked up with no invalidation.
*/
resolve: (userId: string) => Promise<string | null> | string | null;
/** Bumped on every offer, so a re-registered provider invalidates nothing stale. */
generation: number;
}
/** Keyed by `${pluginId}:${key}`. */
const secrets = new Map<string, SecretRegistration>();
let generationCounter = 0;
function mapKey(pluginId: string, key: string): string {
return `${pluginId}:${key}`;
}
/**
* Thrown when the acting user lacks the provider's sharing permission.
*
* Mirrors PluginServicePermissionError: `status` and `body` are the 403 shape
* requirePermission() sends, so a consumer serving an HTTP request can pass it
* straight through.
*/
export class PluginSecretPermissionError extends Error {
readonly code = "EPLUGINSECRETPERM";
readonly status = 403;
readonly body: { error: string; required: string };
constructor(pluginId: string, key: string, permission: string) {
super(
`You do not have access to the "${key}" secret shared by "${pluginId}". ` +
`It requires the "${permission}" permission.`,
);
this.name = "PluginSecretPermissionError";
this.body = { error: "Insufficient permissions", required: permission };
}
}
/** Thrown when a read cannot be attributed to a user. */
export class PluginSecretActorError extends Error {
readonly code = "EPLUGINSECRETACTOR";
constructor(pluginId: string, key: string) {
super(
`A read of "${pluginId}:${key}" has no acting user. Pass one with ` +
`ctx.secrets.getShared(pluginId, key, { userId }) or ctx.asUser.`,
);
this.name = "PluginSecretActorError";
}
}
/** Thrown when a plugin asks for a secret its manifest never declared. */
export class PluginSecretUndeclaredError extends Error {
readonly code = "EPLUGINSECRETUNDECLARED";
constructor(consumerPluginId: string, pluginId: string, key: string) {
super(
`Plugin ${consumerPluginId} cannot read "${pluginId}:${key}": it is not ` +
`declared in the manifest's requiresSecret array`,
);
this.name = "PluginSecretUndeclaredError";
}
}
export function offerSecret(
registration: Omit<SecretRegistration, "generation">,
): SecretRegistration {
const id = mapKey(registration.pluginId, registration.key);
const existing = secrets.get(id);
if (existing) {
pluginLogger.warn(
`Shared secret "${id}" is already offered and is being replaced`,
{ operation: "plugin_secret_registry" },
);
}
const entry: SecretRegistration = {
...registration,
generation: ++generationCounter,
};
secrets.set(id, entry);
return entry;
}
/**
* Withdraws an offer, identity-checked like revokeService: a provider that
* crashed and restarted must not withdraw the replacement its own restart
* installed.
*
* Nothing is remembered afterwards. A service keeps a tombstone so a held
* handle throws a named error, but a secret reference has no handle to keep
* alive -- the next read simply finds nothing and returns null.
*/
export function withdrawSecret(
pluginId: string,
key: string,
registration?: SecretRegistration,
): boolean {
const id = mapKey(pluginId, key);
const current = secrets.get(id);
if (!current) return false;
if (registration && current.generation !== registration.generation) {
return false;
}
return secrets.delete(id);
}
/** Withdraws every offer made by one plugin. Used when it is uninstalled. */
export function withdrawAllForPlugin(pluginId: string): number {
let removed = 0;
for (const [id, registration] of secrets) {
if (registration.pluginId === pluginId) {
secrets.delete(id);
removed++;
}
}
return removed;
}
export function getSecretRegistration(
pluginId: string,
key: string,
): SecretRegistration | undefined {
return secrets.get(mapKey(pluginId, key));
}
export function listSecrets(): SecretRegistration[] {
return [...secrets.values()];
}
/** Test seam. */
export function clearSecretRegistry(): void {
secrets.clear();
}
export interface SecretRequirementResolution {
/** Declared requirements whose provider is present and has a value offered. */
available: string[];
/** Declared requirements with no matching offer right now. */
unavailable: string[];
/** Human-readable reasons for each unsatisfied non-optional requirement. */
errors: string[];
}
/**
* Structural resolution of a manifest's `requiresSecret`.
*
* Like resolveRequirements in service-registry.ts, this deliberately does NOT
* consult any user's permissions: activation is per-instance, permissions are
* per-user. It only asks whether the named plugin currently offers the key.
*
* Availability here is a snapshot for logging and install-time feedback. It is
* never cached into the call path -- getShared re-resolves every time, so a
* provider that appears after activation starts working with no restart.
*/
export function resolveSecretRequirements(
manifest: PluginManifest,
): SecretRequirementResolution {
const available: string[] = [];
const unavailable: string[] = [];
const errors: string[] = [];
for (const requirement of manifest.requiresSecret ?? []) {
const id = mapKey(requirement.plugin, requirement.key);
if (secrets.has(id)) {
available.push(id);
continue;
}
unavailable.push(id);
if (!requirement.optional) {
errors.push(
`requires secret "${requirement.key}" from plugin "${requirement.plugin}", which is not currently offered`,
);
}
}
return { available, unavailable, errors };
}
export interface SecretReadContext {
/** Resolves the user this read acts as. */
resolveUserId: () => string | undefined;
/** Checks a role permission for a user. */
hasPermission: (userId: string, permission: string) => Promise<boolean>;
/** Writes the audit line. Must never throw. Awaited before the value returns. */
audit: (entry: SecretAuditEntry) => void | Promise<void>;
}
export interface SecretAuditEntry {
userId: string;
consumerPluginId: string;
providerPluginId: string;
providerPluginName: string;
key: string;
/** False for a denial, true for an allowed read whether or not a value existed. */
success: boolean;
/** Whether a value actually came back. Never the value itself. */
resolved: boolean;
errorMessage?: string;
}
/**
* Reads a shared secret for one call.
*
* Returns null, rather than throwing, when the provider is not installed or
* has nothing set. Those are ordinary conditions a consumer is expected to
* handle (fall back to its own key, or tell the user to configure one), and an
* optional dependency that threw would force every consumer to wrap each read.
*
* Throws only for the two cases that are genuinely wrong: a plugin reading
* something its manifest never declared, and a user without the permission.
*/
export async function readSharedSecret(
consumerManifest: PluginManifest,
providerPluginId: string,
key: string,
context: SecretReadContext,
): Promise<string | null> {
const consumerPluginId = consumerManifest.id;
// Declared in the manifest, the same rule services.provide enforces: a
// plugin cannot reach a secret it never asked for, even if an admin granted
// the permission for some other reason.
const declared = (consumerManifest.requiresSecret ?? []).find(
(entry: PluginSecretRequire) =>
entry.plugin === providerPluginId && entry.key === key,
);
if (!declared) {
throw new PluginSecretUndeclaredError(
consumerPluginId,
providerPluginId,
key,
);
}
const registration = secrets.get(mapKey(providerPluginId, key));
// Provider absent: uninstalled, disabled, or never installed. Indistinguish-
// able on purpose, and all four are the same answer to the consumer -- which
// is also what makes an uninstall clean rather than a dangling handle.
if (!registration) return null;
const userId = context.resolveUserId();
if (!userId) {
throw new PluginSecretActorError(providerPluginId, key);
}
const allowed = await context.hasPermission(userId, registration.permission);
if (!allowed) {
const error = new PluginSecretPermissionError(
providerPluginId,
key,
registration.permission,
);
await context.audit({
userId,
consumerPluginId,
providerPluginId: registration.pluginId,
providerPluginName: registration.pluginName,
key,
success: false,
resolved: false,
errorMessage: error.message,
});
throw error;
}
try {
const value = await registration.resolve(userId);
const resolved = typeof value === "string" && value.length > 0;
await context.audit({
userId,
consumerPluginId,
providerPluginId: registration.pluginId,
providerPluginName: registration.pluginName,
key,
success: true,
resolved,
});
return resolved ? value : null;
} catch (error) {
await context.audit({
userId,
consumerPluginId,
providerPluginId: registration.pluginId,
providerPluginName: registration.pluginName,
key,
success: false,
resolved: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
// A provider whose own lookup failed is not a permission problem, and the
// consumer should not read it as "not configured" and fall back.
throw error;
}
}
+506
View File
@@ -0,0 +1,506 @@
/**
* Declared, versioned, per-user-permissioned service contracts between plugins.
*
* This is the typed sibling of registry.ts. That one is a bare key/value map a
* plugin can put anything into; this one only carries services a manifest
* declared, at a declared version, behind a declared role permission.
*
* ## What the permission actually gates
*
* Be precise, because there are two unrelated permission systems in this
* runtime and conflating them is the easy mistake:
*
* - permissions.ts gates what a PLUGIN may do (manifest + admin grant).
* - this file gates what a USER may do, through PermissionManager, the same
* RBAC check requirePermission() runs on an HTTP route.
*
* A service permission is an ordinary role permission string. It shows up in
* the admin role editor like hosts.view does, and an admin can grant or revoke
* it per role or per user at any time, whether or not the consuming plugin is
* installed.
*
* The check runs on every call rather than once when the handle is made. That
* is deliberate and is the whole reason the gate lives in the proxy: a revoke
* mid-session has to take effect on the next call, with no restart and no
* reinstall. PermissionManager's per-user cache is already invalidated by the
* RBAC routes that change a role, so "next call" is immediate in practice.
*
* ## Resolution is structural, permission is per-call
*
* resolveRequirements only asks "is this service present at a satisfying
* version". It deliberately does NOT consult any user's permissions:
* activation is per-instance and permissions are per-user, so gating
* activation on one user's grants would be meaningless. A user who lacks the
* permission gets a clear denial from the call instead of the requiring plugin
* mysteriously failing to start.
*/
import semver from "semver";
import { pluginLogger } from "../utils/logger.js";
import { recordConflict } from "./conflicts.js";
import { runAsActor } from "./actor.js";
import type { PluginManifest, PluginServiceRequire } from "./manifest.js";
export interface ServiceRegistration {
service: string;
/**
* The provider's name, for a service several plugins provide side by side
* (sessions.live, keyed by session type). Empty for the one unnamed provider.
*/
name: string;
version: string;
permission: string;
/** The plugin that provided it. */
pluginId: string;
/** Display name, for audit lines. */
pluginName: string;
implementation: Record<string, unknown>;
/**
* Bumped on every provide. A handle captures the value it was made against,
* so a provider that was revoked and re-registered invalidates old handles
* rather than silently rebinding them to a different object.
*/
generation: number;
}
/** service -> provider name ("" when unnamed) -> registration. */
const services = new Map<string, Map<string, ServiceRegistration>>();
/**
* What a provider looked like before it was revoked, by the same two keys.
*
* A handle taken while the provider was up must keep throwing a named error
* after it goes away, rather than degrading to "undefined is not a function",
* which would tell a consumer nothing about why its dependency vanished.
*/
const retired = new Map<string, { pluginId: string; methods: Set<string> }>();
let generationCounter = 0;
function retiredKey(service: string, name: string): string {
return `${service}#${name}`;
}
function lookup(service: string, name = ""): ServiceRegistration | undefined {
return services.get(service)?.get(name);
}
/** Thrown when the provider went away while a consumer still held a handle. */
export class PluginServiceUnavailableError extends Error {
readonly code = "EPLUGINSVCGONE";
constructor(service: string, providerPluginId: string) {
super(
`Service "${service}" is no longer provided (was: ${providerPluginId}). ` +
`The providing plugin has been disabled or replaced.`,
);
this.name = "PluginServiceUnavailableError";
}
}
/**
* Thrown when the acting user lacks the service's permission.
*
* `status` and `body` are the same 403 shape requirePermission() sends, so a
* consumer serving an HTTP request can pass it straight through.
*/
export class PluginServicePermissionError extends Error {
readonly code = "EPLUGINSVCPERM";
readonly status = 403;
readonly body: { error: string; required: string };
constructor(service: string, permission: string) {
super(
`You do not have access to "${service}". It requires the "${permission}" permission.`,
);
this.name = "PluginServicePermissionError";
this.body = { error: "Insufficient permissions", required: permission };
}
}
/** Thrown when a call cannot be attributed to a user. */
export class PluginServiceActorError extends Error {
readonly code = "EPLUGINSVCACTOR";
constructor(service: string) {
super(
`A call to "${service}" has no acting user. Pass one with ` +
`ctx.services.get(name, { userId }) or handle.asUser(userId).`,
);
this.name = "PluginServiceActorError";
}
}
export function provideService(
registration: Omit<ServiceRegistration, "generation" | "name"> & {
name?: string;
},
): ServiceRegistration {
const name = registration.name ?? "";
const existing = lookup(registration.service, name);
// Two plugins cannot answer to the same service and provider name. The
// same plugin replacing its own registration (a restart) is fine.
if (existing && existing.pluginId !== registration.pluginId) {
recordConflict({
kind: "service",
pluginId: registration.pluginId,
heldBy: existing.pluginId,
name: `${registration.service}${name ? `:${name}` : ""}`,
});
throw new Error(
`Service "${registration.service}"${name ? ` (${name})` : ""} is already provided by ${existing.pluginId}`,
);
}
if (existing) {
pluginLogger.warn(
`Service "${registration.service}"${name ? ` (${name})` : ""} is already provided by ${existing.pluginId} and is being replaced by ${registration.pluginId}`,
{ operation: "plugin_service_registry" },
);
}
const entry: ServiceRegistration = {
...registration,
name,
generation: ++generationCounter,
};
let providers = services.get(registration.service);
if (!providers) {
providers = new Map();
services.set(registration.service, providers);
}
providers.set(name, entry);
retired.delete(retiredKey(registration.service, name));
return entry;
}
/**
* Removes a provider, but only the exact registration given. A plugin that
* crashed and restarted must not revoke the replacement its own restart
* installed, so this is identity-checked rather than by name.
*/
export function revokeService(
service: string,
registration?: ServiceRegistration,
): boolean {
const name = registration?.name ?? "";
const current = lookup(service, name);
if (!current) return false;
if (registration && current.generation !== registration.generation) {
return false;
}
retired.set(retiredKey(service, name), {
pluginId: current.pluginId,
methods: new Set(
Object.keys(current.implementation).filter(
(key) => typeof current.implementation[key] === "function",
),
),
});
const providers = services.get(service)!;
providers.delete(name);
if (providers.size === 0) services.delete(service);
return true;
}
export function getRegistration(
service: string,
name = "",
): ServiceRegistration | undefined {
return lookup(service, name);
}
/** The names of every provider of a service right now ("" when unnamed). */
export function listProviderNames(service: string): string[] {
return [...(services.get(service)?.keys() ?? [])];
}
/**
* A plugin service as core itself calls it, or undefined while no plugin
* provides a compatible version.
*
* Core is not a plugin and already authorized the request it is serving, so
* this skips the per-call permission check a plugin's handle runs. It is the
* same object plugins get. Session sharing reaching the terminal's live
* sessions is the first caller.
*/
export function getServiceImplementation<T extends object>(
service: string,
versionRange: string,
name = "",
): T | undefined {
const registration = lookup(service, name);
if (!registration) return undefined;
if (!semver.satisfies(registration.version, versionRange)) return undefined;
return registration.implementation as T;
}
export function listServices(): ServiceRegistration[] {
return [...services.values()].flatMap((providers) => [...providers.values()]);
}
/** Test seam. */
export function clearServiceRegistry(): void {
services.clear();
retired.clear();
}
export interface RequirementResolution {
satisfied: boolean;
/** Human-readable reasons for each unsatisfied hard requirement. */
errors: string[];
/** Optional requirements that are not currently available. */
missingOptional: string[];
}
/**
* Structural resolution of a manifest's `requires` against what is registered
* right now. Generic over the service name -- nothing is special-cased.
*/
export function resolveRequirements(
manifest: PluginManifest,
): RequirementResolution {
const errors: string[] = [];
const missingOptional: string[] = [];
for (const requirement of manifest.requires ?? []) {
// Any one provider satisfies it; a consumer of a named service picks the
// provider per call.
const providers = [...(services.get(requirement.service)?.values() ?? [])];
const registration =
providers.find((candidate) =>
semver.satisfies(candidate.version, requirement.versionRange),
) ?? providers[0];
if (!registration) {
recordUnsatisfied(
requirement,
`requires service "${requirement.service}" (${requirement.versionRange}), which no active plugin provides`,
errors,
missingOptional,
);
continue;
}
if (!semver.satisfies(registration.version, requirement.versionRange)) {
recordUnsatisfied(
requirement,
`requires service "${requirement.service}" ${requirement.versionRange}, but ${registration.pluginId} provides ${registration.version}`,
errors,
missingOptional,
);
}
}
return { satisfied: errors.length === 0, errors, missingOptional };
}
function recordUnsatisfied(
requirement: PluginServiceRequire,
message: string,
errors: string[],
missingOptional: string[],
): void {
if (requirement.optional) {
missingOptional.push(requirement.service);
return;
}
errors.push(message);
}
export interface ServiceCallContext {
/** Resolves the user this call acts as. */
resolveUserId: () => string | undefined;
/** Called when a handle is bound to a named user with asUser(). */
nameUser?: (userId: string) => void;
/** Checks a role permission for a user. */
hasPermission: (userId: string, permission: string) => Promise<boolean>;
/**
* Writes the audit line. Must never throw.
*
* Awaited before the call's own result is handed back, so an action is on
* the trail before the caller can act on it. The broker can afford to fire
* and forget because it replies over postMessage; here the caller is
* holding the promise.
*/
audit: (entry: ServiceAuditEntry) => void | Promise<void>;
}
export interface ServiceAuditEntry {
userId: string;
consumerPluginId: string;
registration: ServiceRegistration;
method: string;
args: unknown[];
success: boolean;
errorMessage?: string;
}
/**
* Wraps a registration in a handle whose every method runs the permission
* check before delegating.
*
* A Proxy rather than a hand-written wrapper so this stays generic over
* whatever shape the provider chose to hand out. Only own, callable properties
* of that object are reachable -- a consumer can call exactly what the
* provider exposed and nothing else. This is a set of individually declared
* contracts, not a general "call any method on any plugin" bus.
*/
export function createServiceHandle<T extends object>(
service: string,
consumerPluginId: string,
context: ServiceCallContext,
name = "",
): T {
const target = Object.create(null) as T;
return new Proxy(target, {
get(_target, property) {
if (typeof property !== "string") return undefined;
if (property === "asUser") {
return (userId: string) =>
createServiceHandle<T>(
service,
consumerPluginId,
{
...context,
resolveUserId: () => {
context.nameUser?.(userId);
return userId;
},
},
name,
);
}
// Resolved at call time, not here: the provider may have gone away
// between taking the handle and using it. A method that WAS reachable
// must keep throwing the typed error rather than degrading to
// `undefined is not a function`, which tells a consumer nothing about
// why its dependency vanished.
const registration = lookup(service, name);
if (!registration) {
const gone = retired.get(retiredKey(service, name));
if (!gone?.methods.has(property)) return undefined;
return () => {
throw new PluginServiceUnavailableError(service, gone.pluginId);
};
}
const value = registration.implementation[property];
if (typeof value !== "function") return undefined;
return (...args: unknown[]) =>
invokeGuarded(
service,
property,
args,
consumerPluginId,
context,
registration.generation,
name,
);
},
has(_target, property) {
const registration = lookup(service, name);
if (!registration || typeof property !== "string") return false;
return typeof registration.implementation[property] === "function";
},
ownKeys() {
const registration = lookup(service, name);
if (!registration) return [];
return Object.keys(registration.implementation).filter(
(key) => typeof registration.implementation[key] === "function",
);
},
getOwnPropertyDescriptor(_target, property) {
const registration = lookup(service, name);
if (!registration || typeof property !== "string") return undefined;
if (typeof registration.implementation[property] !== "function") {
return undefined;
}
return { configurable: true, enumerable: true, writable: false };
},
}) as T;
}
async function invokeGuarded(
service: string,
method: string,
args: unknown[],
consumerPluginId: string,
context: ServiceCallContext,
expectedGeneration: number,
name: string,
): Promise<unknown> {
const registration = lookup(service, name);
// Re-checked here rather than trusted from the get trap: a call is async and
// the provider can be disabled between resolving the method and running it.
if (!registration || registration.generation !== expectedGeneration) {
throw new PluginServiceUnavailableError(
service,
registration?.pluginId ?? consumerPluginId,
);
}
const userId = context.resolveUserId();
if (!userId) throw new PluginServiceActorError(service);
const allowed = await context.hasPermission(userId, registration.permission);
if (!allowed) {
const error = new PluginServicePermissionError(
service,
registration.permission,
);
await context.audit({
userId,
consumerPluginId,
registration,
method,
args,
success: false,
errorMessage: error.message,
});
throw error;
}
const implementation = registration.implementation[method];
if (typeof implementation !== "function") {
throw new PluginServiceUnavailableError(service, registration.pluginId);
}
try {
// Runs as the user the permission was checked for, so the provider reads
// the same user from ctx.currentActor() and cannot be told another one.
const result = await runAsActor(userId, "service", () =>
(implementation as (...callArgs: unknown[]) => unknown).apply(
registration.implementation,
args,
),
);
await context.audit({
userId,
consumerPluginId,
registration,
method,
args,
success: true,
});
return result;
} catch (error) {
await context.audit({
userId,
consumerPluginId,
registration,
method,
args,
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
+422
View File
@@ -0,0 +1,422 @@
/**
* Plugin settings: validation, storage, encryption and change notification.
*
* One module behind both ctx.settings and the /plugins/:id/settings routes, so
* "what is a valid value", "which fields are secret" and "what a read hands
* back" are decided once. Two implementations would drift, and the one that
* drifted would be the one writing to the database.
*
* A field the manifest never declared is rejected rather than stored. That is
* what stops a PUT from filling a plugin's namespace with arbitrary keys, and
* it is why every entry point resolves the field first and the value second.
*/
import type {
PluginManifest,
PluginSettingsField,
} from "@termix/plugin-sdk/manifest";
import {
coerceSettingValue,
isRedactedSecret,
validateSettingValue,
} from "@termix/plugin-sdk/settings";
import {
createCurrentHostRepository,
createCurrentPluginSettingsRepository,
} from "../database/repositories/factory.js";
import type { PluginSettingsScope } from "../database/repositories/plugin-settings-repository.js";
import {
decryptSystemSecret,
encryptSystemSecret,
} from "../utils/system-secret-crypto.js";
import { pluginLogger } from "../utils/logger.js";
export type { PluginSettingsScope };
/**
* Core settings a plugin may read through ctx.settings.readCore.
*
* Deliberately short. Everything here is either already visible in the UI to
* every user, or a switch a plugin has to honour to behave correctly. Adding
* to it means deciding that every plugin holding settings:read-core may see
* that value, so it is a review decision rather than a convenience.
*/
export const CORE_SETTINGS_ALLOWLIST: readonly string[] = [
"app_name",
"notification_private_endpoint_allowlist",
];
type ChangeListener = (value: unknown) => void;
/** pluginId -> key -> listeners. In-process only; settings are not clustered. */
const listeners = new Map<string, Map<string, Set<ChangeListener>>>();
export function onSettingsChange(
pluginId: string,
key: string,
listener: ChangeListener,
): () => void {
let byKey = listeners.get(pluginId);
if (!byKey) {
byKey = new Map();
listeners.set(pluginId, byKey);
}
let set = byKey.get(key);
if (!set) {
set = new Set();
byKey.set(key, set);
}
set.add(listener);
return () => {
set!.delete(listener);
if (set!.size === 0) byKey!.delete(key);
if (byKey!.size === 0) listeners.delete(pluginId);
};
}
export function notifySettingChange(
pluginId: string,
key: string,
value: unknown,
): void {
for (const listener of listeners.get(pluginId)?.get(key) ?? []) {
try {
listener(value);
} catch (error) {
// A listener throwing must not fail the write that already happened.
pluginLogger.error(
`Plugin ${pluginId} settings listener for "${key}" failed`,
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_settings_change" },
);
}
}
}
export type SettingsValidator = (
values: Record<string, unknown>,
context: {
hostId?: number;
defaults?: { level: "admin" | "user" | "folder" };
},
) =>
| Record<string, string>
| void
| undefined
| Promise<Record<string, string> | void | undefined>;
const validators = new Map<
string,
Set<{ scope: PluginSettingsScope; run: SettingsValidator }>
>();
/** A plugin's own check on a settings save, before anything is written. */
export function onSettingsValidate(
pluginId: string,
scope: PluginSettingsScope,
run: SettingsValidator,
): () => void {
let set = validators.get(pluginId);
if (!set) {
set = new Set();
validators.set(pluginId, set);
}
const entry = { scope, run };
set.add(entry);
return () => {
set!.delete(entry);
};
}
/**
* Runs the plugin's validators over a save. Returns field key to message;
* a validator that throws rejects the whole save under "_".
*/
export async function validateSettingsSave(
pluginId: string,
scope: PluginSettingsScope,
scopeId: string | null,
values: Record<string, unknown>,
defaults?: { level: "admin" | "user" | "folder" },
): Promise<Record<string, string>> {
const errors: Record<string, string> = {};
for (const entry of validators.get(pluginId) ?? []) {
if (entry.scope !== scope) continue;
try {
const result = await entry.run(values, {
hostId: scope === "host" && scopeId ? Number(scopeId) : undefined,
...(defaults ? { defaults } : {}),
});
Object.assign(errors, result ?? {});
} catch (error) {
errors._ = error instanceof Error ? error.message : String(error);
}
}
return errors;
}
/** Drops every listener a plugin registered. Called when it deactivates. */
export function clearSettingsListeners(pluginId: string): void {
listeners.delete(pluginId);
validators.delete(pluginId);
}
/** The fields a manifest declares for one scope. */
export function declaredFields(
manifest: PluginManifest,
scope: PluginSettingsScope,
): PluginSettingsField[] {
const settings = manifest.contributes?.settings;
if (!settings) return [];
if (scope === "admin") return settings.admin ?? [];
if (scope === "user") return settings.user ?? [];
const host = settings.host;
if (!host) return [];
// The enable switch is a real stored key, so it has to behave like a field
// everywhere: validated on write, defaulted on read, rendered in the editor.
const enableField: PluginSettingsField[] = host.enableKey
? [
{
key: host.enableKey,
type: "boolean",
labelKey: host.enableLabelKey,
descriptionKey: host.enableDescriptionKey,
default: host.enableDefault ?? false,
},
]
: [];
return [...enableField, ...host.fields];
}
export function findField(
manifest: PluginManifest,
scope: PluginSettingsScope,
key: string,
): PluginSettingsField | undefined {
return declaredFields(manifest, scope).find((field) => field.key === key);
}
function normalizeScopeId(
scope: PluginSettingsScope,
scopeId: string | number | undefined | null,
): string | null {
if (scope === "admin") return null;
if (scopeId === undefined || scopeId === null) {
throw new Error(`Plugin settings scope "${scope}" requires a scope id`);
}
return String(scopeId);
}
/** Stored values are JSON so a field keeps its declared type on the way back. */
function encode(value: unknown): string {
return JSON.stringify(value ?? null);
}
function decode(raw: string | null): unknown {
if (raw === null) return undefined;
try {
return JSON.parse(raw);
} catch {
return undefined;
}
}
export async function getSetting(
manifest: PluginManifest,
scope: PluginSettingsScope,
scopeId: string | number | null | undefined,
key: string,
): Promise<unknown> {
const field = findField(manifest, scope, key);
if (!field) return undefined;
const row = await createCurrentPluginSettingsRepository().get(
manifest.id,
scope,
normalizeScopeId(scope, scopeId),
key,
);
if (!row || row.value === null) return field.default;
const decoded = decode(row.value);
if (decoded === undefined || decoded === null) return field.default;
if (row.encrypted && typeof decoded === "string") {
return decryptSystemSecret(decoded);
}
return decoded;
}
export interface SetSettingOptions {
/** Skips validation for a value core itself produced, such as a migration. */
trusted?: boolean;
}
/**
* Writes one value, after validating it against the declared field.
*
* Returns the error message when the value is rejected, or null on success,
* rather than throwing: a PUT reports every bad field at once and a throw per
* field would only ever surface the first.
*/
export async function setSetting(
manifest: PluginManifest,
scope: PluginSettingsScope,
scopeId: string | number | null | undefined,
key: string,
value: unknown,
options: SetSettingOptions = {},
): Promise<string | null> {
const field = findField(manifest, scope, key);
if (!field) {
return `"${key}" is not a settings field this plugin declares`;
}
// Echoing a redacted secret back means "leave it alone", so a form can save
// without clearing a key it was never shown.
if (field.type === "secret" && isRedactedSecret(value)) return null;
const coerced = options.trusted ? value : coerceSettingValue(field, value);
if (!options.trusted) {
const error = validateSettingValue(field, coerced);
if (error) return error;
}
// Clearing a secret writes null rather than an encrypted empty string, so a
// read reports it as unset instead of "set to nothing".
const clearingSecret = field.type === "secret" && coerced === "";
const shouldEncrypt =
field.type === "secret" && typeof coerced === "string" && !clearingSecret;
const stored = clearingSecret
? null
: shouldEncrypt
? encode(await encryptSystemSecret(coerced as string))
: encode(coerced);
await createCurrentPluginSettingsRepository().set(
manifest.id,
scope,
normalizeScopeId(scope, scopeId),
key,
stored,
shouldEncrypt,
);
notifySettingChange(manifest.id, key, coerced);
return null;
}
/** Every stored value of one host field, with each host's owner. */
export async function listHostValues(
manifest: PluginManifest,
key: string,
): Promise<Array<{ hostId: number; userId: string; value: unknown }>> {
const field = findField(manifest, "host", key);
if (!field) {
throw new Error(
`"${key}" is not a host settings field this plugin declares`,
);
}
if (field.type === "secret") {
throw new Error(`"${key}" is a secret and cannot be listed`);
}
const rows = await createCurrentPluginSettingsRepository().listByKey(
manifest.id,
"host",
key,
);
const hosts = createCurrentHostRepository();
const result: Array<{ hostId: number; userId: string; value: unknown }> = [];
for (const row of rows) {
const hostId = Number(row.scopeId);
if (!Number.isInteger(hostId) || row.value === null) continue;
const host = await hosts.findById(hostId);
if (!host?.userId) continue;
const value = decode(row.value);
if (value === undefined) continue;
result.push({ hostId, userId: host.userId as string, value });
}
return result;
}
export interface GetAllOptions {
/** Replaces every secret with { set } so the value never leaves the server. */
redactSecrets?: boolean;
}
/**
* Every declared field for one scope, with stored values over defaults.
*
* Driven by the manifest rather than by the rows, so a field that was never
* written still comes back with its default and a stale row for a field the
* plugin has since dropped is ignored instead of being handed to the UI.
*/
export async function getAllSettings(
manifest: PluginManifest,
scope: PluginSettingsScope,
scopeId: string | number | null | undefined,
options: GetAllOptions = {},
): Promise<Record<string, unknown>> {
const fields = declaredFields(manifest, scope);
if (fields.length === 0) return {};
const rows = await createCurrentPluginSettingsRepository().getAll(
manifest.id,
scope,
normalizeScopeId(scope, scopeId),
);
const byKey = new Map(rows.map((row) => [row.key, row]));
const values: Record<string, unknown> = {};
for (const field of fields) {
values[field.key] = await resolveFieldValue(
field,
byKey.get(field.key),
options,
);
}
return values;
}
/** Shared by getAllSettings and the host-list projection. */
export async function resolveFieldValue(
field: PluginSettingsField,
row: { value: string | null; encrypted: boolean } | undefined,
options: GetAllOptions = {},
): Promise<unknown> {
const decoded = row && row.value !== null ? decode(row.value) : undefined;
const hasValue = decoded !== undefined && decoded !== null && decoded !== "";
if (field.type === "secret") {
if (options.redactSecrets) return { set: hasValue };
if (!hasValue) return field.default;
return row!.encrypted && typeof decoded === "string"
? decryptSystemSecret(decoded)
: decoded;
}
return hasValue ? decoded : field.default;
}
/**
* Reads a core server setting. The caller has already checked
* settings:read-core; this only enforces the allowlist.
*/
export async function readCoreSetting(key: string): Promise<string | null> {
if (!CORE_SETTINGS_ALLOWLIST.includes(key)) {
throw new Error(
`Core setting "${key}" is not readable by plugins. Readable keys: ${CORE_SETTINGS_ALLOWLIST.join(", ")}`,
);
}
const { createCurrentSettingsRepository } =
await import("../database/repositories/factory.js");
return createCurrentSettingsRepository().get(key);
}
+159
View File
@@ -0,0 +1,159 @@
/**
* Which entities sync between a linked desktop and its server.
*
* Core registers its own at boot and a plugin registers its own through
* ctx.sync, so the list is built rather than hardcoded. The wire names are
* part of the protocol: records on both sides match on them, so renaming one
* orphans data.
*
* Order is dependency order, not declaration order. A row referencing another
* entity is resolved by that entity's syncId, so the target has to be written
* first. That is why every registration carries an explicit order.
*/
import type {
SyncEntityRegistration,
SyncRow,
} from "@termix/plugin-sdk/backend";
/** Core-only extras a plugin registration cannot set. */
export interface CoreSyncEntityOptions {
/** Server-to-desktop only. A desktop never pushes it. */
readOnly?: boolean;
/**
* Builds the user's rows instead of reading `table`, for data that is not
* a table row the user owns (hosts shared with them, plugin settings).
* Rows come back already on the wire: plaintext, with a syncId.
*/
load?: (userId: string) => Promise<SyncRow[]>;
/** Writes an inbound row, for an entity with `load`. */
write?: (userId: string, wire: SyncRow) => Promise<void>;
/** Deletes a row by syncId, for an entity with `load`. */
erase?: (userId: string, syncId: string) => Promise<void>;
/**
* Whether `load` would have returned this syncId if it existed. A row
* `load` cannot see right now (its plugin is off) is not a delete.
*/
covers?: (syncId: string) => boolean;
/** Keys kept off the content hash and out of writes. */
hashIgnore?: readonly string[];
/**
* Widens reference lookups past rows the user owns, e.g. a credential
* shared with them is a valid target for their host's credentialId.
*/
canReference?: (
userId: string,
ownerId: string,
id: number,
) => Promise<boolean>;
}
export interface RegisteredSyncEntity
extends SyncEntityRegistration, CoreSyncEntityOptions {
/** Which plugin registered it, or "core". */
owner: string;
order: number;
userColumn: string;
readOnlyFields: readonly string[];
encryptedFields: readonly string[];
}
const entities = new Map<string, RegisteredSyncEntity>();
const listeners = new Set<() => void>();
/** Core's own entities register under this owner. */
export const CORE_OWNER = "core";
export function registerEntity(
owner: string,
entity: SyncEntityRegistration,
core: CoreSyncEntityOptions = {},
): () => void {
if (!entity.type) {
throw new Error("A sync entity needs a type");
}
const existing = entities.get(entity.type);
if (existing && existing.owner !== owner) {
throw new Error(
`Sync entity "${entity.type}" is already registered by "${existing.owner}"`,
);
}
const registered: RegisteredSyncEntity = {
...entity,
...(owner === CORE_OWNER ? core : {}),
owner,
order: entity.order ?? 100,
userColumn: entity.userColumn ?? "userId",
readOnlyFields: entity.readOnlyFields ?? [],
encryptedFields: entity.encryptedFields ?? [],
};
entities.set(entity.type, registered);
notify();
return () => {
// Identity-checked: a plugin that restarted must not revoke the
// registration its own restart installed.
if (entities.get(entity.type) === registered) {
entities.delete(entity.type);
notify();
}
};
}
export function getEntity(type: string): RegisteredSyncEntity | undefined {
const direct = entities.get(type);
if (direct) return direct;
for (const entity of entities.values()) {
if (entity.answersTo?.includes(type)) return entity;
}
return undefined;
}
export function hasEntity(type: string): boolean {
return entities.has(type);
}
/** Every entity, in dependency order. */
export function listEntities(): RegisteredSyncEntity[] {
return [...entities.values()].sort((a, b) => {
if (a.order !== b.order) return a.order - b.order;
return a.type.localeCompare(b.type);
});
}
export function listEntityTypes(): string[] {
return listEntities().map((entity) => entity.type);
}
export function unregisterByOwner(owner: string): void {
let changed = false;
for (const [type, entity] of [...entities]) {
if (entity.owner === owner) {
entities.delete(type);
changed = true;
}
}
if (changed) notify();
}
/** Called whenever the set of entities changes (a plugin came or went). */
export function onEntitiesChanged(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
function notify(): void {
for (const listener of listeners) {
try {
listener();
} catch {
// A listener's failure is its own problem.
}
}
}
export function resetSyncRegistry(): void {
entities.clear();
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Turns a plugin's table definition into the Drizzle table it is queried
* through. The implementation lives in the SDK so createTestDb builds exactly
* the table the server does; the DDL emitters are there too, because
* termix-plugin writes the same SQL this server applies. Re-exported here so
* core keeps one import site.
*/
export { buildTable } from "@termix/plugin-sdk/table-builder";
export {
createTableSql,
adoptTableSql,
dropTableSql,
addColumnSql,
keyedColumns,
} from "@termix/plugin-sdk/ddl";
+86
View File
@@ -0,0 +1,86 @@
import crypto from "node:crypto";
export interface TrustedPluginKey {
/** First 16 hex chars of the sha256 of the raw public key. */
id: string;
/** Raw 32-byte Ed25519 public key, base64. */
publicKey: string;
/** The Termix release that started trusting it. */
addedIn: string;
}
/**
* The registry signing keys this build trusts. Compile-time on purpose: a
* key read from a registry index would let whoever serves the index sign
* anything.
*
* Rotation: add the new key here and ship a release, start signing with it,
* then remove the old key one release cycle later so installs that update
* late still verify plugins signed before the switch.
*
* Add a key with `termix-plugin keygen` (see the Termix-Registry README).
*/
export const TRUSTED_PLUGIN_KEYS: readonly TrustedPluginKey[] = [];
export type ArtifactVerification =
{ ok: true; keyId: string } | { ok: false; reason: string };
// DER prefix of an Ed25519 SubjectPublicKeyInfo; the raw key follows.
const SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
function toPublicKey(base64: string): crypto.KeyObject | null {
const raw = Buffer.from(base64, "base64");
if (raw.length !== 32) return null;
try {
return crypto.createPublicKey({
key: Buffer.concat([SPKI_PREFIX, raw]),
format: "der",
type: "spki",
});
} catch {
return null;
}
}
/**
* Checks that `buffer` hashes to `sha256` (hex) and that `signature`
* (base64 Ed25519 over the raw digest) was made by a pinned key.
*
* `keys` exists for tests. Production callers never pass it.
*/
export function verifyPluginArtifact(
buffer: Buffer,
sha256: string,
signature: string,
keys: readonly TrustedPluginKey[] = TRUSTED_PLUGIN_KEYS,
): ArtifactVerification {
const digest = crypto.createHash("sha256").update(buffer).digest();
const expected = Buffer.from(sha256.trim().toLowerCase(), "hex");
if (
expected.length !== digest.length ||
!crypto.timingSafeEqual(expected, digest)
) {
return { ok: false, reason: "sha256 does not match the file" };
}
const sig = Buffer.from(signature.trim(), "base64");
if (sig.length !== 64) {
return { ok: false, reason: "signature is not a valid Ed25519 signature" };
}
if (keys.length === 0) {
return { ok: false, reason: "this build trusts no signing keys" };
}
for (const key of keys) {
const publicKey = toPublicKey(key.publicKey);
if (publicKey && crypto.verify(null, digest, publicKey, sig)) {
return { ok: true, keyId: key.id };
}
}
return { ok: false, reason: "signature does not match a trusted key" };
}
/** TERMIX_REQUIRE_SIGNED_PLUGINS=true blocks unsigned user plugins. */
export function requireSignedPlugins(): boolean {
return process.env.TERMIX_REQUIRE_SIGNED_PLUGINS === "true";
}
+245
View File
@@ -0,0 +1,245 @@
/**
* A user's rows in plugin tables, for the per-user exports and imports.
*
* Core never names a plugin table. Any table a running plugin declared with a
* refUser column belongs to a user, so it travels with that user's export.
* Encrypted columns stay behind: they are sealed with this server's keys.
*/
import { sql } from "drizzle-orm";
import { columnName, createTableSql } from "@termix/plugin-sdk/ddl";
import { prefixedTableName } from "@termix/plugin-sdk/db";
import type { PluginTableDefinition } from "@termix/plugin-sdk/db";
import {
runStatement,
selectRows,
} from "../utils/crypto-migration/raw-rows.js";
import { listAllTables } from "./data.js";
export interface UserOwnedTable {
pluginId: string;
/** The physical table name, p_<id>_<name>. */
table: string;
/** The legacy core name the table adopted, for older export files. */
legacyName?: string;
userColumn: string;
/** Other refUser columns, such as a grantee or the user who shared. */
otherUserColumns: string[];
/** refHost columns. */
hostColumns: string[];
/** Columns that travel, as SQL names. The primary key and secrets do not. */
columns: string[];
definition: PluginTableDefinition;
}
export function listUserOwnedTables(): UserOwnedTable[] {
const owned: UserOwnedTable[] = [];
for (const { pluginId, definition } of listAllTables()) {
const entries = Object.entries(definition.columns);
const user = entries.find(([, column]) => column.type === "refUser");
if (!user) continue;
owned.push({
pluginId,
table: prefixedTableName(pluginId, definition.name),
legacyName: definition.adopts,
userColumn: columnName(user[0], user[1]),
otherUserColumns: entries
.filter(([, column]) => column.type === "refUser")
.slice(1)
.map(([property, column]) => columnName(property, column)),
hostColumns: entries
.filter(([, column]) => column.type === "refHost")
.map(([property, column]) => columnName(property, column)),
columns: entries
.filter(
([, column]) =>
column.type !== "id" &&
!column.primaryKey &&
column.type !== "encryptedText",
)
.map(([property, column]) => columnName(property, column)),
definition,
});
}
return owned;
}
type Row = Record<string, unknown>;
/** The user's rows in every user-owned plugin table, keyed by table. */
export async function readUserPluginRows(
userId: string,
): Promise<Record<string, Row[]>> {
const result: Record<string, Row[]> = {};
for (const owned of listUserOwnedTables()) {
try {
const rows = await selectRows<Row>(
sql`SELECT ${sql.join(
owned.columns.map((column) => sql.identifier(column)),
sql`, `,
)} FROM ${sql.identifier(owned.table)} WHERE ${sql.identifier(
owned.userColumn,
)} = ${userId}`,
);
if (rows.length > 0) result[owned.table] = rows;
} catch {
// The plugin's migration has not created the table yet.
}
}
return result;
}
interface SqliteLike {
exec(source: string): unknown;
prepare(source: string): {
run(...params: unknown[]): unknown;
all(...params: unknown[]): unknown[];
};
}
/** Writes the user's plugin rows into a SQLite export file. */
export async function writeUserPluginTables(
exportDb: SqliteLike,
userId: string,
): Promise<number> {
const rowsByTable = await readUserPluginRows(userId);
let written = 0;
for (const owned of listUserOwnedTables()) {
const rows = rowsByTable[owned.table];
if (!rows?.length) continue;
for (const statement of createTableSql(
"sqlite",
owned.pluginId,
owned.definition,
)) {
exportDb.exec(statement);
}
const insert = exportDb.prepare(
`INSERT INTO "${owned.table}" (${owned.columns
.map((column) => `"${column}"`)
.join(", ")}) VALUES (${owned.columns.map(() => "?").join(", ")})`,
);
for (const row of rows) {
insert.run(...owned.columns.map((column) => toSqlite(row[column])));
written++;
}
}
return written;
}
function toSqlite(value: unknown): unknown {
if (value === undefined) return null;
if (typeof value === "boolean") return value ? 1 : 0;
if (value instanceof Date) return value.toISOString();
if (value !== null && typeof value === "object") return JSON.stringify(value);
return value;
}
export interface PluginRowsImport {
imported: number;
skipped: number;
errors: string[];
}
/**
* Whether a row from an import file may be written as `userId`. The file is
* user input, so a reference to anyone else's user or host is not trusted:
* other user columns must point back at the row's own owner, and host columns
* at a host the importing user owns.
*/
async function rowReferencesAllowed(
owned: UserOwnedTable,
source: Row,
userId: string,
): Promise<boolean> {
const owner = source[owned.userColumn];
for (const column of owned.otherUserColumns) {
const value = source[column];
if (value !== null && value !== undefined && value !== owner) return false;
}
for (const column of owned.hostColumns) {
const value = source[column];
if (value === null || value === undefined) continue;
const rows = await selectRows(
sql`SELECT 1 FROM ${sql.identifier("ssh_data")} WHERE ${sql.identifier(
"id",
)} = ${value} AND ${sql.identifier("user_id")} = ${userId} LIMIT 1`,
);
if (rows.length === 0) return false;
}
return true;
}
/**
* Copies plugin rows from an import file into this server as `userId`. A row
* matching one the user already has is skipped, and so is one that points at
* another user or at a host the importer does not own. Files from before 2.9
* carry the legacy table names, which are read too.
*/
export async function importUserPluginRows(
importDb: SqliteLike,
userId: string,
): Promise<PluginRowsImport> {
const summary: PluginRowsImport = { imported: 0, skipped: 0, errors: [] };
for (const owned of listUserOwnedTables()) {
let rows: Row[] | null = null;
for (const name of [owned.table, owned.legacyName].filter(Boolean)) {
try {
rows = importDb.prepare(`SELECT * FROM "${name}"`).all() as Row[];
break;
} catch {
// not in this file under that name
}
}
if (!rows) continue;
for (const source of rows) {
const columns = owned.columns.filter(
(column) => column === owned.userColumn || source[column] !== undefined,
);
const owner = source[owned.userColumn];
const values = columns.map((column) =>
column === owned.userColumn ||
(owned.otherUserColumns.includes(column) && source[column] === owner)
? userId
: source[column],
);
try {
if (!(await rowReferencesAllowed(owned, source, userId))) {
summary.skipped++;
continue;
}
const existing = await selectRows(
sql`SELECT 1 FROM ${sql.identifier(owned.table)} WHERE ${sql.join(
columns.map((column, index) =>
values[index] === null
? sql`${sql.identifier(column)} IS NULL`
: sql`${sql.identifier(column)} = ${values[index]}`,
),
sql` AND `,
)} LIMIT 1`,
);
if (existing.length > 0) {
summary.skipped++;
continue;
}
await runStatement(
sql`INSERT INTO ${sql.identifier(owned.table)} (${sql.join(
columns.map((column) => sql.identifier(column)),
sql`, `,
)}) VALUES (${sql.join(
values.map((value) => sql`${value}`),
sql`, `,
)})`,
);
summary.imported++;
} catch (error) {
summary.errors.push(
`${owned.table} import error: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
return summary;
}
+407
View File
@@ -0,0 +1,407 @@
/**
* WebSockets for plugins: one upgrade handler, no ports.
*
* A plugin registers /plugin-ws/<id>/<path> and core serves it from the main
* server's upgrade event. Auth is the same as every other socket in Termix
* (utils/ws-auth.ts), the actor is set for the handler, and every socket a
* plugin opened is closed when it deactivates.
*
* Two shapes, because two kinds of caller exist:
*
* - route(): core owns the WebSocketServer and hands the plugin a live
* socket. What a plugin writing its own protocol wants.
* - upgrade(): core hands over the raw upgrade after authenticating it, for
* a library that insists on owning its own server. guacamole-lite is the
* reason this exists: it constructs a WebSocketServer internally, so the
* only way to attach it is to give it the upgrade.
*
* Both run auth first, so "bring your own server" never means "bring your own
* auth".
*/
import type { IncomingMessage, Server as HttpServer } from "node:http";
import type { Duplex } from "node:stream";
import { WebSocketServer, type WebSocket } from "ws";
import type {
PluginWebSocketHandler,
PluginWebSocketOptions,
} from "@termix/plugin-sdk/backend";
import { pluginLogger } from "../utils/logger.js";
import { recordConflict } from "./conflicts.js";
import { extractWebSocketToken } from "../utils/ws-auth.js";
import { runAsActor } from "./actor.js";
import { isPluginInstalled } from "./http.js";
const PLUGIN_WS_PREFIX = "/plugin-ws/";
type RawUpgradeHandler = (
request: IncomingMessage,
socket: Duplex,
head: Buffer,
userId: string,
) => void;
interface Route {
pluginId: string;
path: string;
options: PluginWebSocketOptions;
/** Declared capabilities, for the per-upgrade network:serve check. */
declared: readonly string[];
handler?: PluginWebSocketHandler;
rawHandler?: RawUpgradeHandler;
/** Sockets handed to rawHandler, destroyed when the route goes away. */
rawSockets?: Set<Duplex>;
}
/** Largest frame a plugin socket accepts. ws defaults to 100MB. */
const MAX_PAYLOAD_BYTES = 64 * 1024 * 1024;
/** Keyed by "<pluginId>:<path>". */
const routes = new Map<string, Route>();
/** One server per plugin route, so disposal can close exactly its sockets. */
const servers = new Map<string, WebSocketServer>();
const attachedServers = new Set<HttpServer>();
function normalizePath(path: string): string {
const withSlash = path.startsWith("/") ? path : `/${path}`;
return withSlash.length > 1 && withSlash.endsWith("/")
? withSlash.slice(0, -1)
: withSlash;
}
function key(pluginId: string, path: string): string {
return `${pluginId}:${normalizePath(path)}`;
}
/**
* Parses /plugin-ws/<id>/<path> out of an upgrade URL.
*
* Returns null for anything else, so core's own sockets and a stray request
* both fall through to whatever else is listening rather than being claimed.
*/
export function parsePluginWsUrl(
url: string | undefined,
): { pluginId: string; path: string } | null {
if (!url) return null;
const pathname = url.split("?")[0];
if (!pathname.startsWith(PLUGIN_WS_PREFIX)) return null;
const rest = pathname.slice(PLUGIN_WS_PREFIX.length);
const slash = rest.indexOf("/");
if (slash <= 0) return null;
const pluginId = rest.slice(0, slash);
const path = normalizePath(rest.slice(slash));
if (!pluginId || path === "/") return null;
return { pluginId, path };
}
/**
* Resolves the user behind an upgrade, or null.
*
* A half-authenticated session is not a user: a token still awaiting TOTP is
* refused here exactly as createAuthMiddleware refuses it for HTTP, so a
* socket cannot be the way around the second factor.
*/
async function verifyToken(request: IncomingMessage): Promise<string | null> {
const token = extractWebSocketToken(request);
if (!token) return null;
try {
const { AuthManager } = await import("../utils/auth-manager.js");
const payload = await AuthManager.getInstance().verifyJWTToken(token);
if (!payload || payload.pendingTOTP) return null;
return payload.userId ?? null;
} catch {
return null;
}
}
function reject(socket: Duplex, status: number, message: string): void {
try {
socket.write(`HTTP/1.1 ${status} ${message}\r\nConnection: close\r\n\r\n`);
} catch {
// The peer may already be gone.
}
socket.destroy();
}
/**
* Serves a plugin upgrade, or returns false so the caller can pass it on.
*
* Exported for the test suite and for database.ts, which wires it into both
* the HTTP and the HTTPS server.
*/
async function handlePluginUpgrade(
request: IncomingMessage,
socket: Duplex,
head: Buffer,
): Promise<boolean> {
const parsed = parsePluginWsUrl(request.url);
if (!parsed) return false;
const route = routes.get(key(parsed.pluginId, parsed.path));
if (!route) {
// Claimed by the prefix but nothing is listening: answering here is more
// honest than leaving the socket hanging. A disabled plugin has no routes
// left, so an installed id means "off", not "missing".
if (isPluginInstalled(parsed.pluginId)) {
reject(socket, 503, "Service Unavailable");
} else {
reject(socket, 404, "Not Found");
}
return true;
}
// Same reasoning as the HTTP side: the grant is in the database, so it is
// checked per upgrade rather than at registration, and a revoke takes effect
// on the next connection without a restart.
const { hasCapability } = await import("./permissions.js");
if (!(await hasCapability(route.pluginId, "network:serve", route.declared))) {
reject(socket, 403, "Forbidden");
return true;
}
let userId = "";
if (!route.options.public) {
const resolved = await verifyToken(request);
if (!resolved) {
reject(socket, 401, "Unauthorized");
return true;
}
userId = resolved;
} else if (route.options.optionalAuth) {
userId = (await verifyToken(request)) ?? "";
}
// A guest on a public socket has no actor at all, the same as a public
// HTTP route, so nothing downstream mistakes a placeholder for a user.
const asCaller = (fn: () => void) =>
userId ? runAsActor(userId, "request", fn) : fn();
if (route.rawHandler) {
const rawHandler = route.rawHandler;
const tracked = route.rawSockets;
if (tracked) {
tracked.add(socket);
socket.once("close", () => tracked.delete(socket));
}
asCaller(() => rawHandler(request, socket, head, userId));
return true;
}
const server = servers.get(key(route.pluginId, route.path));
if (!server) {
reject(socket, 503, "Service Unavailable");
return true;
}
const { getClientIp, getRequestOrigin } =
await import("../utils/request-origin.js");
const { DataCrypto } = await import("../utils/data-crypto.js");
const connection = {
userId,
request,
clientIp: getClientIp(request as never),
requestOrigin: getRequestOrigin(request as never),
isDataUnlocked: () => !!userId && !!DataCrypto.getUserDataKey(userId),
};
server.handleUpgrade(request, socket, head, (ws: WebSocket) => {
const handler = route.handler;
if (!handler) {
ws.close(1011, "No handler");
return;
}
asCaller(() => {
void Promise.resolve(handler({ ...connection, socket: ws })).catch(
(error) => {
pluginLogger.error(
`Plugin ${route.pluginId} socket ${route.path} failed`,
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_ws_error" },
);
try {
ws.close(1011, "Plugin error");
} catch {
// Already closed.
}
},
);
});
});
return true;
}
/** Wires the upgrade handler onto a server. Idempotent per server. */
export function attachPluginWebSockets(server: HttpServer): void {
if (attachedServers.has(server)) return;
attachedServers.add(server);
server.on("upgrade", (request, socket, head) => {
void handlePluginUpgrade(
request as IncomingMessage,
socket as Duplex,
head as Buffer,
).catch((error) => {
pluginLogger.error(
"Plugin WebSocket upgrade failed",
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_ws_upgrade" },
);
reject(socket as Duplex, 500, "Internal Server Error");
});
});
}
export function registerPluginWsRoute(
pluginId: string,
path: string,
handler: PluginWebSocketHandler,
declared: readonly string[],
options: PluginWebSocketOptions = {},
): () => void {
const routeKey = key(pluginId, path);
const normalized = normalizePath(path);
if (routes.has(routeKey)) {
recordConflict({ kind: "ws", pluginId, name: normalized });
throw new Error(
`/plugin-ws/${pluginId}${normalized} is already registered`,
);
}
routes.set(routeKey, {
pluginId,
path: normalized,
options,
declared,
handler,
});
if (options.public) auditPublicSocket(pluginId, normalized);
servers.set(
routeKey,
new WebSocketServer({ noServer: true, maxPayload: MAX_PAYLOAD_BYTES }),
);
pluginLogger.info(`Mounted /plugin-ws/${pluginId}${normalized}`, {
operation: "plugin_ws_mount",
});
return () => disposeRoute(routeKey, pluginId, normalized);
}
export function registerPluginWsUpgrade(
pluginId: string,
path: string,
handler: RawUpgradeHandler,
declared: readonly string[],
options: PluginWebSocketOptions = {},
): () => void {
const routeKey = key(pluginId, path);
const normalized = normalizePath(path);
if (routes.has(routeKey)) {
recordConflict({ kind: "ws", pluginId, name: normalized });
throw new Error(
`/plugin-ws/${pluginId}${normalized} is already registered`,
);
}
routes.set(routeKey, {
pluginId,
path: normalized,
options,
declared,
rawHandler: handler,
rawSockets: new Set(),
});
if (options.public) auditPublicSocket(pluginId, normalized);
pluginLogger.info(`Mounted raw upgrade /plugin-ws/${pluginId}${normalized}`, {
operation: "plugin_ws_mount",
});
return () => disposeRoute(routeKey, pluginId, normalized);
}
function disposeRoute(routeKey: string, pluginId: string, path: string): void {
const route = routes.get(routeKey);
routes.delete(routeKey);
for (const socket of route?.rawSockets ?? []) {
try {
socket.destroy();
} catch {
// Already gone.
}
}
route?.rawSockets?.clear();
const server = servers.get(routeKey);
if (server) {
servers.delete(routeKey);
// Close the clients first: server.close() waits for them to drain, and a
// live terminal would otherwise keep the route alive after deactivate.
for (const client of server.clients) {
try {
client.close(1001, "Plugin disabled");
} catch {
// Already gone.
}
}
try {
server.close();
} catch {
// Closing twice is not an error worth surfacing.
}
}
pluginLogger.info(`Unmounted /plugin-ws/${pluginId}${path}`, {
operation: "plugin_ws_unmount",
});
}
export function getRegisteredWsRoutes(): string[] {
return [...routes.keys()];
}
/** The socket paths a plugin serves without core's login check. */
export function getPluginPublicWsRoutes(pluginId: string): string[] {
return [...routes.values()]
.filter((route) => route.pluginId === pluginId && route.options.public)
.map((route) => route.path);
}
/** Same record the HTTP side writes for its public routes. */
function auditPublicSocket(pluginId: string, path: string): void {
pluginLogger.warn(
`Plugin ${pluginId} serves /plugin-ws/${pluginId}${path} without core authentication`,
{ operation: "plugin_ws_public" },
);
void import("../utils/audit-logger.js")
.then(({ logAudit }) =>
logAudit({
userId: "system",
username: `plugin:${pluginId}`,
action: "plugin_ws_public_route",
resourceType: "plugin",
resourceId: pluginId,
resourceName: pluginId,
details: `unauthenticated socket: ${path}`,
success: true,
}),
)
.catch(() => {});
}
/** Test seam. */
export function resetPluginWebSockets(): void {
for (const routeKey of [...routes.keys()]) {
const route = routes.get(routeKey);
if (route) disposeRoute(routeKey, route.pluginId, route.path);
}
routes.clear();
servers.clear();
attachedServers.clear();
}