docs: add The sandbox page (built-ins + sandboxed plugins)

This commit is contained in:
orbit-docs 2026-07-04 06:44:15 +00:00
parent 44771bcdee
commit 0eb797cf84
No known key found for this signature in database
3 changed files with 127 additions and 2 deletions

View file

@ -37,7 +37,8 @@ Merge rules: objects merge key-by-key; **arrays and scalars replace** wholesale.
| `features.push` | bool | Web Push notifications — the Settings row **and** the re-subscribe-on-connect. | | `features.push` | bool | Web Push notifications — the Settings row **and** the re-subscribe-on-connect. |
| `features.imageUpload` | bool | Composer image button **and** paste / drag-drop upload. | | `features.imageUpload` | bool | Composer image button **and** paste / drag-drop upload. |
| `features.register` | bool | Account self-service: the "Create account" button, the "Forgot password" link, and their FAQ entries. | | `features.register` | bool | Account self-service: the "Create account" button, the "Forgot password" link, and their FAQ entries. |
| `plugins` | string[] | Plugin script URLs loaded at startup. See [Plugins](https://orbit.tchatou.fr/docs/plugins/). | | `plugins` | string[] | Plugin script URLs loaded at startup — a bare URL runs in-page, or `{url, sandbox: true, permissions: […]}` runs isolated. See [Plugins](https://orbit.tchatou.fr/docs/plugins/). |
| `builtins` | string[] | Built-in **sandboxed** features to enable by name (opt-in; default `[]`). Currently `"dice"`. See [The sandbox](https://orbit.tchatou.fr/docs/sandbox/). |
> `defaults.*` only seed a user's preferences the **first** time — once someone changes a setting, > `defaults.*` only seed a user's preferences the **first** time — once someone changes a setting,
> it's stored in their browser and the config no longer overrides it. > it's stored in their browser and the config no longer overrides it.
@ -61,6 +62,7 @@ can still switch in **Settings**). The bundled plugins are localized too — see
"report": { "service": "ReportServ", "target": "#staff" }, "report": { "service": "ReportServ", "target": "#staff" },
"defaults": { "theme": "dark", "compact": true, "lang": "" }, "defaults": { "theme": "dark", "compact": true, "lang": "" },
"features": { "push": true, "imageUpload": true, "register": false }, "features": { "push": true, "imageUpload": true, "register": false },
"plugins": [] "plugins": [],
"builtins": []
} }
``` ```

View file

@ -169,3 +169,7 @@ they run with the same trust as the app itself. There is no user-uploaded plugin
mechanism. Orbit deliberately does **not** expose internal modules or runtime mechanism. Orbit deliberately does **not** expose internal modules or runtime
component replacement — that would couple plugins to internals that are still component replacement — that would couple plugins to internals that are still
moving. The API above is the stable surface. moving. The API above is the stable surface.
For code you don't fully trust — a community or third-party plugin — mark the
entry `sandbox: true` to run it in an isolated, capability-gated iframe instead.
See [The sandbox](https://orbit.tchatou.fr/docs/sandbox/).

119
docs/sandbox.md Normal file
View file

@ -0,0 +1,119 @@
Section: Plugins
Order: 220
# The sandbox
Most plugins run **in-page** — a trusted `<script>` on the app origin with the
full `Orbit` API. That is fine for code an operator wrote or fully vets, but it
also means such a plugin can read anything on the page: cookies, `localStorage`
(including the SASL handoff password), the store. For code you **don't** fully
trust — a community or third-party plugin — Orbit has a real isolation boundary.
The **sandbox** runs code in an **opaque-origin iframe**
(`sandbox="allow-scripts"`, no `allow-same-origin`) that can reach the app **only**
through a capability-gated message bridge. Sandboxed code cannot touch the page
DOM, cookies, `localStorage`, or the store — it can do only what its declared
`permissions` allow, and nothing else.
> **Experimental.** The sandbox API is small and may change between releases. It
> is a core subsystem (`src/sandbox/`), not a plugin itself.
## Two ways it is used
### Built-in features
Orbit ships a few small features that run **sandboxed by default** — bundled into
the app and mounted by the app itself, no external file to load. They are
**opt-in per deployment**: a built-in only mounts if its name is listed in
`config.json`.
```jsonc
{
"builtins": ["dice"] // enables the sandboxed 🎲 / 🪙 footer widget
}
```
The default is `[]` (no built-ins). Currently the only built-in is `dice`.
### Sandboxed plugins
Any `config.json` plugin entry can opt into the sandbox with `sandbox: true` and a
list of `permissions`. Use this for community / third-party / less-trusted plugins.
```jsonc
"plugins": [
"/app/plugins/orbit-clock.js", // in-page (trusted)
{ "url": "/app/plugins/community-thing.js", // sandboxed (isolated)
"sandbox": true, "permissions": ["irc", "storage"] }
]
```
## Capabilities
A sandboxed plugin can do nothing privileged unless the operator grants the
matching permission. Reading a cached state snapshot and rendering UI inside its
own iframe need no grant — they are already contained.
| permission | unlocks |
|---|---|
| `irc` | `orbit.irc.say / msg / send / join / part / list` — acts as the user |
| `notify` | `orbit.notify(title, body)` |
| `storage` | `orbit.storage.set(key, value)` (namespaced, persisted for the plugin) |
Ungranted or unknown calls are refused by the host (fail-closed).
## The sandboxed API
A sandboxed plugin registers the same way as any plugin —
`Orbit.plugin(name, fn)` — but `fn` receives a **constrained** `orbit`:
| member | notes |
|---|---|
| `orbit.log(...)` | namespaced console log |
| `orbit.on(event, fn)` | forwarded events: `connected`, `message`, `buffer.active`, `status` |
| `orbit.irc.*` | `say / msg / send / join / part / list` (needs `irc`) |
| `orbit.notify(title, body?)` | desktop notification (needs `notify`) |
| `orbit.state.active() / nick() / account() / buffers()` | synchronous, from a pushed snapshot |
| `orbit.storage.get(k, fallback?) / set(k, v)` | `set` needs `storage` |
| `orbit.ui(slot, build)` | `build(el)` fills the plugin's own iframe; the host sizes the frame to the content |
The app's theme variables (`--bg`, `--ink`, `--accent`, `--muted`, `--border`,
`--panel`, `--green-soft`) are mirrored into the sandbox and kept in sync on theme
change, so sandboxed UI can use `var(--accent)` and look native in light and dark.
## Writing one
```js
Orbit.plugin('coinflip', function (orbit) {
orbit.ui('footer_item', function (root) {
var b = document.createElement('button');
b.textContent = '🪙';
b.style.cssText = 'border:1px solid var(--border);background:var(--panel);' +
'color:var(--ink);border-radius:9px;padding:.3rem .5rem;cursor:pointer';
b.onclick = function () { orbit.irc.say('🪙 ' + (Math.random() < 0.5 ? 'heads' : 'tails')); };
root.appendChild(b);
});
});
```
With `"permissions": ["irc"]` this can post to the channel but has no access to the
page, cookies, `localStorage`, or the store. UI is built with plain DOM inside the
plugin's own iframe — the app's React is not shared across the isolation boundary.
## Self-hosting
The sandbox iframe needs two things from your web server, both contained (the
sandbox document is opaque-origin, so its looser script policy cannot touch the
app):
1. Add `'self'` to the app's `frame-src` in its Content-Security-Policy, so the app
may embed the sandbox document.
2. Serve `/app/plugin-sandbox.html` with its **own** tight CSP that lets the
bootstrap run but blocks all network egress:
```
Content-Security-Policy: default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval'; style-src 'unsafe-inline'; img-src data:; connect-src 'none'; frame-ancestors 'self'
```
Until (1) is applied the sandbox iframe is blocked, so sandboxed code is simply
inert — in-page plugins are unaffected.