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

GET/environments/{environmentId}/configsconfig:read
GET/environments/{environmentId}/configs/{key}config:reveal for users, config:read for service accounts
PUT/environments/{environmentId}/configs/{key}config:update
DELETE/environments/{environmentId}/configs/{key}config:delete
POST/environments/{environmentId}/configs/{key}/restoreconfig:delete

Key format

Keys must match:

Text
^[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
curl -sS "https://evonia-gate.annatarhe.com/api/v1/environments/$ENVIRONMENT_ID/configs" \
  -H "x-api-key: $GATE_API_KEY"
JSON
{
  "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
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:

PrincipalPermission required
Userconfig:reveal
Service accountconfig: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",
    }),
  },
);

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:

Text
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
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.

TypeScript
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.