orbit/src/modules/sandbox/protocol.ts
Jean f999661b68
Reported by Juest: fix dark flip/roll buttons in yomirc
The dice feature's buttons styled themselves with var(--panel) and var(--green-soft),
neither of which exists: --panel was never a token and --green-soft got renamed to
--accent-soft. So the buttons had no fill and the bare <button> picked up the OS dark
UA chrome (the sandbox iframe declared color-scheme: light dark, following the visitor's
OS, not the app). On a dark desktop the flip/roll buttons came out dark in a light theme.

Use the real, mirrored tokens (--accent-soft fill, --accent hover) so the buttons always
carry the theme's own accent, and pin the sandbox iframe's color-scheme to the app theme
(from its --bg) so no plugin's native controls track the OS instead of the skin. Drop the
dead --panel from the mirror list.
2026-07-05 02:40:34 +00:00

76 lines
3.5 KiB
TypeScript

// Wire protocol + capability model for sandboxed plugins.
//
// A sandboxed plugin runs in an opaque-origin iframe (sandbox="allow-scripts",
// no allow-same-origin) and can touch the app ONLY by sending these messages
// over a MessageChannel. The host validates every inbound RPC against the
// plugin's declared `permissions` before doing anything — so an untrusted or
// buggy plugin cannot read the SASL password, cookies, or the store, and cannot
// act as the user unless the operator granted the matching permission.
//
// This module is pure (no DOM, no store) so the capability gate is unit-testable
// on its own — the part that must never regress.
/** Permissions an operator can grant a sandboxed plugin in config.json. */
export const PERMISSIONS = ['irc', 'notify', 'storage'] as const;
export type Permission = (typeof PERMISSIONS)[number];
/** RPC methods the guest may call, mapped to the permission each one needs.
* `null` = always allowed (contained to the plugin's own iframe / read-only). */
export const RPC_CAPABILITY: Record<string, Permission | null> = {
'irc.say': 'irc',
'irc.msg': 'irc',
'irc.send': 'irc',
'irc.join': 'irc',
'irc.part': 'irc',
'irc.list': 'irc',
'notify': 'notify',
'storage.set': 'storage',
// UI stays inside the plugin's own sandboxed iframe, so it needs no grant.
'ui.claim': null,
'ui.resize': null,
'log': null,
};
/** 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
const need = RPC_CAPABILITY[method];
return need === null || permissions.includes(need);
}
/** Keep only the recognised permission strings from operator config. */
export function sanitizePermissions(input: unknown): Permission[] {
if (!Array.isArray(input)) return [];
return PERMISSIONS.filter((p) => input.includes(p));
}
// ── message shapes (host <-> guest over the MessageChannel) ──────────────────
// host -> guest
export interface InitMsg {
type: 'init';
name: string;
permissions: Permission[];
source: string; // the plugin's JS, fetched host-side (same trust as app)
snapshot: StateSnapshot; // cached so guest state reads stay synchronous
storage: Record<string, unknown>;
theme: Record<string, string>; // app CSS vars, so sandboxed UI matches the theme
}
export interface EventMsg { type: 'event'; name: string; args: unknown[]; }
export interface SnapshotMsg { type: 'snapshot'; snapshot: StateSnapshot; }
export interface ThemeMsg { type: 'theme'; theme: Record<string, string>; }
export interface RpcReplyMsg { type: 'rpc:reply'; id: number; result?: unknown; error?: string; }
// App CSS variables mirrored into the sandbox so plugins can `var(--accent)` etc.
export const THEME_VARS = ['--bg', '--ink', '--accent', '--muted', '--border', '--accent-soft'] as const;
// guest -> host
export interface RpcMsg { type: 'rpc'; id: number; method: string; args: unknown[]; }
export interface StateSnapshot { active: string; nick: string; account: string; buffers: string[]; }
export type HostToGuest = InitMsg | EventMsg | SnapshotMsg | ThemeMsg | RpcReplyMsg;
export type GuestToHost = RpcMsg;
/** App events forwarded into the sandbox. A deny-list-free allow-list: the guest
* only ever sees these, never raw internals. */
export const FORWARDED_EVENTS = ['connected', 'message', 'buffer.active', 'status'] as const;