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.

  1. 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.
  2. 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).
  3. 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 env at 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 an Authorization header). 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.