Configuration and secrets
Each environment holds a set of configuration entries. A variable is stored in the clear; a
secret is encrypted with AES-256-GCM. Both are versioned — every write creates a new version and
nothing is overwritten in place.
Endpoints
/environments/{environmentId}/configs/environments/{environmentId}/configs/{key}/environments/{environmentId}/configs/{key}/environments/{environmentId}/configs/{key}/environments/{environmentId}/configs/{key}/restoreKey format
Keys must match:
^[A-Z][A-Z0-9_]{0,126}$Uppercase, starting with a letter, up to 127 characters. DATABASE_URL is valid; database-url and
1_KEY are not. Keys are unique per environment, so the same key may hold different values in
development and production.
Listing
curl -sS "https://evonia-gate.annatarhe.com/api/v1/environments/$ENVIRONMENT_ID/configs" \
-H "x-api-key: $GATE_API_KEY"{
"data": [
{ "key": "FEATURE_FLAGS", "kind": "variable", "value": "beta,search-v2", "masked": false },
{ "key": "SESSION_SIGNING_KEY", "kind": "secret", "value": null, "masked": true }
]
}Add ?state=archived or ?state=all to include deleted entries.
Reading one value
curl -sS "https://evonia-gate.annatarhe.com/api/v1/environments/$ENVIRONMENT_ID/configs/SESSION_SIGNING_KEY" \
-H "x-api-key: $GATE_API_KEY"This is the only route that decrypts. The permission it requires depends on who is asking:
| Principal | Permission required |
|---|---|
| User | config:reveal |
| Service account | config:read |
The asymmetry is deliberate. A user's role grants broad access, so revealing plaintext sits behind a
distinct permission that developer does not have. A service account's scope list is already an
explicit, narrow grant, so config:read is that grant.
Writing
PUT creates the entry if it does not exist and adds a version if it does.
await fetch(
`https://evonia-gate.annatarhe.com/api/v1/environments/${environmentId}/configs/SESSION_SIGNING_KEY`,
{
method: "PUT",
headers: { "content-type": "application/json", "x-api-key": process.env.GATE_API_KEY! },
body: JSON.stringify({
kind: "secret",
value: newSigningKey,
description: "Rotated during the January key rotation",
}),
},
);curl -sS -X PUT \
"https://evonia-gate.annatarhe.com/api/v1/environments/$ENVIRONMENT_ID/configs/SESSION_SIGNING_KEY" \
-H "content-type: application/json" \
-H "x-api-key: $GATE_API_KEY" \
-d '{ "kind": "secret", "value": "'"$NEW_KEY"'", "description": "Rotated" }'body, _ := json.Marshal(map[string]string{
"kind": "secret",
"value": newSigningKey,
"description": "Rotated during the January key rotation",
})
req, _ := http.NewRequestWithContext(ctx, http.MethodPut,
fmt.Sprintf("https://evonia-gate.annatarhe.com/api/v1/environments/%s/configs/%s",
environmentID, "SESSION_SIGNING_KEY"),
bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-API-Key", os.Getenv("GATE_API_KEY"))Each write increments currentVersion and records who made it. Changing kind on an existing key is
allowed — the new version simply stores differently.
Encryption
Secrets are sealed with AES-256-GCM under a master key supplied as GATE_CONFIG_MASTER_KEY (base64,
exactly 32 bytes). Each version stores its own ciphertext, initialization vector, and auth tag.
Associated data binds every ciphertext to its location and version:
gate:config:{environmentId}:{key}:{version}A ciphertext moved to a different key, environment, or version number will not decrypt. That is the point — copying a database row between environments does not move a usable secret.
Archive and restore
DELETE sets deletedAt; versions are retained.
curl -sS -X DELETE \
"https://evonia-gate.annatarhe.com/api/v1/environments/$ENVIRONMENT_ID/configs/OLD_FLAG" \
-H "x-api-key: $GATE_API_KEY"
curl -sS -X POST \
"https://evonia-gate.annatarhe.com/api/v1/environments/$ENVIRONMENT_ID/configs/OLD_FLAG/restore" \
-H "x-api-key: $GATE_API_KEY"Both need config:delete.
Loading configuration at boot
Read once at startup and hold the values in memory. Every secret read is audited and costs a decryption, so a per-request fetch is both slow and noisy.
type ConfigEntry = { key: string; kind: "variable" | "secret"; value: string | null };
export async function loadConfig(environmentId: string) {
const list = await gate<ConfigEntry[]>(`/environments/${environmentId}/configs`);
// Only secrets need the individual, audited read.
const secrets = await Promise.all(
list
.filter((entry) => entry.kind === "secret")
.map(async (entry) => {
const full = await gate<ConfigEntry>(`/environments/${environmentId}/configs/${entry.key}`);
return [entry.key, full.value] as const;
}),
);
return Object.fromEntries([
...list.filter((entry) => entry.kind === "variable").map((entry) => [entry.key, entry.value]),
...secrets,
]);
}Give the service account doing this an environmentId so a leaked staging key cannot read
production. See Service API keys.