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:
| Layer | Set by | Carries |
|---|---|---|
Manifest (informer.yaml env:) | Author at deploy time | Key names + optional descriptions. Seeds unset placeholder rows. |
app_environment table | Installer at install time (UI), or runtime API | Encrypted 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
valuecolumn so no response serializer can accidentally leak it. - API responses never return the value —
GETlists report a maskedset: booleanflag;PUT/DELETEresponses echo the same summary shape. The decrypted value only ever crosses into a sandboxed handler via theenvbag at execution time. - The generic
PUT /api/apps/{id}route strips any incomingdefn.envfrom the payload (an earlier leaky path) — clients must use the routes below. - Bundle export carries keys + descriptions but NULLs
value(viaclearPasswords), 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_environmentrows automatically (the model exposes areEncryptinstance method thatreEncryptSystemauto-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:
| Field | Omitted | Provided |
|---|---|---|
value | Keep the current value (or create an unset placeholder). | Set / rotate the secret. Must be non-empty. |
description | Leave 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:
- Reads the manifest's
env:block. - For each declared key:
- If no row exists → creates an unset placeholder (value
NULL). - If a row exists → updates
descriptionto match the manifest (if changed). Never touchesvalueon redeploy.
- If no row exists → creates an unset placeholder (value
- 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
| Status | Cause |
|---|---|
| 400 | key fails the pattern check (spaces, dashes, leading digit) or is one of __proto__, constructor, prototype. |
| 400 | PUT payload value present but empty (min length 1). |
| 403 | Caller lacks app:bind. |
| 404 | App not visible to caller (read_access scope) — same 404 the lookup pre returns elsewhere. |
| 404 | DELETE on a non-existent key (caller has bind permission). |
| 409 | PUT race lost the unique-constraint check; client should retry. |