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, thenResponse.redirectto the signed URL — you never proxy the bytes. (Public media serves by default; you don't needenv.MEDIAto 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 folderprefix; page with the returnedcursor.put(path, body, { contentType })→ store bytes, get back{ ok, path, url, private, size, contentType }.bodyis aReadableStream(passrequest.bodyto stream a large upload past the 32 MiB RPC cap), anArrayBuffer/Uint8Array, or a string. Visibility follows the path (a_-prefix is private). Binary only — code-like text (html/css/js) is rejected (that belongs inpublic/). Overwrites by key; bytes are instantly servable (no publish). You gate the route that calls this —env.MEDIAis space-scoped, not user-scoped.remove(path)→ delete one object.
That's enough to build a media library on your own
__manage/mediaroute (browse + upload + delete) and to accept audience uploads (a file from an end user). Same<your-space-slug>/…namespace as the agent'smedia.*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 intoenvat deploy time (a secret's value is set by the owner in-browser and decrypted intoenvfor 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 justenv, 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.