Variables, secrets & outbound access (v1)
A space configures three things, all managed from inside run_code through the
config.* surface (symmetrical with state.*, git.*, media.*). They are
fully decoupled — secrets are just env, and outbound access is a separate
allow/deny list.
- Variables — named, non-secret config your code reads from
env(a public API base URL, a feature flag, a model id). You set the value. - Secrets — credentials. You declare the name; the owner sets the
value in their browser. The value never passes through this conversation.
It is injected into your deployed worker's
env(like a variable, but write-only from the outside). - Outbound permissions — which hosts your deployed site may
fetch(). Outbound is default-deny; an owner approves each host.
The config.* surface (inside run_code)
// set/inspect/remove — metadata only; secret VALUES are never returned or set here
config.setVariable({ name, value }) // non-secret config (you set it directly)
config.declareSecret({ name, description? }) // a secret: name only; owner sets the value
config.allowEgress({ host }) // propose a public host (owner approves)
config.list() // { variables, egress } — never SECRET values
config.remove({ name }) // a variable/secret by NAME, or a rule by HOST
All run as run_code({ slug: "<your-space-slug>", code: "async () => config.<fn>(…)" }).
Two actions are owner-only and happen in the browser, never from a tool:
setting a secret's value, and approving an outbound host. Both are done
on the space settings page — the settings URL is returned by get_urls,
and declareSecret / allowEgress each also return a settingsUrl pointing
there. Share that URL with the owner. You can propose hosts and declare
secrets, but you can never set a value or approve a host — that's what keeps
default-deny a real anti-exfiltration boundary.
Variables & secrets → injected as env
Both variables and secrets are injected into your deployed worker's env, so
your code reads them the way every Cloudflare Worker reads config:
const base = env.API_BASE_URL; // a variable (you set the value)
const res = await fetch(`${base}/things`, {
headers: { Authorization: `Bearer ${env.STRIPE_KEY}` }, // a secret (owner set the value)
});
// set a variable (you set the value directly — no leak concern):
config.setVariable({ name: "API_BASE_URL", value: "https://api.example.com" })
// declare a secret (name only; owner pastes the value at the returned settingsUrl):
config.declareSecret({ name: "STRIPE_KEY" })
Names must be env-style identifiers: /^[A-Z_][A-Z0-9_]*$/ (and not MEDIA,
which is reserved). Env is baked at deploy time — a variable/secret you set
(or a value the owner sets) after a deploy takes effect on the next deploy
(commit + deploy).
The value of a secret is stored encrypted at rest and only ever decrypted into your facet's own
envat deploy time. It's readable by the deployed site's own JS — which is exactly what you want for a credential the code must use in process (signing a JWT, building anAuthorizationheader). Your facet code is responsible for handling it well (don't echo it back in a response). The owner's threat model is their own code.
Outbound permissions
Your own platform URLs (<your-space-slug>.unfolder.space and your preview hosts) are
always allowed — so your site can fetch itself to self-test, and cross-space
calls to other spaces' public URLs work. (A custom domain is not auto-allowed —
approve it like any other host.) Every other host is blocked (403) unless a
rule allows it.
A rule you create is pending (still denied) until the owner activates it;
config.list() shows pending: true, and a self-test fails loudly on a pending
host rather than sending blind. The block is a synthetic 403 Response from
the egress gateway (not a thrown error) — so check res.status, don't rely on
fetch to throw.
config.allowEgress({ host: "api.github.com" }) // proposes a public host
The owner clicks Allow; after that your code just fetches it.
Calling a third-party API with a credential
Secrets and egress are independent, so a credentialed upstream is just the two steps together — declare the secret, allow the host — and your code builds the request itself:
config.declareSecret({ name: "STRIPE_KEY" }) // owner sets the value in settings
config.allowEgress({ host: "api.stripe.com" }) // owner approves the host
// env.STRIPE_KEY is the owner-set value; you build the header.
const res = await fetch("https://api.stripe.com/v1/charges", {
method: "POST",
headers: { Authorization: `Bearer ${env.STRIPE_KEY}` },
body,
});
Both must be active: the secret needs a value (owner-set) and the host needs
approval (owner-approved). Until then the secret is absent from env (pending)
and/or the egress is denied.
Inspect & remove
config.list() // { variables, egress } — a variable's value is shown; a secret's is not
config.remove({ name }) // a variable/secret by NAME, or an outbound rule by HOST
A non-secret variable's value is plain config and is returned by config.list().
Secret values are write-only: they never appear in config.list(), your
bundle, git log, logs, or any tool output.