Capabilities and Security
Race Platform plugins run in isolated runtimes (QuickJS on the
client, a node:vm sandbox on the server). The host injects only the
capabilities a plugin has been granted, and every entry point runs
under a hard wall-clock timeout.
Declared capabilities
Every plugin's manifest declares the capabilities it wants:
{
"capabilities": ["fetch:api.example.com", "storage"]
}
The supported capability names today:
| Capability | What it gives the plugin | Status |
|---|---|---|
log | host.log(level, ...args) — stdout-style logging with [plugin:<id>] prefix | Always available |
hasCapability | host.hasCapability(name) to feature-detect | Always available |
fetch:<host> | host.fetch(url, init?) — Promise<Response>, but only for the named hostname | Enforced — denied at runtime if the URL hostname isn't declared in the manifest and allowlisted in the account's capability_grants |
storage | host.storage.{get,set}(key) — namespaced KV backed by Redis (dev) / Cloudflare KV (prod) | Enforced — only injected when the manifest declared it and the account granted it |
fetch:* is allowed in the manifest as a wildcard but it still has to
match the per-account capability_grants rows host-by-host. There is
no path for a plugin to fetch a host the account didn't approve.
Host API surface
The host object passed into the plugin module is built per-invocation
from the intersection of (manifest-declared capabilities, account-granted
capabilities). The full surface — nothing more is exposed:
interface PluginHost {
log(level: "debug" | "info" | "warn" | "error", ...args: unknown[]): void;
hasCapability(capability: string): boolean;
// Present only when at least one fetch:<host> capability is declared
// AND granted. Per-call hostname check rejects with CapabilityDenied
// for any URL outside (declared ∩ granted).
fetch?(url: string, init?: RequestInit): Promise<Response>;
// Present only when the "storage" capability is declared AND granted.
// Keys are namespaced under `plugin:<accountId>:<pluginId>:<key>` so
// different plugins / accounts can never collide.
storage?: {
get(key: string): Promise<string | null>;
set(key: string, value: string): Promise<void>;
};
}
A plugin asking for an ungranted capability still gets the log warning at load time:
[plugin race-demo:rolling-avg-rpm@1.0.0]
warning: declared capability `fetch:api.example.com` is not granted; host.fetch will be undefined
…and host.hasCapability("fetch:api.example.com") returns false.
Calls to a host.fetch for a non-allowlisted host throw a
CapabilityDenied error which the API surfaces as
400 capability_denied.
The grant store
Capability grants live in the capability_grants table, keyed by
(accountId, pluginId, capability). The plugin runtime reads grants
on every invocation, intersects them with the manifest's declared
capabilities, and only constructs the matching host functions.
Sandboxing
The server-side node:vm runtime (apps/api/src/services/plugin_runtime.ts)
runs the plugin's compiled JS inside a vm.Context whose globals are an
explicit safelist:
Math,JSON,Date,Array,Object,String,Number,Boolean,Error,Promise,Symbol,Map,Set,RegExpparseInt,parseFloat,isNaN,isFiniteconsole.{log,warn,error}— proxied through the host's loggersetTimeout/clearTimeout—setTimeoutis clamped at 10 seconds so a misbehaving plugin can't park a long-lived timer in the host
Everything else is omitted by construction:
- No
process/process.env— env vars (DB creds, JWT secret, etc.) are not reachable. A plugin readingprocess.env.JWT_SECRETgets aReferenceError. - No
require,import, dynamicimport()— the only resolvable name is@race/plugin-sdk, which the runtime injects as a stub bridge.codeGeneration: { strings: false, wasm: false }blocksevalandnew Function. - No
Buffer,fs,child_process,net,http, ambientfetch, WebSocket, Worker, orglobalThis-rooted Node primitives. - No DOM / Flutter widgets — UI is RFW JSON only, returned from
uiCellhandlers.
Wall-clock timeout
Every entry point — module init and every method call — runs under
vm.Script.runInContext({ timeout }) with a default of 5 seconds. A
plugin in an infinite loop fails with:
plugin <id> math.call timed out after 5000 ms
The API surfaces this as 400 bad_request and the failed invocation
does not block subsequent requests.
Compile cache
Parsing the plugin source via new vm.Script(...) is the dominant cost
of a cold invocation. The runtime caches the parsed Script per
(pluginId, manifest.version) so repeated invocations skip the parser.
A re-upload bumps the version and gets a fresh cache entry.
What you can do today
- Declare any of
log,hasCapability,fetch:<host>,storagein the manifest. - The runtime constructs
host.fetch/host.storageonly when the capability is both declared in the manifest and present incapability_grantsfor your account. - A plugin trying to
process.env,require(), orevalgets aReferenceError/ parse error and the request fails fast. - A plugin that calls
host.fetch("https://evil.com")without an approving grant gets aCapabilityDeniederror. - Infinite loops are killed at 5 s (configurable per call site).
What's coming
- UI capability grant prompts — when you install a plugin that
wants
fetch:<host>, the UI asks the account owner to approve. - Audit log — record every capability use for traceability (granted hosts, fetch URLs, storage keys touched).
- Plugin signing + trust tiers (self-signed, account-signed, marketplace-signed) — schema is in place, UI deferred.
- Per-plugin memory cap —
node:vmruns in the host process so an allocation-heavy plugin can still pressure the API. A future move to V8 isolates (Cloudflare Workers /isolated-vm) gets us a real per-isolate heap budget.