Dynamic worker capabilities (v1)

Your deployed worker runs inside an isolated facet of the per-space UnfolderSpace 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, two methods:
    • sign(path, { expiresIn }) → a signed, time-limited space-host URL the supervisor 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.)
    • put(path, bytes, { contentType }) → store an inbound upload (avatar, PDF, export) and get back { path, url, private }. Visibility follows the path — a normal path is public, a _-prefixed one private — so your user-facing logic picks where each user file lands. Overwrites by key; you authenticate + size-check the uploader. See media.
  • 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/preview hosts are 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 storage state is the facet's own Durable Object storage, below which provide sqlite and kv dialects.

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:

  • Production facet: SQLite preserved across redeploys. Use it for user data, durable counters, anything that must survive a deploy.
  • Preview facet: SQLite reset per redeploy (the facet is delete'd then get'd). Treat it as ephemeral.

This SQLite lives in the deployed worker, not the run_code source Workspace. To read it from run_code, use introspect.query({ sql, params?, facet? }) — a single read-only SELECT/PRAGMA/EXPLAIN against the live facet (defaults to production). 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 — start the unfolder.auth prompt. Every facet boots with nodejs_compat, so libraries like better-auth work in-facet.

Naming convention

The bundler 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.