Dynamic worker capabilities (v1)

Your deployed worker runs inside the single isolated facet of the per-space SpaceRuntime Durable Object. the platform injects a small, capability-scoped set of loopbacks — each one is locked to your space and can't reach another:

  • env.MEDIA — a capability for your own space's media, scoped to your space (a ../absolute path is always rejected). Five methods:

    • sign(path, { expiresIn }) → a signed, time-limited space-host URL the edge verifies and serves. Gate private (_*) media per-identity: decide in your worker, then Response.redirect to the signed URL — you never proxy the bytes. (Public media serves by default; you don't need env.MEDIA to read it.)
    • url(path, { expiresIn }) → the addressable URL for a path — plain for public, signed for private — what you render into <img>/<a>.
    • list(prefix?, { cursor? }) → one page of your media: { items: { path, size, uploaded, contentType }[], cursor?, truncated }, optionally under a folder prefix; page with the returned cursor.
    • put(path, body, { contentType }) → store bytes, get back { ok, path, url, private, size, contentType }. body is a ReadableStream (pass request.body to stream a large upload past the 32 MiB RPC cap), an ArrayBuffer/Uint8Array, or a string. Visibility follows the path (a _-prefix is private). Binary only — code-like text (html/css/js) is rejected (that belongs in public/). Overwrites by key; bytes are instantly servable (no publish). You gate the route that calls thisenv.MEDIA is space-scoped, not user-scoped.
    • remove(path) → delete one object.

    That's enough to build a media library on your own __manage/media route (browse + upload + delete) and to accept audience uploads (a file from an end user). Same <your-space-slug>/… namespace as the agent's media.* and the upload page — an object written any of those ways is visible to the others.

  • env.<YOUR_VARS> / env.<YOUR_SECRETS> — every Variable and Secret you declared is injected into env at deploy time (a secret's value is set by the owner in-browser and decrypted into env for your code to read; see secrets).

  • Outbound fetch() is routed through a per-space egress gateway: your own site host is always allowed; every other host is default-deny until the owner approves it. Secrets and egress are independent — a secret is just env, and your code builds any auth header it needs. See secrets.

Isolated state lives in the facet's own Durable Object storage, which exposes both a SQLite and a KV dialect:

Free per-facet storage: SQLite + KV

Every facet has its own Durable Object storage — both a SQL interface (ctx.storage.sql) and a simple KV interface (ctx.storage.kv) backed by the same SQLite engine.

import { DurableObject } from "cloudflare:workers";

export class App extends DurableObject {
  fetch(request) {
    // Simple KV (great for counters, flags, small JSON):
    let counter = this.ctx.storage.kv.get("counter") || 0;
    ++counter;
    this.ctx.storage.kv.put("counter", counter);

    // Or use SQL directly via this.ctx.storage.sql.exec(...) — see
    // https://developers.cloudflare.com/durable-objects/api/sqlite-storage-api/

    return new Response("You have made " + counter + " requests.\n");
  }
}

SQL with bound parameters

ctx.storage.sql.exec(query, ...params) binds positional ? placeholders to the trailing arguments. Always pass user-supplied values this way; do not string-interpolate them into the query (SQL injection in your own app).

import { DurableObject } from "cloudflare:workers";

export class App extends DurableObject {
  constructor(ctx, env) {
    super(ctx, env);
    // CREATE TABLE IF NOT EXISTS is idempotent — safe to run on every
    // cold start.
    ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS notes (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      author TEXT NOT NULL,
      body   TEXT NOT NULL,
      created_at INTEGER NOT NULL
    )`);
  }

  async fetch(request) {
    const url = new URL(request.url);

    if (request.method === "POST") {
      const form = await request.formData();
      // Parameterized INSERT — values bound to `?` placeholders.
      this.ctx.storage.sql.exec(
        "INSERT INTO notes (author, body, created_at) VALUES (?, ?, ?)",
        String(form.get("author") ?? "anon"),
        String(form.get("body") ?? ""),
        Date.now(),
      );
      return new Response(null, { status: 303, headers: { Location: "/" } });
    }

    // Parameterized SELECT — cursor → array.
    const rows = [...this.ctx.storage.sql
      .exec("SELECT id, author, body FROM notes ORDER BY id DESC LIMIT ?", 50)
      .toArray()];
    return Response.json(rows);
  }
}

Storage lifecycle: the facet's SQLite is preserved across publishes. Use it for user data, durable counters, anything that must survive a republish.

This SQLite lives in the deployed worker, not the run_code source Workspace. To read it from run_code, use introspect.query({ sql, params? }) — a single read-only SELECT/PRAGMA/EXPLAIN against the live facet. To write/seed it, expose an endpoint on your worker and call it over HTTP. introspect works as long as your App keeps the scaffold's __query export (see admin-prep).

To gate content by identity (member-only pages, an owner-only admin), run your own auth in this storage — pick an unfolder.auth-* pattern (unfolder.auth-sessions for "sign in with Google" + a lightweight session, unfolder.auth-accounts for full accounts). Every facet boots with nodejs_compat — and builds resolve packages the same way — so libraries like better-auth work in-facet with their real node:* implementations.

Naming convention

The facet host resolves your DO class via getDurableObjectClass("App") — so it must be a named export called App. export default class App will fail at facet startup.

Cross-space calls

If your app needs to call other unfolder.space spaces, just fetch them over HTTP — that's the cross-space integration pattern (see multi-space). To call a sibling space you own with a shared secret, see the securing section in multi-space.

Outbound network, variables & secrets

Outbound fetch() is default-deny (your own platform URLs are always allowed), and a space configures variables, secrets, and per-host outbound rules through config.* in run_code. That's its own surface — see secrets for the full model (setVariable, declareSecret, allowEgress, list, remove) and when to reach for each.