# Plugins
Orbit has a small, **operator-controlled** plugin system. A deployment lists
plugin scripts in `config.json`; each is loaded at startup and registers against
a global `Orbit` object to hook events, read state, send IRC, theme the UI, and
add UI in predefined slots.
> **Status: experimental.** Orbit is a work in progress and this API may change
> between releases. Plugins are deployment-controlled (same trust level as the
> app itself) — there is no user-uploaded plugin marketplace, by design.
## Which kind of plugin (the trust rule)
Every plugin runs in **each visitor's browser**, so the security question isn't "do
I trust this code" but "whose data pays if I'm wrong" — and the answer is your users,
who never chose the plugin. A plugin with full page access can read a visitor's
session and login credential, act as them, and read their DMs. That gives one hard
rule, and three buckets:
> **Untrusted code is always sandboxed. No exceptions.**
1. **Core UI** — the chat, composer, sidebar, settings. This is the app, not a plugin.
2. **Built-in extras** — first-party features you wrote and review (radio, clock,
dice, copy, games). Trusted, so isolation is a *choice*, made by fit:
- a self-contained widget (its own panel or button, reaching IRC through the API)
→ **sandbox it**: it costs nothing and doubles as a reference plugin that can't
crash the app (radio, clock, dice — see [PLUGIN-SDK.md](./PLUGIN-SDK.md));
- something woven into the UI (a control on every message, a filter over the
message stream, or a feature that needs the app's own URL) → **run it in-page**,
with the full React API below; sandboxing it fights the model for no gain
(copy, games).
3. **Third-party plugins** — anything you didn't write and audit yourself. **Always
sandboxed**, however trivial it looks. This is the bucket the rule is for.
Same split a lot of servers use: essential, deeply-integrated behaviour is built into
the core, optional self-contained bits are separate modules. Orbit does the same, with
one twist — a web client can be handed genuinely untrusted plugins, so its module tier
(the sandbox) is a real security boundary, not just packaging.
## Where plugins live
Plugin files live under **`public/plugins/third/`** (served at `/app/plugins/third/`).
Per the trust rule above, third-party plugins are **always sandboxed** — an
opaque-origin iframe reachable only through the capability bridge (see
[SANDBOX.md](./SANDBOX.md); author them with the [SDK](./PLUGIN-SDK.md)). In-page
loading (full page access, no isolation) stays available, but only for trusted
first-party code — never for anything you didn't write yourself.
## Enabling plugins
Add script URLs to `plugins` in [`config.json`](../CONFIG.md). Prefer the sandboxed
object form with an explicit permission list:
```json
{ "plugins": [{ "url": "/app/plugins/third/orbit-clock.js", "sandbox": true, "permissions": [] }] }
```
A bare URL (or an object without `"sandbox": true`) still loads **in-page** — trusted,
full access — for first-party plugins that need the React API:
```json
{ "plugins": ["/app/plugins/third/orbit-demo.js"] }
```
They load in order, after the app boots. Host them anywhere the page can reach
(same-origin recommended). For a third-party origin, pin the file with
Subresource Integrity by giving an object instead of a URL:
```json
{ "plugins": [{ "url": "https://cdn.example/x.js", "integrity": "sha384-…" }] }
```
(`crossorigin` defaults to `anonymous` when an `integrity` hash is set.) See
[SECURITY.md](../SECURITY.md) for generating the hash and a sample CSP header.
## Writing a plugin
A plugin is a plain `.js` file that calls `Orbit.plugin()`:
```js
Orbit.plugin('my-plugin', (orbit, log) => {
log('loaded, Orbit v' + orbit.version);
orbit.on('message', (m) => log(m.from, 'said', m.text, 'in', m.target));
orbit.addUi('composer_button', () =>
orbit.html``);
});
```
UI is authored with the `html` tagged template ([HTM](https://github.com/developit/htm),
bound to the app's React) — runtime template markup, no build step. Prefer
`orbit.h(...)` (`React.createElement`) if you'd rather not use templates.
## The `Orbit` API
### Global
| Member | Description |
|---|---|
| `Orbit.version` / `Orbit.commit` | app version + git commit (build-time) |
| `Orbit.apiVersion` | plugin API contract version (bumped on surface changes) — feature-detect with `if (Orbit.apiVersion >= N) …` |
| `Orbit.plugin(name, fn)` | register a plugin; `fn(orbit, log)` |
| `Orbit.on/once/off/emit(event, …)` | the app event bus |
| `Orbit.config()` | the resolved runtime config |
| `Orbit.React` / `Orbit.ReactDOM` / `Orbit.jsxRuntime` / `Orbit.Fragment` / `Orbit.h` / `Orbit.html` | render primitives (externalization targets for compiled plugins) |
### Inside `Orbit.plugin(name, (orbit) => …)`
| Member | Description |
|---|---|
| `orbit.on/once/off/emit` | event bus (see events below) |
| `orbit.state.active()` | active buffer name |
| `orbit.state.nick()` / `account()` | your nick / logged-in account |
| `orbit.state.buffers()` | open buffer names |
| `orbit.state.get()` | full store snapshot (read-only) |
| `orbit.server.hasCap(cap)` | is an IRCv3 capability negotiated? — gate cap-dependent features so you never 421 a leaner server |
| `orbit.server.caps()` | every cap Orbit negotiates, each `{name, available, enabled}` |
| `orbit.server.isupport()` | read-only snapshot of the ISUPPORT (005) tokens |
| `orbit.server.network()` | network name (ISUPPORT NETWORK) |
| `orbit.server.numeric(code)` | RPL/ERR name for a numeric reply, e.g. `'433'` → `'ERR_NICKNAMEINUSE'` |
| `orbit.irc.send(line)` | send a raw IRC line |
| `orbit.irc.msg(target, text)` | PRIVMSG a target |
| `orbit.irc.say(text)` | send to the active buffer |
| `orbit.irc.join(chan)` / `part(chan)` | join / part |
| `orbit.irc.list()` | request the channel list |
| `orbit.themes.current()/list()/set(id)` | read/set the theme |
| `orbit.storage.get(key, def)/set(key, val)` | namespaced persistence |
| `orbit.addUi(slot, render)` | add UI to a slot (returns a remover) |
| `orbit.addSettingsSection({label, icon?, render})` | add a whole Settings section |
| `orbit.addMessageDecorator(m => …)` | inline UI after every message's text; `m` = `{id, nick, text, raw, kind, ts, mine}` (`text` is formatting-stripped, `raw` keeps mIRC codes) |
| `orbit.addMessageAction(m => …)` | a button in every message's hover action toolbar (next to reply/react); same `m` |
| `orbit.h / orbit.html` | render helpers |
| `log(…)` | namespaced console logger |
### Events
`ready`, `connected` (`{nick}`), `status` (connection status string),
`buffer.active` (buffer name), `message` (`{from, target, text, self}`),
`raw` (the parsed `IrcMessage`).
### UI slots
| Slot | Where |
|---|---|
| `composer_button` | a button in the message composer toolbar |
| `topbar_item` | an item in the channel topbar action row (next to search / notifications) |
| `sidebar_item` | an item in the conversation sidebar header (next to the compose button) |
| `navbar` | a full-width bar across the very top of the app (network branding + portal links) |
| `settings_section` | a whole section in Settings (own nav entry + pane) — use `orbit.addSettingsSection()` |
Two per-message hooks (added by callback, not slot name) run for every rendered
message and receive a read-only view of it: `orbit.addMessageDecorator(m => …)`
appends inline UI after the text (badges/chips), while
`orbit.addMessageAction(m => …)` adds a button to the hover action toolbar next
to reply/react (it inherits the toolbar styling). Every contributed slot, action
and decorator renders inside its own error boundary, so a crashing plugin renders
nothing instead of taking down the app.
## Compiled plugins (write real React)
The example above is an *uncompiled* `.js` plugin. For anything substantial,
build a plugin like a normal project and compile it to one droppable file — a
compiled, externalized-React plugin model.
The trick: mark `react`, `react-dom` and `react/jsx-runtime` **external** and map
them to `Orbit.React` / `Orbit.ReactDOM` / `Orbit.jsxRuntime`, so your bundle
shares Orbit's single React instance and never carries its own. (Bundling your
own React breaks hooks with "invalid hook call".) Then author normal TSX with
hooks/state and render it into a slot:
```tsx
import { useState } from 'react';
Orbit.plugin('my-plugin', (orbit) => {
orbit.addSettingsSection({ label: 'My plugin', icon: '🧩', render: () => });
});
```
A ready-to-copy starter (Vite config with the externals already set up, tsconfig,
ambient types and an example) lives in [`plugin-template/`](../plugin-template).
`npm install && npm run build` → one `dist/*.js` you drop in and list in
`config.json`.
## Intentionally not exposed
Orbit does **not** offer access to internal modules or runtime component
replacement. Those would couple plugins to internals that are still moving; the
API above is the deliberately stable surface. Ask (or open an issue) if you need
a hook that isn't here.
## Working examples
In-page (trusted, full React API):
| File | Shows |
|---|---|
| [`orbit-demo.js`](../public/plugins/third/orbit-demo.js) | events, a `composer_button`, an IRC action |
| [`orbit-copy.js`](../public/plugins/third/orbit-copy.js) | a `message_action` (toolbar copy button with copied-confirmation) |
| [`orbit-navbar.js`](../public/plugins/third/orbit-navbar.js) | a brandable top `navbar` (logo + portal links), configured under `"navbar"` in config.json |
Sandboxed (the [SDK](./PLUGIN-SDK.md), no page access):
| File | Shows |
|---|---|
| [`orbit-hello.js`](../public/plugins/third/orbit-hello.js) | a starter: `panel`, a slash command, a shortcut |
| [`orbit-radio.js`](../public/plugins/third/orbit-radio.js) | a full `panel` player (hero, volume, station list) |
| [`orbit-clock.js`](../public/plugins/third/orbit-clock.js) | a minimal `topbar_item` clock |