Secrets
The Problem
SQL templates often need sensitive values: database passwords, API keys, service credentials. Hardcoding these is a security risk. Environment variables work but require manual setup on each machine.
noorm provides encrypted secret storage tied to your configs. Secrets travel with your project (encrypted), unlock with your identity, and inject into templates at runtime. Stage definitions can require certain secrets, ensuring configs are complete before use.
Two Types of Secrets
Secrets come in two scopes:
| Scope | Storage | Use Case |
|---|---|---|
| Config-scoped | Per-config | Database passwords, per-environment credentials |
| Global | Shared across configs | API keys, shared service credentials |
Config-scoped secrets are deleted when their config is deleted. Global secrets persist independently.
Required vs Optional
Secrets can be required in two ways: universally (for all configs) or per-stage (for configs matching that stage).
Universal Secrets
Defined at the root level of settings.yml, these are required by every config regardless of stage:
# .noorm/settings.yml
secrets:
- key: ENCRYPTION_KEY
type: password
description: App-wide encryption keyUse universal secrets for credentials needed across all environments—shared API keys, license keys, or application-level secrets.
Stage Secrets
Defined within a stage, these are only required for configs matching that stage:
# .noorm/settings.yml
stages:
prod:
description: Production database
secrets:
- key: DB_PASSWORD
type: password
description: Main database password
required: true
- key: READONLY_PASSWORD
type: password
description: Read-only user passwordA config named prod will require both universal secrets and prod-stage secrets. A config named dev requires only universal secrets (plus any dev-stage secrets).
Optional Secrets
User-defined secrets not listed in settings. Add them freely for template interpolation—they're not validated or required, just stored and available.
The CLI merges universal and stage-specific requirements when displaying missing secrets and blocking operations.
Secret Types
The type field controls CLI input behavior:
| Type | Input Behavior | Validation |
|---|---|---|
string | Plain text | None |
password | Masked input, no echo | None |
api_key | Masked input | None |
connection_string | Plain text | Valid URI format |
Types are hints for the CLI—all secrets are stored identically (encrypted strings).
CLI Workflow
Viewing Secrets
noorm secret # List secrets for active configThe list screen shows:
- Required secrets with status (✓ set / ✗ missing)
- Optional secrets you've added
- Type hints from stage definitions
- Masked value previews for set secrets
Masked previews show the secret length and first few characters (in verbose mode only), using the same format as log redaction. For example, sk-1234567890 becomes sk-1********... (12). This helps verify which value is stored without exposing it.
Setting Secrets
Config-scoped local secret values are managed two ways:
- TUI —
noorm ui→ Settings → Secrets →a. The set screen shows missing required secrets as suggestions, accepts any key name (UPPER_SNAKE_CASE recommended), masks input for password/api_key types, validates connection_string as a URI, and warns before overwriting an existing value. - CLI —
noorm secret set <key> [value](src/cli/secret/set.ts) writes the same encrypted record headlessly, scoped to the active or--config-named config. Passing the value as a positional puts it in shell history and in the process table (ps -ww -eo args) and inset -xCI traces — use--stdininstead, which is mutually exclusive with the positional and strips one trailing newline so theechoidiom stores the right value.
noorm secret set API_KEY "sk-live-..."
noorm secret set DB_PASSWORD "secret123" --config prod
echo "$API_KEY" | noorm secret set API_KEY --stdinDeleting Secrets
Through the TUI, from the same Settings → Secrets screen, highlight the entry and press d. Headlessly, noorm secret rm <key> --yes removes a config-scoped local secret (--yes is required to prevent accidental deletion). Required secrets cannot be deleted—only updated. When you try to delete a required secret through the TUI, it shows a warning toast indicating whether it's a universal or stage-specific secret:
"DB_PASSWORD" is a universal secret and cannot be deleted"API_KEY" is a stage secret and cannot be deleted
This distinction helps you understand where the secret is defined:
- Universal secrets — Defined in the global
secretssection ofsettings.yml, required by all stages - Stage secrets — Defined within a specific stage's
secretsarray
To manage secret definitions, use the settings screens (see below).
Keyboard Shortcuts (TUI)
| Key | Action |
|---|---|
a | Add new secret |
e | Edit selected secret |
d | Delete selected secret |
Enter | Edit selected secret |
Esc | Go back |
Managing Secret Definitions
Secret definitions (the requirements declared in settings.yml, not the values) are managed through the TUI's settings screens:
noorm ui→ Settings → Secrets — universal definitions required across every confignoorm ui→ Settings → Stages →<stage>→ Secrets — definitions scoped to a specific stage
These screens edit settings.yml directly. The actual secret values are set on the Secrets screen for the active config.
CI/CD Pipelines
noorm secret set is headless, but it's still the wrong tool for CI: it writes to the per-developer local store on disk, which is exactly what a fresh CI runner doesn't have. For non-interactive pipelines, push secrets through the vault instead — team-shared, stored in the database, and reachable from a clean checkout:
noorm vault set DB_PASSWORD "$DB_PASSWORD"
echo "$API_KEY" | noorm vault set API_KEY --stdin # --stdin is required to read the pipe
noorm vault list --json # Inspect what's stored
noorm vault rm OLD_API_KEYThe vault sits below local secrets in the resolution hierarchy ($.secrets.KEY checks local-config → global-local → vault), so individual developers can still override a vault value through the TUI or noorm secret set without affecting the team.
Using Secrets in Templates
Secrets inject into SQL templates via the $ context:
noorm's Eta instance uses {% %} / {%~ %}, not the stock <% %> delimiters:
-- sql/users/create-readonly.sql.tmpl
CREATE USER {%~ $.secrets.READONLY_USER %}
WITH PASSWORD '{%~ $.secrets.READONLY_PASSWORD %}';
GRANT SELECT ON ALL TABLES TO {%~ $.secrets.READONLY_USER %};Global secrets use $.globalSecrets:
-- Reference app-level secrets
-- API key: {%~ $.globalSecrets.SHARED_API_KEY %}Reading an unresolved key on $.secrets throws MissingSecretError naming the key and the tiers searched — it does not render as undefined. $.globalSecrets is a plain object and does not throw. To read an optional secret, probe first: 'KEY' in $.secrets ? $.secrets.KEY : fallback. See Template › Secret Access.
Stage Matching
The CLI matches config names to stage names to determine required secrets. A config named prod uses secrets defined in the prod stage.
# settings.yml
stages:
prod: # Stage name
secrets:
- key: DB_PASSWORD
type: password
staging:
secrets:
- key: DB_PASSWORD
type: password
- key: DEBUG_KEY
type: stringnoorm config use prod # Activates 'prod' config
noorm secret # Shows DB_PASSWORD as required
noorm config use staging # Activates 'staging' config
noorm secret # Shows DB_PASSWORD, DEBUG_KEY as requiredSecurity Model
- Encryption at rest — Secrets are stored in
.noorm/state/state.enc, encrypted with AES-256-GCM - Key derivation — Encryption key derives from your private key via HKDF
- Values never displayed — CLI shows keys only, never values
- Masked input — Password types use non-echoing input
- No logging — Secret values are never emitted to observer events
- Redaction — Logger automatically masks secret fields if they appear in event data
Observer Events
Secret operations emit events for logging and debugging:
// Config-scoped secrets
observer.on('secret:set', ({ configName, key }) => {
console.log(`Secret ${key} set for ${configName}`)
})
observer.on('secret:deleted', ({ configName, key }) => {
console.log(`Secret ${key} deleted from ${configName}`)
})
// Global secrets
observer.on('global-secret:set', ({ key }) => {
console.log(`Global secret ${key} set`)
})
observer.on('global-secret:deleted', ({ key }) => {
console.log(`Global secret ${key} deleted`)
})The logger listens for these events to add secret keys to its redaction list before they can be logged.
StateManager API
For programmatic access:
import { StateManager } from './core/state'
const state = new StateManager(process.cwd())
await state.load()
// Config-scoped secrets
await state.setSecret('prod', 'DB_PASSWORD', 'super-secret')
const password = state.getSecret('prod', 'DB_PASSWORD')
const keys = state.listSecrets('prod') // ['DB_PASSWORD']
const all = state.getAllSecrets('prod') // { DB_PASSWORD: '...' }
await state.deleteSecret('prod', 'DB_PASSWORD')
// Global secrets (shared values across all configs)
await state.setGlobalSecret('API_KEY', 'sk-...')
const key = state.getGlobalSecret('API_KEY')
const globalKeys = state.listGlobalSecrets() // ['API_KEY']
await state.deleteGlobalSecret('API_KEY')Note: Global secret values (stored in state) are managed only via the StateManager API or the TUI's Settings → Secrets screen.
noorm secret set|list|rmis config-scoped only — there is no headless command for the global store. Universal and per-stage secret definitions live insettings.ymland are edited fromnoorm ui→ Settings → Secrets / Stages.
See State Management for complete StateManager documentation.
Completeness Check
Before running operations, verify a config has all required secrets:
import { checkConfigCompleteness } from './core/config'
// A required secret satisfied by the vault still counts as set — pass the
// vault's key names, or a CI runner with no local store reports incomplete.
const check = checkConfigCompleteness(config, state, settings, {
vaultSecretKeys: await listVaultSecretKeys(db, dialect),
})
if (!check.complete) {
console.log('Missing secrets:', check.missingSecrets) // ['DB_PASSWORD', ...]
console.log('Stage violations:', check.violations)
}The fourth argument is optional: { stageName?, vaultSecretKeys? }. stageName overrides the stage that would otherwise be inferred from the config name.
The CLI runs this check and prompts users to set missing secrets before proceeding with operations.