Scope

This document covers the customer-code execution surface — what we call Functions. It's the implementation companion to the feature overview. If you're evaluating us for a production use case that needs customer-authored code, this is the page that answers the questions you'd ask in a security review.

What you'll find here:

Graph data model

Scripts are first-class graph entities. A script is a logical container; each deploy creates a new immutable ScriptVersion. Capabilities, secrets, and the runtime are edges, not properties — they're independently queryable and revocable.

(Site)-[:HAS_SCRIPT]->(Script {name})
(Script)-[:HAS_VERSION]->(ScriptVersion {version, code, createdAt})
(Script)-[:BELONGS_TO]->(Site)
(ScriptVersion)-[:RUNS_AS]->(ExecutionMode)
(ScriptVersion)-[:RUNS_ON]->(Runtime)
(ScriptVersion)-[:GRANTED]->(Capability)
(ScriptVersion)-[:USES_SECRET]->(Secret)

Every edge is recorded in the audit chain. Rotating a secret is a USES_SECRET edge swap. Deprecating a capability is dropping a GRANTED edge. Forensics on "when did this script gain access to X" is a single graph traversal.

ExecutionMode values today

Other modes (em:on-request, em:scheduled) are planned. The mode is a graph node, not a string property, so future modes don't require a schema migration.

Runtime values today

Python (via Pyodide) and WebAssembly are on the roadmap. Adding a new runtime is creating a new Runtime node and wiring the appropriate isolation backend.

POST /api/scripts — create a script

POST /api/scripts
Authorization: Bearer <key>
X-Site-Id: site:<your-site>
Content-Type: application/json

{
  "name": "enrich-blog-post",
  "code": "const post = input; return { ...post, readMinutes: Math.ceil(post.body.split(/\\s+/).length / 230) };",
  "runtime": "nodejs22",
  "capabilities": ["graph.read"],
  "secrets": []
}

201 Created
{
  "scriptId": "script:enrich-blog-post",
  "versionId": "sv:enrich-blog-post:1"
}

Required fields: name (kebab-case, max 64 chars) and code (the function body — see Execution model below for what's in scope inside the body).

Optional fields:

PUT /api/scripts/:name — new version

PUT /api/scripts/enrich-blog-post
{
  "code": "const post = input; return { ...post, readMinutes: Math.ceil(post.body.split(/\\s+/).length / 250) };"
}

200 OK
{ "versionId": "sv:enrich-blog-post:2", "version": 2 }

Each PUT creates a new ScriptVersion. The Script node points at the latest by version number; older versions remain in the graph. Rolling back is a single mutation that re-points the Script at an older version — the same primitive as content rollback.

The execution model: isolated-vm in detail

This is the part that matters for a security review.

Why isolated-vm and not node:vm

Node's built-in vm module shares the host heap. The official docs explicitly warn it is not a security boundary. Multiple published prototype-pollution and structured-clone escapes let JS in vm reach process.binding, the module loader, and the filesystem. We do not use node:vm.

isolated-vm creates a separate V8 isolate. The isolate has its own heap, its own globals, and no way to reference anything in the host process unless the host explicitly exposes it. Prototype tricks don't escape because there's nothing to escape to.

The host exposes capabilities via ivm.Reference

The isolate starts empty — no console, no input, no utils. The host then installs References for each capability. A Reference is a host-side function that the isolate can call but cannot inspect; the host enforces checks, then returns a structurally-cloned result via ExternalCopy.

// In the host (transform-runner.ts):
await jail.set('__console_log',
  new ivm.Reference((...args) => logs.push({ level: 'log', args: args.map(cloneSafe) })),
);

// Inside the isolate, a bootstrap script wraps __console_log into
// a familiar "console.log" API the user code calls naturally.

The full set of References installed today:

Reference Host-side implementation Capability gate
__console_log / __console_warn / __console_error Append to logs[]; ExternalCopy strips functions/symbols always-on
__input Cloned via ExternalCopy from opts.input always-on
__util_slugify, __util_iso, __util_now Pure helpers (no I/O); host-side functions always-on
__util_http_request node:undici fetch with 10s timeout + 1MB cap http.outbound
__graph_query graphClient.query (read-only — no MERGE/CREATE/DELETE/SET) graph.read

References installed but not gated by capabilities (console, input, utils.{slugify,iso,now}) are pure — they can't reach the network, the graph, or the filesystem. Capability-gated References check the GRANTED edge at script-load time. There is no "elevate at runtime" path.

The bootstrap script

After all References are installed, the host runs a small bootstrap inside the isolate that rebuilds the friendly API surface user code expects:

// Reconstructed inside the isolate, calls flow back through References.
const console = {
  log:   (...a) => __console_log.applyIgnored(undefined, [a.map(__cleanForLog)]),
  warn:  (...a) => __console_warn.applyIgnored(undefined, [a.map(__cleanForLog)]),
  error: (...a) => __console_error.applyIgnored(undefined, [a.map(__cleanForLog)]),
};

const utils = {
  slugify: (s) => __util_slugify.applySync(undefined, [s]),
  iso:     (d) => __util_iso.applySync(undefined, [d]),
  now:     ()  => __util_now.applySync(undefined, []),
  http: {
    get:     async (u, o) => __util_http_request.applySyncPromise(undefined, ['GET', u, undefined, o]),
    post:    async (u, b, o) => __util_http_request.applySyncPromise(undefined, ['POST', u, b, o]),
    request: async (m, u, o, b) => __util_http_request.applySyncPromise(undefined, [m, u, b, o]),
  },
};

const graph = {
  query: async (cypher, params) => __graph_query.applySyncPromise(undefined, [cypher, params]),
};

const input = __input;

The bootstrap is what makes user code look normal. Without it, user code would call __console_log.applyIgnored(...) directly — possible but ugly. The bootstrap is also where __cleanForLog lives, which strips functions and symbols from console arguments so the ExternalCopy boundary doesn't reject them.

Resource limits

Execution mode: build-transform

Build-transforms run at compile time. The build pipeline iterates content records that have been edited since the last build; for each record, it checks which scripts have RUNS_AS->em:build-transform edges and applies them in order. The transform's input is the content record; the return value replaces the record in the build output.

// transform that derives word count + read time
const post = input;
const words = post.body.split(/\s+/).filter(Boolean).length;
return {
  ...post,
  derivedReadMinutes: Math.max(1, Math.ceil(words / 230)),
  derivedWordCount: words,
};

Per-record, per-build. Results are not cached across builds — every build runs the transform fresh against current code. This is intentional: it means rolling back a script version takes effect on the very next build, no cache flush.

Execution mode: inbound-webhook

Inbound webhooks run on HMAC-authenticated external POSTs at POST /api/hooks/:siteId/:slug. The slug routes to a configured InboundWebhook node which carries the shared secret. The receiver flow:

  1. Resolve the site → graph by :siteId.
  2. Look up the webhook by :slug within that graph.
  3. Verify HMAC: compute HMAC-SHA256(secret, body), compare to X-Webhook-Signature header. Constant-time compare.
  4. If signature valid: record the event as an immutable graph fact, invoke any scripts wired to this slug, return 202 Accepted with eventId.
  5. If signature invalid: still record the event (with signatureValid: false), return 401 with eventId. Replay via POST /api/inbound/events/:eventId/replay after the secret is rotated.

The script's input for this mode is { body, headers, slug }. Return value is ignored for fire-and-forget; for synchronous response, future modes will support an explicit return shape.

HMAC signature shape

// External system computes:
signature = hex(hmac_sha256(secret, request_body))

// Sends:
POST https://app.staticowl.com/api/hooks/site:acme/stripe
X-Webhook-Signature: a3f9c2...
Content-Type: application/json

{"id": "evt_1", "type": "customer.created", ...}

Why this shape and not Stripe-style timestamped signatures: third parties already implement it. Specifically, GitHub's X-Hub-Signature-256, Slack's X-Slack-Signature, Stripe's Stripe-Signature all compute over the raw request body. We accept the simple form by default and add per-provider verifiers for the well-known integrations.

Observability

Every script invocation produces a RunResult with:

RunResults for build-transforms are attached to the build's audit record; for inbound-webhook invocations, attached to the InboundEvent graph node. Both queryable via the admin UI, the /api/scripts/:name/runs endpoint, and the standard db.changes() bitemporal queries.

Failure modes

Failure Detection Consequence
Out of memory isolate aborts; RunResult.error.message = "Isolate was disposed" For build-transform: record left untransformed; build records the error. For webhook: 500 returned to caller, event recorded.
Timeout isolate terminated; error.message = "Execution timed out" Same as OOM. Wall-clock is the only timer.
User threw inside script RunResult.error captures the throw with stack Same as above; error is the script's, not ours.
Outbound HTTP timeout 10s per-request timeout returns to the script as a rejected promise Script can catch and decide. The default behavior is to fail the run.
Outbound HTTP response > 1MB Read truncated; r.truncated: true in response Script can detect and handle. No automatic retry.
Capability not granted The relevant Reference isn't installed; calling it throws TypeError Script fails at the call site with a clear message. Add the capability at the next deploy.
Graph query attempt to write graph.query only accepts read-only Cypher; engine rejects MERGE/CREATE/DELETE Query fails with a 4xx; not a security issue, just a contract.

What's not yet shipped

If any of these block your use case, talk to us before you commit — we'll prioritize what design partners need.

Related reading