sandbox: move the guest bootstrap into typed core source

The guest (bridge + SDK) was hand-written vanilla JS in public/plugin-sandbox.html
— untyped, unlinted, and hand-duplicating protocol constants (drift risk that grew
with the API). Move it to src/modules/sandbox/guest/ as TypeScript that imports the
shared types from protocol.ts (rpc() is now typed against RpcMethod, so a method not
wired on the host won't compile). A small Vite plugin transpiles it into
plugin-sandbox.html: served on the fly in dev, emitted into the build otherwise —
kept unminified so the served security-boundary code stays auditable, and inline so
the response keeps its own header CSP (an external script can't use 'self' in an
opaque origin). Behavior-neutral: verified clock/radio/dice mount, radio opens/plays
identically, trigger centered.
This commit is contained in:
Jean Chevronnet 2026-07-07 18:58:06 +00:00
parent f32282d1fe
commit 6821fe3617
No known key found for this signature in database
5 changed files with 348 additions and 298 deletions

View file

@ -46,7 +46,9 @@ UI inside its own iframe) needs no grant because it is already contained.
Ungranted or unknown calls are refused host-side (fail-closed). The gate is unit
tested in `src/modules/sandbox/protocol.test.ts`.
The sandboxed API (`src/modules/sandbox/host.ts` + `public/plugin-sandbox.html`):
The guest is typed core source in `src/modules/sandbox/guest/` (the SDK lives here
too); a Vite plugin transpiles it into `plugin-sandbox.html` at build (served on the
fly in dev). The sandboxed API (`src/modules/sandbox/host.ts` + the guest):
`orbit.log`, `orbit.on(event, fn)` (forwarded `connected` / `message` /
`buffer.active` / `status`), `orbit.irc.*`, `orbit.notify`,
`orbit.state.active/nick/account/buffers` (from a pushed snapshot, synchronous),

View file

@ -1,296 +0,0 @@
<!doctype html>
<meta charset="utf-8">
<title>Orbit sandboxed plugin</title>
<style>
/* Transparent iframe so a plugin sits on the app background; color-scheme goes on
<body>, not the root (root scheme paints an opaque UA canvas = grey box).
Hide scrollbars (not overflow:hidden — that paints an opaque backdrop and kills
the transparency) so a mid-resize frame never flashes a scrollbar. */
html { background: transparent; color-scheme: normal; scrollbar-width: none; }
body { margin: 0; padding: 0; background: transparent; color: var(--ink, inherit); display: inline-block; font: 13px/1.4 system-ui, sans-serif; }
*, *::before, *::after { box-sizing: border-box; }
::-webkit-scrollbar { width: 0; height: 0; }
</style>
<body>
<script>
/*
* Guest bootstrap for a sandboxed Orbit plugin.
*
* This document runs in an OPAQUE origin (the embedding <iframe> has
* sandbox="allow-scripts" and no allow-same-origin), so it has no access to the
* app's DOM, cookies, localStorage or store. Its only link to the app is the
* MessagePort handed over in the `init` message; every privileged action is an
* RPC the host validates against the plugin's granted permissions.
*
* Kept as a static, dependency-free file (not part of the TS bundle) so it can be
* served with its own strict CSP.
*/
(function () {
var port, snap = { active: '', nick: '', account: '', buffers: [], network: '', isupport: {}, caps: [] }, store = {};
var listeners = {}; // event name -> [fn]
var hookN = 0; // unique id source for command/shortcut hooks
var pending = new Map(), rpcId = 0; // in-flight RPCs
// Is this background colour a dark one? Rough luminance on a #rgb/#rrggbb or
// rgb(...) string; anything unparseable counts as light.
function isDark(c) {
if (!c) return false;
var m = String(c).trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i), r, g, b;
if (m) {
var h = m[1]; if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
r = parseInt(h.slice(0, 2), 16); g = parseInt(h.slice(2, 4), 16); b = parseInt(h.slice(4, 6), 16);
} else {
var n = String(c).match(/(\d+)\D+(\d+)\D+(\d+)/); if (!n) return false;
r = +n[1]; g = +n[2]; b = +n[3];
}
return (0.2126 * r + 0.7152 * g + 0.0722 * b) < 128;
}
// Mirror the app theme vars onto our root; color-scheme on <body> (not root) so
// native controls match without the root painting an opaque canvas.
function applyTheme(vars) {
if (!vars) return;
for (var k in vars) { try { document.documentElement.style.setProperty(k, vars[k]); } catch (e) {} }
if (vars['--bg']) document.body.style.colorScheme = isDark(vars['--bg']) ? 'dark' : 'light';
}
function rpc(method) {
var args = Array.prototype.slice.call(arguments, 1);
return new Promise(function (resolve, reject) {
var id = ++rpcId;
pending.set(id, { resolve: resolve, reject: reject });
port.postMessage({ type: 'rpc', id: id, method: method, args: args });
});
}
function emit(name) {
var a = Array.prototype.slice.call(arguments, 1);
(listeners[name] || []).forEach(function (fn) { try { fn.apply(null, a); } catch (e) { /* plugin threw */ } });
}
// The constrained API a sandboxed plugin sees. Same shape spirit as the in-page
// Orbit, but privileged bits go through the host and reads come from the snapshot.
function makeApi(name) {
// Low-level: fill this iframe's DOM, claim a UI slot, and keep the host-owned
// frame sized to the content. Everything else (panel/el) is built on top.
function ui(slot, build) {
var outer = document.createElement('div');
outer.style.cssText = 'display:inline-block';
var inner = document.createElement('div');
outer.appendChild(inner);
document.body.appendChild(outer);
try { build(inner); } catch (e) { rpc('log', 'ui build threw: ' + e); }
rpc('ui.claim', slot);
var report = function () {
var r = outer.getBoundingClientRect();
rpc('ui.resize', Math.ceil(r.width), Math.ceil(r.height));
};
report();
if (window.ResizeObserver) new ResizeObserver(report).observe(outer);
}
// ---- SDK ---------------------------------------------------------------
// Themed atoms + a bar panel that hides every rough edge of the sandbox:
// theming, the async frame-resize lag, upward growth and no half-drawn flash.
var sdkStyled = false;
function sdkCss() {
if (sdkStyled) return; sdkStyled = true;
var s = document.createElement('style');
s.textContent =
'@keyframes obx-in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}' +
'.obx-trig{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;min-width:52px;padding:6px 4px;cursor:pointer;border:0;border-radius:12px;background:transparent;color:var(--muted,#999);font:inherit;transition:color .12s,background .12s}' +
'.obx-trig:hover{color:var(--ink,inherit);background:var(--bg,transparent)}' +
'.obx-trig.on{color:var(--accent,#3a7)}' +
'.obx-trig i{display:inline-flex;align-items:center;justify-content:center;font-size:1.2rem;line-height:1;font-style:normal}' +
'.obx-trig b{font-size:.68rem;font-weight:600;letter-spacing:.01em;line-height:1;white-space:nowrap}' +
'.obx-card{box-sizing:border-box;padding:12px;border-radius:16px;background:var(--bg,#0d0d0f);border:1px solid var(--border,#8884);box-shadow:0 20px 50px -12px rgba(0,0,0,.6),0 3px 10px -3px rgba(0,0,0,.4);animation:obx-in .18s ease both}' +
'.obx-hd{display:flex;align-items:center;gap:.4rem;margin-bottom:10px;font-weight:800;font-size:13px}' +
'.obx-hd b{flex:1}' +
'.obx-x{border:0;background:transparent;color:var(--muted,#999);cursor:pointer;font-size:14px;line-height:1;border-radius:8px;width:24px;height:24px}' +
'.obx-x:hover{color:var(--ink,inherit)}' +
'.obx-btn{border:0;border-radius:10px;background:var(--accent,#3a7);color:var(--bg,#fff);font:inherit;font-weight:700;padding:8px 12px;cursor:pointer}' +
'.obx-row{display:flex;flex-direction:column;gap:1px;padding:.4rem .5rem;border:0;border-radius:10px;background:transparent;color:var(--ink,inherit);font:inherit;text-align:left;cursor:pointer;width:100%;transition:background .12s}' +
'.obx-row:hover{background:rgba(128,128,128,.13)}' +
'.obx-row.on{box-shadow:inset 3px 0 0 var(--accent,#3a7)}' +
'.obx-row span{font-weight:700;font-size:12.5px}.obx-row small{font-size:10.5px;color:var(--muted,#888)}' +
'input[type=range].obx-range{-webkit-appearance:none;appearance:none;height:4px;border-radius:3px;width:100%;cursor:pointer;background:var(--border,#8886);accent-color:var(--accent,#3a7)}';
document.head.appendChild(s);
}
function setIcon(elm, icon) {
if (icon == null) return;
if (icon.nodeType) elm.appendChild(icon);
else if (typeof icon === 'string' && icon.charAt(0) === '<') elm.innerHTML = icon;
else elm.textContent = String(icon);
}
var el = {
button: function (label, onClick) {
var b = document.createElement('button'); b.type = 'button'; b.className = 'obx-btn'; b.textContent = label;
if (onClick) b.onclick = onClick; return b;
},
row: function (title, sub, onClick) {
var b = document.createElement('button'); b.type = 'button'; b.className = 'obx-row';
var t = document.createElement('span'); t.textContent = title; b.appendChild(t);
if (sub) { var s = document.createElement('small'); s.textContent = sub; b.appendChild(s); }
if (onClick) b.onclick = onClick; return b;
},
slider: function (o) {
o = o || {}; var r = document.createElement('input'); r.type = 'range'; r.className = 'obx-range';
r.min = o.min != null ? o.min : 0; r.max = o.max != null ? o.max : 1; r.step = o.step != null ? o.step : 0.01;
r.value = o.value != null ? o.value : 0.5;
if (o.onInput) r.oninput = function () { o.onInput(Number(r.value), r); };
return r;
},
};
// A bar trigger that opens a floating themed panel above it. Owns the whole
// frame dance: on open the panel is measured but kept hidden until the frame has
// grown to fit it, then revealed — so it never flashes half-drawn by the composer,
// never grows the wrong way, and always matches the theme. Returns a controller.
function panel(opts) {
opts = opts || {}; sdkCss();
var width = opts.width || 280;
var open = false, hot = false, pending = null, card, body, trig, ctrl;
function clearPending() { if (pending) { window.removeEventListener('resize', pending); pending = null; } }
function paint() { if (trig) trig.className = 'obx-trig' + ((open || hot) ? ' on' : ''); }
function setOpen(v) {
open = v; clearPending(); paint();
if (v) {
card.style.visibility = 'hidden'; card.style.display = 'block';
pending = function () {
if (window.innerHeight < card.offsetHeight) return;
clearPending();
card.style.visibility = 'visible';
card.style.animation = 'none'; void card.offsetWidth; card.style.animation = 'obx-in .18s ease both';
};
window.addEventListener('resize', pending);
requestAnimationFrame(pending);
} else { card.style.display = 'none'; card.style.visibility = ''; }
if (opts.onToggle) try { opts.onToggle(v); } catch (e) {}
}
ctrl = {
open: function () { setOpen(true); }, close: function () { setOpen(false); },
toggle: function () { setOpen(!open); }, isOpen: function () { return open; },
active: function (on) { hot = !!on; paint(); }, body: function () { return body; },
};
ui(opts.slot || 'nav_item', function (root) {
// Centre the trigger over the slot: without this the fit-content trigger
// left-aligns under the wider card when open and slides onto the next tab.
root.style.cssText = 'display:flex;flex-direction:column;align-items:center;gap:8px';
card = document.createElement('div'); card.className = 'obx-card';
card.style.width = width + 'px'; card.style.display = 'none';
if (opts.title != null) {
var hd = document.createElement('div'); hd.className = 'obx-hd';
var tb = document.createElement('b'); tb.textContent = opts.title;
var x = document.createElement('button'); x.className = 'obx-x'; x.type = 'button'; x.textContent = '✕'; x.title = 'Close';
x.onclick = function () { setOpen(false); };
hd.appendChild(tb); hd.appendChild(x); card.appendChild(hd);
}
body = document.createElement('div'); card.appendChild(body);
trig = document.createElement('button'); trig.type = 'button'; trig.className = 'obx-trig'; trig.title = opts.label || '';
var i = document.createElement('i'); setIcon(i, opts.icon); trig.appendChild(i);
if (opts.label) { var lb = document.createElement('b'); lb.textContent = opts.label; trig.appendChild(lb); }
trig.onclick = function () { setOpen(!open); };
root.appendChild(card); root.appendChild(trig);
if (opts.render) try { opts.render(body, ctrl); } catch (e) { rpc('log', 'panel render threw: ' + e); }
paint();
});
return ctrl;
}
return {
name: name,
sandboxed: true,
log: function () { rpc.apply(null, ['log'].concat(Array.prototype.slice.call(arguments))); },
on: function (ev, fn) { (listeners[ev] = listeners[ev] || []).push(fn); return function () {
listeners[ev] = (listeners[ev] || []).filter(function (f) { return f !== fn; }); }; },
irc: {
say: function (t) { return rpc('irc.say', t); },
msg: function (to, t) { return rpc('irc.msg', to, t); },
send: function (l) { return rpc('irc.send', l); },
join: function (c) { return rpc('irc.join', c); },
part: function (c) { return rpc('irc.part', c); },
list: function () { return rpc('irc.list'); },
},
notify: function (title, body) { return rpc('notify', title, body); },
// Register a slash command: typing "/name a b" calls run(["a","b"], "a b").
// Returns an unregister fn. Built-in commands win over a plugin's.
command: function (nameStr, run, help) {
var id = 'cmd:' + (++hookN);
(listeners[id] = listeners[id] || []).push(run);
rpc('command.register', String(nameStr), id, help != null ? String(help) : undefined);
return function () { delete listeners[id]; rpc('command.dispose', id); };
},
// Register a keyboard shortcut, e.g. "mod+j" or "alt+shift+r" — a modifier is
// REQUIRED (bare keys are refused). run() fires when it's pressed in the chat view.
shortcut: function (combo, run) {
var id = 'key:' + (++hookN);
(listeners[id] = listeners[id] || []).push(run);
rpc('shortcut.register', String(combo), id);
return function () { delete listeners[id]; rpc('shortcut.dispose', id); };
},
state: {
active: function () { return snap.active; },
nick: function () { return snap.nick; },
account: function () { return snap.account; },
buffers: function () { return snap.buffers.slice(); },
},
// Read-only server / capability info, from the snapshot (no RPC, no grant).
server: {
network: function () { return snap.network || ''; },
isupport: function () { var o = {}, m = snap.isupport || {}, k; for (k in m) o[k] = m[k]; return o; },
hasCap: function (cap) { return (snap.caps || []).some(function (c) { return c.name === cap && c.enabled; }); },
caps: function () { return (snap.caps || []).map(function (c) { return { name: c.name, available: c.available, enabled: c.enabled }; }); },
},
storage: {
get: function (k, fallback) { return k in store ? store[k] : fallback; },
set: function (k, v) { store[k] = v; return rpc('storage.set', k, v); },
},
ui: ui, // low-level slot
panel: panel, // high-level bar panel (handles theming + the resize dance)
el: el, // themed atoms: button / row / slider
};
}
// The global the plugin source registers against: Orbit.plugin('name', fn).
var registered = false;
window.Orbit = {
sandboxed: true,
plugin: function (name, fn) {
if (registered) return; registered = true;
var api = makeApi(name);
try { fn(api, api.log); } catch (e) { rpc('log', 'plugin threw during init: ' + e); }
},
};
window.addEventListener('message', function (e) {
var m = e.data;
if (!m || !m.type) return;
if (m.type === 'init' && e.ports && e.ports[0]) {
// The init message carries the plugin source that gets eval'd below, so
// accept it only from the host frame, and only once (no port swap).
if (e.source !== window.parent || port) return;
port = e.ports[0];
snap = m.snapshot || snap;
store = m.storage || {};
applyTheme(m.theme);
port.onmessage = function (ev) {
var d = ev.data;
if (!d) return;
if (d.type === 'rpc:reply') {
var p = pending.get(d.id); if (!p) return; pending.delete(d.id);
d.error ? p.reject(new Error(d.error)) : p.resolve(d.result);
} else if (d.type === 'event') {
emit.apply(null, [d.name].concat(d.args || []));
} else if (d.type === 'snapshot') {
snap = d.snapshot || snap;
} else if (d.type === 'theme') {
applyTheme(d.theme);
}
};
// Run the plugin now that the bridge is live. new Function keeps it out of
// this bootstrap's scope; it only reaches the app through window.Orbit.
try { new Function(m.source).call(window); }
catch (err) { rpc('log', 'plugin source failed: ' + err); }
}
});
})();
</script>

View file

@ -0,0 +1,297 @@
// Guest bootstrap for a sandboxed Orbit plugin — the code that runs INSIDE the
// opaque-origin iframe. Counterpart to ../host.ts. It has no access to the app's
// DOM, cookies, localStorage or store; its only link is the MessagePort handed over
// in the `init` message, and every privileged action is an RPC the host validates
// against the plugin's permissions.
//
// This is core, typed source. A Vite plugin transpiles it to a self-contained
// script inlined into plugin-sandbox.html (it must be standalone — the opaque origin
// can't load app chunks), so it imports ONLY types from the shared protocol.
import type { RpcMethod, StateSnapshot, HostToGuest } from '../protocol';
(function () {
let port: MessagePort;
let snap: StateSnapshot = { active: '', nick: '', account: '', buffers: [], network: '', isupport: {}, caps: [] };
let store: Record<string, unknown> = {};
const listeners: Record<string, ((...a: unknown[]) => void)[]> = {}; // event name -> [fn]
let hookN = 0; // unique id source for command/shortcut hooks
const pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
let rpcId = 0;
// Is this background colour a dark one? Rough luminance on a #rgb/#rrggbb or
// rgb(...) string; anything unparseable counts as light.
function isDark(c: string): boolean {
if (!c) return false;
const m = String(c).trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
let r: number, g: number, b: number;
if (m) {
let h = m[1]; if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
r = parseInt(h.slice(0, 2), 16); g = parseInt(h.slice(2, 4), 16); b = parseInt(h.slice(4, 6), 16);
} else {
const n = String(c).match(/(\d+)\D+(\d+)\D+(\d+)/); if (!n) return false;
r = +n[1]; g = +n[2]; b = +n[3];
}
return (0.2126 * r + 0.7152 * g + 0.0722 * b) < 128;
}
// Mirror the app theme vars onto our root; color-scheme on <body> (not root) so
// native controls match without the root painting an opaque canvas.
function applyTheme(vars: Record<string, string> | undefined): void {
if (!vars) return;
for (const k in vars) { try { document.documentElement.style.setProperty(k, vars[k]); } catch { /* bad var */ } }
if (vars['--bg']) document.body.style.colorScheme = isDark(vars['--bg']) ? 'dark' : 'light';
}
function rpc(method: RpcMethod, ...args: unknown[]): Promise<unknown> {
return new Promise((resolve, reject) => {
const id = ++rpcId;
pending.set(id, { resolve, reject });
port.postMessage({ type: 'rpc', id, method, args });
});
}
function emit(name: string, ...a: unknown[]): void {
(listeners[name] || []).forEach((fn) => { try { fn(...a); } catch { /* plugin threw */ } });
}
// The constrained API a sandboxed plugin sees. Same shape spirit as the in-page
// Orbit, but privileged bits go through the host and reads come from the snapshot.
function makeApi(name: string) {
// Low-level: fill this iframe's DOM, claim a UI slot, and keep the host-owned
// frame sized to the content. Everything else (panel/el) is built on top.
function ui(slot: string, build: (root: HTMLElement) => void): void {
const outer = document.createElement('div');
outer.style.cssText = 'display:inline-block';
const inner = document.createElement('div');
outer.appendChild(inner);
document.body.appendChild(outer);
try { build(inner); } catch (e) { rpc('log', 'ui build threw: ' + e); }
rpc('ui.claim', slot);
const report = () => {
const r = outer.getBoundingClientRect();
rpc('ui.resize', Math.ceil(r.width), Math.ceil(r.height));
};
report();
if (window.ResizeObserver) new ResizeObserver(report).observe(outer);
}
// ---- SDK ---------------------------------------------------------------
// Themed atoms + a bar panel that hides every rough edge of the sandbox:
// theming, the async frame-resize lag, upward growth and no half-drawn flash.
let sdkStyled = false;
function sdkCss(): void {
if (sdkStyled) return; sdkStyled = true;
const s = document.createElement('style');
s.textContent =
'@keyframes obx-in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}' +
'.obx-trig{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;min-width:52px;padding:6px 4px;cursor:pointer;border:0;border-radius:12px;background:transparent;color:var(--muted,#999);font:inherit;transition:color .12s,background .12s}' +
'.obx-trig:hover{color:var(--ink,inherit);background:var(--bg,transparent)}' +
'.obx-trig.on{color:var(--accent,#3a7)}' +
'.obx-trig i{display:inline-flex;align-items:center;justify-content:center;font-size:1.2rem;line-height:1;font-style:normal}' +
'.obx-trig b{font-size:.68rem;font-weight:600;letter-spacing:.01em;line-height:1;white-space:nowrap}' +
'.obx-card{box-sizing:border-box;padding:12px;border-radius:16px;background:var(--bg,#0d0d0f);border:1px solid var(--border,#8884);box-shadow:0 20px 50px -12px rgba(0,0,0,.6),0 3px 10px -3px rgba(0,0,0,.4);animation:obx-in .18s ease both}' +
'.obx-hd{display:flex;align-items:center;gap:.4rem;margin-bottom:10px;font-weight:800;font-size:13px}' +
'.obx-hd b{flex:1}' +
'.obx-x{border:0;background:transparent;color:var(--muted,#999);cursor:pointer;font-size:14px;line-height:1;border-radius:8px;width:24px;height:24px}' +
'.obx-x:hover{color:var(--ink,inherit)}' +
'.obx-btn{border:0;border-radius:10px;background:var(--accent,#3a7);color:var(--bg,#fff);font:inherit;font-weight:700;padding:8px 12px;cursor:pointer}' +
'.obx-row{display:flex;flex-direction:column;gap:1px;padding:.4rem .5rem;border:0;border-radius:10px;background:transparent;color:var(--ink,inherit);font:inherit;text-align:left;cursor:pointer;width:100%;transition:background .12s}' +
'.obx-row:hover{background:rgba(128,128,128,.13)}' +
'.obx-row.on{box-shadow:inset 3px 0 0 var(--accent,#3a7)}' +
'.obx-row span{font-weight:700;font-size:12.5px}.obx-row small{font-size:10.5px;color:var(--muted,#888)}' +
'input[type=range].obx-range{-webkit-appearance:none;appearance:none;height:4px;border-radius:3px;width:100%;cursor:pointer;background:var(--border,#8886);accent-color:var(--accent,#3a7)}';
document.head.appendChild(s);
}
function setIcon(elm: HTMLElement, icon: unknown): void {
if (icon == null) return;
if ((icon as Node).nodeType) elm.appendChild(icon as Node);
else if (typeof icon === 'string' && icon.charAt(0) === '<') elm.innerHTML = icon;
else elm.textContent = String(icon);
}
const el = {
button(label: string, onClick?: () => void): HTMLButtonElement {
const b = document.createElement('button'); b.type = 'button'; b.className = 'obx-btn'; b.textContent = label;
if (onClick) b.onclick = onClick; return b;
},
row(title: string, sub?: string, onClick?: () => void): HTMLButtonElement {
const b = document.createElement('button'); b.type = 'button'; b.className = 'obx-row';
const t = document.createElement('span'); t.textContent = title; b.appendChild(t);
if (sub) { const s = document.createElement('small'); s.textContent = sub; b.appendChild(s); }
if (onClick) b.onclick = onClick; return b;
},
slider(o?: { min?: number; max?: number; step?: number; value?: number; onInput?: (v: number, el: HTMLInputElement) => void }): HTMLInputElement {
o = o || {}; const r = document.createElement('input'); r.type = 'range'; r.className = 'obx-range';
r.min = String(o.min != null ? o.min : 0); r.max = String(o.max != null ? o.max : 1); r.step = String(o.step != null ? o.step : 0.01);
r.value = String(o.value != null ? o.value : 0.5);
const onInput = o.onInput; if (onInput) r.oninput = () => onInput(Number(r.value), r);
return r;
},
};
// A bar trigger that opens a floating themed panel above it. Owns the whole frame
// dance: on open the panel is measured but kept hidden until the frame has grown to
// fit it, then revealed — so it never flashes half-drawn by the composer, never grows
// the wrong way, and always matches the theme. Returns a controller.
function panel(opts: {
slot?: string; width?: number; title?: string; label?: string; icon?: unknown;
render?: (body: HTMLElement, ctrl: PanelCtrl) => void; onToggle?: (open: boolean) => void;
}): PanelCtrl {
opts = opts || {}; sdkCss();
const width = opts.width || 280;
let open = false, hot = false;
let pendingGrow: (() => void) | null = null;
let card: HTMLElement, body: HTMLElement, trig: HTMLElement;
function clearPending() { if (pendingGrow) { window.removeEventListener('resize', pendingGrow); pendingGrow = null; } }
function paint() { if (trig) trig.className = 'obx-trig' + ((open || hot) ? ' on' : ''); }
function setOpen(v: boolean) {
open = v; clearPending(); paint();
if (v) {
card.style.visibility = 'hidden'; card.style.display = 'block';
pendingGrow = () => {
if (window.innerHeight < card.offsetHeight) return;
clearPending();
card.style.visibility = 'visible';
card.style.animation = 'none'; void card.offsetWidth; card.style.animation = 'obx-in .18s ease both';
};
window.addEventListener('resize', pendingGrow);
requestAnimationFrame(pendingGrow);
} else { card.style.display = 'none'; card.style.visibility = ''; }
if (opts.onToggle) try { opts.onToggle(v); } catch { /* plugin threw */ }
}
const ctrl: PanelCtrl = {
open: () => setOpen(true), close: () => setOpen(false),
toggle: () => setOpen(!open), isOpen: () => open,
active: (on: boolean) => { hot = !!on; paint(); }, body: () => body,
};
ui(opts.slot || 'nav_item', (root) => {
// Centre the trigger over the slot: without this the fit-content trigger
// left-aligns under the wider card when open and slides onto the next tab.
root.style.cssText = 'display:flex;flex-direction:column;align-items:center;gap:8px';
card = document.createElement('div'); card.className = 'obx-card';
card.style.width = width + 'px'; card.style.display = 'none';
if (opts.title != null) {
const hd = document.createElement('div'); hd.className = 'obx-hd';
const tb = document.createElement('b'); tb.textContent = opts.title;
const x = document.createElement('button'); x.className = 'obx-x'; x.type = 'button'; x.textContent = '✕'; x.title = 'Close';
x.onclick = () => setOpen(false);
hd.appendChild(tb); hd.appendChild(x); card.appendChild(hd);
}
body = document.createElement('div'); card.appendChild(body);
trig = document.createElement('button'); (trig as HTMLButtonElement).type = 'button'; trig.className = 'obx-trig'; trig.title = opts.label || '';
const i = document.createElement('i'); setIcon(i, opts.icon); trig.appendChild(i);
if (opts.label) { const lb = document.createElement('b'); lb.textContent = opts.label; trig.appendChild(lb); }
trig.onclick = () => setOpen(!open);
root.appendChild(card); root.appendChild(trig);
if (opts.render) try { opts.render(body, ctrl); } catch (e) { rpc('log', 'panel render threw: ' + e); }
paint();
});
return ctrl;
}
return {
name,
sandboxed: true,
log: (...args: unknown[]) => rpc('log', ...args),
on(ev: string, fn: (...a: unknown[]) => void) {
(listeners[ev] = listeners[ev] || []).push(fn);
return () => { listeners[ev] = (listeners[ev] || []).filter((f) => f !== fn); };
},
irc: {
say: (t: string) => rpc('irc.say', t),
msg: (to: string, t: string) => rpc('irc.msg', to, t),
send: (l: string) => rpc('irc.send', l),
join: (c: string) => rpc('irc.join', c),
part: (c: string) => rpc('irc.part', c),
list: () => rpc('irc.list'),
},
notify: (title: string, body: string) => rpc('notify', title, body),
// Register a slash command: typing "/name a b" calls run(["a","b"], "a b").
// Returns an unregister fn. Built-in commands win over a plugin's.
command(nameStr: string, run: (...a: unknown[]) => void, help?: string) {
const id = 'cmd:' + (++hookN);
(listeners[id] = listeners[id] || []).push(run);
rpc('command.register', String(nameStr), id, help != null ? String(help) : undefined);
return () => { delete listeners[id]; rpc('command.dispose', id); };
},
// Register a keyboard shortcut, e.g. "mod+j" or "alt+shift+r" — a modifier is
// REQUIRED (bare keys are refused). run() fires when it's pressed in the chat view.
shortcut(combo: string, run: (...a: unknown[]) => void) {
const id = 'key:' + (++hookN);
(listeners[id] = listeners[id] || []).push(run);
rpc('shortcut.register', String(combo), id);
return () => { delete listeners[id]; rpc('shortcut.dispose', id); };
},
state: {
active: () => snap.active,
nick: () => snap.nick,
account: () => snap.account,
buffers: () => snap.buffers.slice(),
},
// Read-only server / capability info, from the snapshot (no RPC, no grant).
server: {
network: () => snap.network || '',
isupport: () => ({ ...snap.isupport }),
hasCap: (cap: string) => (snap.caps || []).some((c) => c.name === cap && c.enabled),
caps: () => (snap.caps || []).map((c) => ({ name: c.name, available: c.available, enabled: c.enabled })),
},
storage: {
get: (k: string, fallback?: unknown) => (k in store ? store[k] : fallback),
set: (k: string, v: unknown) => { store[k] = v; return rpc('storage.set', k, v); },
},
ui, // low-level slot
panel, // high-level bar panel (handles theming + the resize dance)
el, // themed atoms: button / row / slider
};
}
// The global the plugin source registers against: Orbit.plugin('name', fn).
let registered = false;
(window as unknown as { Orbit: OrbitGlobal }).Orbit = {
sandboxed: true,
plugin(name: string, fn: (orbit: ReturnType<typeof makeApi>, log: (...a: unknown[]) => void) => void) {
if (registered) return; registered = true;
const api = makeApi(name);
try { fn(api, api.log); } catch (e) { rpc('log', 'plugin threw during init: ' + e); }
},
};
window.addEventListener('message', (e: MessageEvent) => {
const m = e.data;
if (!m || !m.type) return;
if (m.type === 'init' && e.ports && e.ports[0]) {
// The init message carries the plugin source that gets eval'd below, so accept
// it only from the host frame, and only once (no port swap).
if (e.source !== window.parent || port) return;
port = e.ports[0];
snap = m.snapshot || snap;
store = m.storage || {};
applyTheme(m.theme);
port.onmessage = (ev: MessageEvent) => {
const d = ev.data as HostToGuest;
if (!d) return;
if (d.type === 'rpc:reply') {
const p = pending.get(d.id); if (!p) return; pending.delete(d.id);
if (d.error) p.reject(new Error(d.error)); else p.resolve(d.result);
} else if (d.type === 'event') {
emit(d.name, ...(d.args || []));
} else if (d.type === 'snapshot') {
snap = d.snapshot || snap;
} else if (d.type === 'theme') {
applyTheme(d.theme);
}
};
// Run the plugin now that the bridge is live. new Function keeps it out of this
// bootstrap's scope; it only reaches the app through window.Orbit.
try { new Function(m.source).call(window); }
catch (err) { rpc('log', 'plugin source failed: ' + err); }
}
});
interface PanelCtrl {
open: () => void; close: () => void; toggle: () => void; isOpen: () => boolean;
active: (on: boolean) => void; body: () => HTMLElement;
}
interface OrbitGlobal {
sandboxed: boolean;
plugin: (name: string, fn: (orbit: ReturnType<typeof makeApi>, log: (...a: unknown[]) => void) => void) => void;
}
})();

View file

@ -47,6 +47,10 @@ export const RPC_CAPABILITY: Record<string, Permission | null> = {
'log': null,
};
/** Every RPC method name the guest types its calls against this, so a method that
* isn't wired on the host can't be called (no host/guest string drift). */
export type RpcMethod = keyof typeof RPC_CAPABILITY;
/** Is `method` a known RPC, and is it permitted for a plugin holding `permissions`? */
export function isGranted(permissions: readonly string[], method: string): boolean {
if (!(method in RPC_CAPABILITY)) return false; // unknown method: refuse by default

View file

@ -2,11 +2,54 @@ import { defineConfig, type Plugin } from 'vite'
import react from '@vitejs/plugin-react'
import { execSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import ts from 'typescript'
const pkg = JSON.parse(readFileSync('./package.json', 'utf8'))
let commit = ''
try { commit = execSync('git rev-parse --short HEAD').toString().trim() } catch { /* not a git checkout */ }
// The sandbox guest (src/modules/sandbox/guest) runs INSIDE an opaque-origin iframe,
// so it can't load app chunks — it must be a standalone script served with its own
// strict CSP. We keep it as typed core source and transpile it here into a shell HTML
// (plugin-sandbox.html): served on the fly in dev, emitted into the build otherwise.
// Kept unminified so the served security-boundary code stays auditable.
const GUEST_ENTRY = 'src/modules/sandbox/guest/index.ts'
const GUEST_SHELL = `<!doctype html>
<meta charset="utf-8">
<title>Orbit sandboxed plugin</title>
<style>
/* Transparent iframe so a plugin sits on the app background; color-scheme goes on
<body>, not the root (root scheme paints an opaque UA canvas = grey box).
Hide scrollbars (not overflow:hidden that paints an opaque backdrop and kills
the transparency) so a mid-resize frame never flashes a scrollbar. */
html { background: transparent; color-scheme: normal; scrollbar-width: none; }
body { margin: 0; padding: 0; background: transparent; color: var(--ink, inherit); display: inline-block; font: 13px/1.4 system-ui, sans-serif; }
*, *::before, *::after { box-sizing: border-box; }
::-webkit-scrollbar { width: 0; height: 0; }
</style>
<body>
%GUEST%`
function buildGuestHtml(): string {
const src = readFileSync(GUEST_ENTRY, 'utf8')
const js = ts.transpileModule(src, { compilerOptions: { target: ts.ScriptTarget.ES2018, module: ts.ModuleKind.ESNext } })
.outputText.replace(/^\s*export\s*\{\s*\}\s*;?\s*$/gm, '') // strip the module marker (breaks a classic <script>)
return GUEST_SHELL.replace('%GUEST%', () => `<script>\n${js}</script>\n`)
}
const sandboxGuest: Plugin = {
name: 'sandbox-guest',
configureServer(server) {
server.middlewares.use((req, res, next) => {
if ((req.url || '').split('?')[0].endsWith('/plugin-sandbox.html')) {
res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.end(buildGuestHtml()); return
}
next()
})
},
generateBundle() {
this.emitFile({ type: 'asset', fileName: 'plugin-sandbox.html', source: buildGuestHtml() })
},
}
// Stamp the built app with its commit so the running client can poll for a newer
// deploy (src/ui/appUpdate.ts) and refresh itself in place, no manual reload.
const emitVersion: Plugin = {
@ -25,7 +68,7 @@ const emitVersion: Plugin = {
// https://vite.dev/config/
export default defineConfig({
base: '/app/',
plugins: [react(), emitVersion],
plugins: [react(), emitVersion, sandboxGuest],
define: {
__APP_VERSION__: JSON.stringify(pkg.version),
__BUILD_TIME__: JSON.stringify(new Date().toISOString()),