Skip to content

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:

ScopeStorageUse Case
Config-scopedPer-configDatabase passwords, per-environment credentials
GlobalShared across configsAPI 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:

yaml
# .noorm/settings.yml
secrets:
    - key: ENCRYPTION_KEY
      type: password
      description: App-wide encryption key

Use 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:

yaml
# .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 password

A 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:

TypeInput BehaviorValidation
stringPlain textNone
passwordMasked input, no echoNone
api_keyMasked inputNone
connection_stringPlain textValid URI format

Types are hints for the CLI—all secrets are stored identically (encrypted strings).

CLI Workflow

Viewing Secrets

bash
noorm secret              # List secrets for active config

The 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:

  • TUInoorm 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.
  • CLInoorm 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 in set -x CI traces — use --stdin instead, which is mutually exclusive with the positional and strips one trailing newline so the echo idiom stores the right value.
bash
noorm secret set API_KEY "sk-live-..."
noorm secret set DB_PASSWORD "secret123" --config prod

echo "$API_KEY" | noorm secret set API_KEY --stdin

Deleting 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 secrets section of settings.yml, required by all stages
  • Stage secrets — Defined within a specific stage's secrets array

To manage secret definitions, use the settings screens (see below).

Keyboard Shortcuts (TUI)

KeyAction
aAdd new secret
eEdit selected secret
dDelete selected secret
EnterEdit selected secret
EscGo 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 config
  • noorm 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:

bash
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_KEY

The 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
-- 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:

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

yaml
# settings.yml
stages:
    prod:                           # Stage name
        secrets:
            - key: DB_PASSWORD
              type: password

    staging:
        secrets:
            - key: DB_PASSWORD
              type: password
            - key: DEBUG_KEY
              type: string
bash
noorm 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 required

Security Model

  1. Encryption at rest — Secrets are stored in .noorm/state/state.enc, encrypted with AES-256-GCM
  2. Key derivation — Encryption key derives from your private key via HKDF
  3. Values never displayed — CLI shows keys only, never values
  4. Masked input — Password types use non-echoing input
  5. No logging — Secret values are never emitted to observer events
  6. Redaction — Logger automatically masks secret fields if they appear in event data

Observer Events

Secret operations emit events for logging and debugging:

typescript
// 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:

typescript
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|rm is config-scoped only — there is no headless command for the global store. Universal and per-stage secret definitions live in settings.yml and are edited from noorm ui → Settings → Secrets / Stages.

See State Management for complete StateManager documentation.

Completeness Check

Before running operations, verify a config has all required secrets:

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