Skip to main content

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:

CapabilityWhat it gives the pluginStatus
loghost.log(level, ...args) — stdout-style logging with [plugin:<id>] prefixAlways available
hasCapabilityhost.hasCapability(name) to feature-detectAlways available
fetch:<host>host.fetch(url, init?)Promise<Response>, but only for the named hostnameEnforced — denied at runtime if the URL hostname isn't declared in the manifest and allowlisted in the account's capability_grants
storagehost.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, RegExp
  • parseInt, parseFloat, isNaN, isFinite
  • console.{log,warn,error} — proxied through the host's logger
  • setTimeout / clearTimeoutsetTimeout is 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 reading process.env.JWT_SECRET gets a ReferenceError.
  • No require, import, dynamic import() — the only resolvable name is @race/plugin-sdk, which the runtime injects as a stub bridge. codeGeneration: { strings: false, wasm: false } blocks eval and new Function.
  • No Buffer, fs, child_process, net, http, ambient fetch, WebSocket, Worker, or globalThis-rooted Node primitives.
  • No DOM / Flutter widgets — UI is RFW JSON only, returned from uiCell handlers.

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>, storage in the manifest.
  • The runtime constructs host.fetch / host.storage only when the capability is both declared in the manifest and present in capability_grants for your account.
  • A plugin trying to process.env, require(), or eval gets a ReferenceError / parse error and the request fails fast.
  • A plugin that calls host.fetch("https://evil.com") without an approving grant gets a CapabilityDenied error.
  • 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 capnode:vm runs 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.