Skip to main content

Environment

Apps declare environment variables — encrypted per-app key/value pairs that the server injects into server routes, webhooks, and agent tools at execution time. Values are stored encrypted at rest and never returned by the API; keys travel across bundle export/import but values do not (the installer fills them in per tenant).

Concept

Two pieces work together:

LayerSet byCarries
Manifest (informer.yaml env:)Author at deploy timeKey names + optional descriptions. Seeds unset placeholder rows.
app_environment tableInstaller at install time (UI), or runtime APIEncrypted values per (tenant, appId, key).

The manifest declares which keys an app expects; the installer picks appropriate values per tenant in the app's Admin → Environment tab (or via the REST API documented below). Handlers read both via the injected env bag — only keys with a stored value appear there.

Reconciliation between manifest and storage is additive only:

  • Declared keys with no row → unset placeholder created (value: NULL).
  • Declared keys that already exist → description is synced from the manifest; the encrypted value is never touched on redeploy.
  • Keys absent from the manifest → left alone (they may be UI/installer- added rows that the developer's manifest doesn't know about).

Manifest

# informer.yaml
env:
STRIPE_KEY:
description: Stripe secret key for live API calls
API_BASE_URL:
description: Base URL the app calls for upstream data
WEBHOOK_SECRET:
description: HMAC secret for inbound webhook verification

Accepted shapes (all tolerated):

env:
STRIPE_KEY: # bare key, no description
API_URL: Base URL of the API # bare string treated as the description
GREETING:
description: Friendly greeting (not secret — safe demo value)

Security invariants

  • Values are encrypted with the tenant's active token secret (AES-256-CBC) before the row is written. The default scope on the model excludes the value column so no response serializer can accidentally leak it.
  • API responses never return the value — GET lists report a masked set: boolean flag; PUT/DELETE responses echo the same summary shape. The decrypted value only ever crosses into a sandboxed handler via the env bag at execution time.
  • The generic PUT /api/apps/{id} route strips any incoming defn.env from the payload (an earlier leaky path) — clients must use the routes below.
  • Bundle export carries keys + descriptions but NULLs value (via clearPasswords), so the receiving installer fills values per tenant. Encryption is tenant-token-scoped, so values couldn't cross-tenant anyway.
  • Token-secret rotation re-encrypts all app_environment rows automatically (the model exposes a reEncrypt instance method that reEncryptSystem auto-discovers).

Routes

GET /apps/{id}/environment

Returns the app's environment variables (keys + descriptions + set/unset status). Values are never included.

Permission: permission.app.bind (Member+, level 2). Env-key names are install-time configuration; gating read access at Member+ keeps plain Members from enumerating which secrets the app declares.

Response:

[
{
"key": "STRIPE_KEY",
"description": "Stripe secret key for live API calls",
"set": true,
"updatedAt": "2026-05-25T14:23:00.000Z"
},
{
"key": "API_BASE_URL",
"description": "Base URL the app calls for upstream data",
"set": false,
"updatedAt": "2026-05-25T14:20:11.000Z"
}
]

set: true means an encrypted value is stored. set: false means the row exists as a declared-but-unfilled placeholder (typically seeded by a manifest env: entry awaiting an installer).

PUT /apps/{id}/environment/{key}

Create or update one variable.

Permission: permission.app.bind (Member+, level 2). Same rationale as the dependency-binding route: setting an env value is install-time configuration, and managed apps (origin: deployed | marketplace) must allow their installers to fill values per tenant. app.edit would lock managed apps out via its && !isManaged clause.

Path: {key} must match ^[A-Za-z_][A-Za-z0-9_]*$ and must not be one of __proto__, constructor, prototype (prototype-pollution probe rejects). The same validation is applied at the deploy step's env: reconciliation and at the migration backfill — all three entry paths to app_environment agree on what a valid env-var name looks like.

DELETE is deliberately permissive on the key path param (no regex / no invalid list) so a historical entry that somehow doesn't match the pattern — including the reserved trio — can still be removed for cleanup. The PUT path enforces the contract going forward; DELETE never creates a row, so loosening its accepted shape is safe.

Payload:

{
"value": "sk_live_…",
"description": "Stripe secret key for live API calls"
}

Both fields are optional, and each optionality is meaningful:

FieldOmittedProvided
valueKeep the current value (or create an unset placeholder).Set / rotate the secret. Must be non-empty.
descriptionLeave the description unchanged.Replace it; null clears it; "" (empty) clears it.

A value-only call rotates the secret. A description-only call lets admins fix a typo without re-entering the secret.

Response: the variable's summary (same shape as the GET list item). The value is never echoed.

Concurrency: the route runs in a route-scoped transaction. A concurrent PUT racing past the find-then-create null check produces a 23505 unique violation, which the route surfaces as a clean 409 Conflict. The boom payload is:

{
"statusCode": 409,
"error": "Conflict",
"message": "Concurrent update to environment variable \"<key>\" — retry the request."
}

Clients should retry once on 409.

DELETE /apps/{id}/environment/{key}

Remove a variable.

Permission: permission.app.bind (Member+, level 2). The permission check runs before the variable lookup, so an unauthorized caller gets 403 regardless of whether the key exists — env-key names are sensitive metadata (they hint at the app's integration shape) and the lookup-first ordering would leak existence to non-installers via the 404-vs-403 differential.

Response: the deleted variable's summary (200 with body).

Deploy reconciliation

When npm run deploy (or POST /apps/{id}/_deploy) runs, the extractEnvironment deploy step:

  1. Reads the manifest's env: block.
  2. For each declared key:
    • If no row exists → creates an unset placeholder (value NULL).
    • If a row exists → updates description to match the manifest (if changed). Never touches value on redeploy.
  3. Keys absent from the manifest are never deleted — they may be UI/installer-added rows the developer's manifest doesn't know about.

The phase is non-fatal for malformed-YAML and JS-class errors: those throws are caught by runNonFatalPhase, recorded into partialFailures, and the rest of the deploy proceeds. SQL-class errors are NOT savepoint-isolated — they abort the outer deploy transaction and the whole deploy rolls back. The reconciliation itself doesn't issue concurrent writes against any specific key, so this is mostly a theoretical concern; the documented behavior is just that you should not rely on extractEnvironment to keep the rest of a deploy alive if pg returns an error.

Runtime injection

Handlers receive env in their context bag. Only keys with a stored value appear; declared-but-unset keys are absent (so env.STRIPE_KEY is undefined until the installer fills it):

// server/charge.js
module.exports = async ({ env, fetch }) => {
if (!env.STRIPE_KEY) {
return { status: 503, body: { error: 'STRIPE_KEY not configured' } };
}
return await fetch('https://api.stripe.com/v1/charges', {
method: 'POST',
headers: { Authorization: `Bearer ${env.STRIPE_KEY}` }
});
};

The env bag is built by AppEnvironment.envMap(appId), which loads the unscoped rows for the app, decrypts each value, and returns a flat { key: plaintext } object. Decryption failures are isolated per-key — a single corrupted row (or a row left over from an incomplete rotation) logs a decrypt-failed warning and is omitted from the bag rather than poisoning the whole handler execution.

App copy

POST /apps/{id}/_copy carries env rows into the copy verbatim. The encrypted blobs travel as-is (same tenant, same token secret — so they stay valid without re-encryption). A defense-in-depth guard refuses the copy if any source row's value doesn't look encrypted-shaped, so a data-drift row can't propagate plaintext silently.

Errors

StatusCause
400key fails the pattern check (spaces, dashes, leading digit) or is one of __proto__, constructor, prototype.
400PUT payload value present but empty (min length 1).
403Caller lacks app:bind.
404App not visible to caller (read_access scope) — same 404 the lookup pre returns elsewhere.
404DELETE on a non-existent key (caller has bind permission).
409PUT race lost the unique-constraint check; client should retry.