# Plugin System

> **Area:** plugin-system
> **Status:** Active
> **Last updated:** 2026-05-25

## Overview

Plugins extend Toju's renderer with bundled or local ES modules that can publish events into a server, register UI contributions (pages, panels, actions, channel sections, embed renderers), store per-user or per-server data, and exchange messages over P2P. They are described by a typed manifest, gated by an explicit capability grant model, and coordinated across three subdomains: the Electron plugin loader (`electron/plugin-library.ts`), the renderer plugin runtime (`toju-app/src/app/domains/plugins/`), and the server's plugin-support surface (`server/src/routes/plugin-support.ts`). This area documents the contract those three sides share.

## Responsibilities

- Define the canonical plugin manifest shape (`TojuPluginManifest`) and its semantic versioning, dependency, and capability requirements.
- Discover local plugin manifests from disk in Electron and surface them to the renderer.
- Load plugin modules — local file://, http(s)://, or bundled — into the renderer and run their lifecycle hooks.
- Gate every host API call by an explicit capability grant.
- Persist server-scoped plugin requirements and event definitions on the signaling server, broadcasting changes to connected clients.

This area does **not** own:

- The wire format of `plugin_event` envelopes themselves — those live with [websocket-envelopes](./websocket-envelopes.md); this area defines the **validation rules** applied to them.
- IPC plumbing for plugin manifest discovery — that's the [ipc-bridge](./ipc-bridge.md) surface.
- Per-plugin business logic — that lives in the plugin's own code.

## Key concepts

- **Manifest** — `TojuPluginManifest` (`toju-app/src/app/shared-kernel/plugin-system.contracts.ts`). Required: `id`, `title`, `description`, `version`, `apiVersion`, `kind`, `schemaVersion` (fixed `1`), `compatibility.minimumTojuVersion`. Optional: `entrypoint`, `bundle`, `capabilities[]`, `events[]`, `data[]`, `ui`, `settings`, `relationships`, `authors`, `pluginUser`, `scope`, `homepage`, `changelog`, `license`, `readme`, `load.priority`.
- **Capability** — a string ID (e.g. `messages.send`, `events.server.publish`, `ui.pages`, `storage.local`). Plugins declare what they need in `capabilities[]`; the host enforces grants per plugin.
- **Plugin event** — a declared `{ eventName, direction, scope, schema?, maxPayloadBytes? }` tuple. `direction` is `clientToServer | serverRelay | p2pHint`; `scope` is `server | channel | user | plugin`.
- **Capability grant** — an entry in `metoyou_plugin_capability_grants` (localStorage + desktop file) recording user consent for a `(pluginId, capability)` pair.
- **Activation context** — `TojuPluginActivationContext` — what a plugin module receives in its `activate(context)` hook: `pluginId`, `manifest`, `api` (the capability-gated `TojuClientPluginApi`), and a `subscriptions[]` cleanup list.
- **Local plugin** — a folder under `${app.getPath('userData')}/plugins/<id>/` containing `toju-plugin.json` (preferred) or `plugin.json` and resolved relative assets.

---

## Manifest contract

Declared in `toju-app/src/app/shared-kernel/plugin-system.contracts.ts` (the source of truth on the renderer side; the Electron loader treats the manifest as `unknown` until the renderer validates it).

Highlights:

- **`schemaVersion: 1`** — fixed; bump only with a coordinated migration.
- **`apiVersion`** — declares which host API surface the plugin expects.
- **`kind`** — distinguishes plugin types (currently a string union; see file for exact members).
- **`bundle.url`** + optional `bundle.entrypointUrl` — for remote / store-installed plugins.
- **`entrypoint`** — relative path within the local plugin folder; rejected if it escapes `pluginRoot`.
- **`relationships.{requires,optional,conflicts,before,after}`** — `pluginId` + optional `versionRange`. Resolved at activation; missing required dependencies block activation.
- **`events[]`** — registered with the server via `plugin-support` when the plugin is required by a server. The server uses these definitions to validate inbound `plugin_event` envelopes.
- **`data[]`** — `{ key, scope: server|channel|user|plugin, storage: local|serverData, schema? }`.
- **`load.priority`** — `bootstrap | high | default | low`; controls ordering when several plugins are activated together.
- **`pluginUser`** — synthetic user identity for messages a plugin posts on its own behalf.

---

## Discovery & loading

### Local discovery (Electron main)

`electron/plugin-library.ts`:

- Scans `${app.getPath('userData')}/plugins/` **one level deep** for plugin folders.
- For each folder, reads `toju-plugin.json` (preferred) or `plugin.json` as raw JSON. No schema validation at this layer.
- Resolves relative paths (`entrypoint`, `readme`) against the plugin root, rejecting `..`, absolute paths, or anything that escapes the root via `isPathInside()` realpath check.
- Returns `LocalPluginDiscoveryResult { plugins, errors, pluginsPath }` where each `plugins[i]` is a `LocalPluginManifestDescriptor` carrying the raw manifest, manifest path, plugin root, plugin-root `file://` URL, optional entrypoint and readme paths, and a `discoveredAt` timestamp.

Exposed to the renderer through the [ipc-bridge](./ipc-bridge.md) as `listLocalPluginManifests` and `getLocalPluginsPath`.

### Renderer runtime

`toju-app/src/app/domains/plugins/`:

- **`PluginHostService`** — orchestrates lifecycle: `discoverLocalPlugins`, `registerLocalManifest`, `activate`, `deactivate`, `reload`, `loadPluginModule`.
- **`PluginClientApiService`** — constructs the capability-gated facade `TojuClientPluginApi` per plugin. Subsystems: `channels`, `events`, `messages`, `messageBus`, `p2p`, `profile`, `users`, `roles`, `server`, `attachments`, `media`, `storage`, `serverData`, `clientData`, `ui`, `logger`, `context`.
- **`PluginCapabilityService`** — `grant(pluginId, capability)`, `revoke()`, `grantAll(manifest)`, `assert(pluginId, capability)`. Storage in `metoyou_plugin_capability_grants` (localStorage + desktop file).
- **`PluginMessageBusService`** — plugin-scoped pub/sub with topic, optional channel/peer targeting, optional message replay.
- **`PluginStorageService`** — split storage paths for `local` and `serverData` scopes.
- **`PluginUiRegistryService`** — central registry of UI contributions consumed by `plugin-render-host`, `plugin-page-host`, `plugin-action-menu`.
- **`PluginRequirementStateService`**, **`PluginDesktopStateService`** — state slices.

### Module loading

- Entrypoint URL is `file://` for local plugins, `http(s)://` for remote, or the `bundle.url` for bundled.
- Bytes are fetched, wrapped in a `Blob`-backed object URL, then imported via dynamic `import()` so devtools and stack traces resolve.
- `GuardedPluginMutationObserver` wraps observer callbacks to catch plugin errors and break infinite redispatch loops.

### Lifecycle states

From `plugin-runtime.models.ts`: `discovered → validated → blocked | loading → ready → loaded → failed | unloading → unloaded → disabled`.

### Module contract

```ts
export interface TojuClientPluginModule {
  activate?(context: TojuPluginActivationContext): void | Promise<void>;
  deactivate?(context: TojuPluginActivationContext): void | Promise<void>;
  ready?(context: TojuPluginActivationContext): void | Promise<void>;
  onServerRequirementsChanged?(context, snapshot): void | Promise<void>;
  onPluginDataChanged?(context, event): void | Promise<void>;
}
```

---

## Server surface (`server/src/routes/plugin-support.ts`)

Each endpoint scoped to `:serverId`. Permission `manageServer` enforced for mutations via `server-permissions.service.ts`.

- `GET /:serverId/plugins` → `PluginRequirementsSnapshot` — full set of required/optional plugins + event definitions for the server.
- `PUT /:serverId/plugins/:pluginId/requirement` — upsert a requirement: `status: required | optional | recommended | blocked | incompatible`, `installUrl?`, `sourceUrl?`, `manifest?`, `versionRange?`.
- `DELETE /:serverId/plugins/:pluginId/requirement` — remove.
- `PUT /:serverId/plugins/:pluginId/events/:eventName` — register or update an event definition (`direction`, `scope`, `schema?`, `maxPayloadBytes?`).
- `DELETE /:serverId/plugins/:pluginId/events/:eventName` — delete.
- `GET|PUT|DELETE /:serverId/plugins/:pluginId/data/:key` — **disabled** on the signaling server (returns HTTP 410). Plugin data on the server is intentionally out of scope.

Identifier patterns:

- `pluginId`: `/^[a-z0-9][a-z0-9.-]{1,126}[a-z0-9]$/`
- `eventName`: `/^[a-z][a-z0-9.:-]{1,126}[a-z0-9]$/`

Changes broadcast to connected clients via the [websocket-envelopes](./websocket-envelopes.md) — the matching `PluginRequirementsSnapshot` is also delivered as part of the `join_server` and `view_server` responses.

---

## Plugin event validation

`server/src/services/plugin-support.service.ts` exposes `validatePluginEventEnvelope()`. The `plugin_event` envelope handler (`server/src/websocket/handler.ts`) calls it before broadcasting. Validation checks:

- `eventName` is registered for `pluginId` on `serverId`.
- `direction` permits the source (clientToServer vs p2pHint vs serverRelay).
- `payload` size ≤ `maxPayloadBytes` (default 64 KB).
- If `schema` was declared in the manifest, the payload conforms — TODO: confirm the schema dialect (looks like JSON Schema subset).

## Security considerations

- **Plugins run in the renderer's JS context.** There is no true sandbox: plugins have DOM access, can read/write `localStorage`, and can issue `fetch` requests subject to the renderer's CSP.
- **Capability model is the primary security boundary.** Every method on `TojuClientPluginApi` calls `PluginCapabilityService.assert(pluginId, capability)`. Missing grant → `PluginCapabilityError`.
- **Path traversal** in the local plugin loader is blocked by `isPathInside()` realpath checks (`electron/plugin-library.ts`).
- **Payload bounds** on plugin events: default 64 KB, configurable per event definition.
- **No code signing or integrity verification.** Plugins are trusted to the extent the user granted capabilities. The plugin store flow is documented in `docs-site/docs/plugin-development/`.
- **TODO**: review whether `bundle.url` fetches go through a CSP / allow-list or are unbounded.

## Configuration

- **Local plugins path**: `${app.getPath('userData')}/plugins/` (Electron). Exposed as `getLocalPluginsPath`.
- **Capability grants**: `metoyou_plugin_capability_grants` in localStorage; mirrored to a desktop file via `PluginDesktopStateService`.
- **Server-side persistence**: plugin requirements and event definitions are stored in the server's database; entities and migrations live alongside other server entities (TODO: enumerate the specific entities).

## Testing

- **Electron**: `electron/plugin-library.spec.ts` — covers valid/invalid JSON, path traversal rejection, escaping entrypoints. Uses fixture `TEST_PLUGIN_FIXTURE_DIR`.
- **Renderer**: `plugin-host.service.spec.ts`, `plugin-store.service.spec.ts`, `local-plugin-discovery.service.spec.ts`.
- **Server**: `server/src/websocket/handler-plugin.spec.ts` — `plugin_event` validation flow.
- **E2E**: `e2e/tests/.../plugin-support-api.spec.ts`, `plugin-manager-ui.spec.ts`, `plugin-api-two-users.spec.ts`.

## Known issues and limitations

- **Server-side plugin data is intentionally disabled** (`410 Gone` on `*/data/:key` routes). Plugins must currently treat `serverData` storage as not yet implemented on the signaling server. TODO: clarify whether this is a permanent boundary or scheduled work.
- **No true sandbox** for plugin execution. The capability model is the only restraint between a plugin and the renderer's globals.
- **Manifest validation is renderer-side.** The Electron loader treats manifests as raw JSON; malformed or hostile manifests are caught when the renderer registers them.
- **No host-side plugin allow-list** beyond per-server requirements — a user can install any local plugin.

## Related features

- **[ipc-bridge](./ipc-bridge.md)** — surfaces `listLocalPluginManifests`, `getLocalPluginsPath`, and the plugin CQRS commands (`SavePluginData`, `DeletePluginData`, `GetPluginData`).
- **[websocket-envelopes](./websocket-envelopes.md)** — defines the `plugin_event` envelope this area validates.
- **[server-directory](./server-directory.md)** — `join_server` / `view_server` responses include `PluginRequirementsSnapshot` for the joined server.

## Documentation for plugin authors

Author-facing docs ship with the desktop app via Docusaurus:

- `docs-site/docs/plugin-development/create-a-plugin.md`
- `docs-site/docs/user-guide/plugins.md`
- `docs-site/docs/developer/llm-plugin-builder-guide.md`

## Changelog

| Date | Change |
|------|--------|
| 2026-05-25 | Initial documentation |
