hazo_env
v0.6.0
Published
Canonical environment resolver — typed env names, per-env DB/file/secret config, doctor and CLI for hazo apps
Maintainers
Readme
hazo_env
Canonical environment resolver for hazo apps. Typed env names, per-env DB/file/secret config, a DB+files migration engine with PII masking, a doctor diagnostic command, and a hazo-env CLI.
What it does
- Env resolution — typed
HazoEnv(dev | test | staging | prod), role mapping, pattern declaration, andassertEnv()to fail fast at boot ifHAZO_ENVis invalid. - Per-env DB config —
resolveConnectConfig()maps the current env to itshazo_connectconfig (SQLite or PostgREST) with zero hardcoded connection strings in app code. - Per-env file config —
resolveFilesConfig()maps the current env to ahazo_filesconfig rooted at the declareddata_root. - Secrets —
getSecret()resolves from.env.localonly; placeholders inhazo_env_config.iniare substituted at runtime without storing secrets. - Migration engine —
runMigration({ from, to })copies a DB + files between envs. Validates → snapshots target → copies tables (paged, schema-checked) → copies files → verifies → writes hazo_audit entry. Prod target refused without an explicit confirm token. - PII masking — masking rules declared per table/column in
hazo_env_masking.ini(seed) and synced tohazo_app_configat runtime. Built-in transforms:mask_email,fake_name,mask_phone,jitter_date,hash,tokenize,drop,nullify. Custom transforms viaregisterMask(). Applied automatically when the migration target role istestorstaging. - Doctor —
doctor()/hazo-env doctorvalidates pattern, DB reachability, required secrets (no values printed), data_root writability, schema level, and (with--probe --all) migration parity across envs + masking ruleset column validation. - CLI —
hazo-env current | doctor | snapshot | migrate | verify | restore | mask.
Installation
npm install hazo_envPeer deps (required): hazo_core, hazo_config.
Peer deps (optional): hazo_connect (for resolveConnectConfig), hazo_files (for resolveFilesConfig), hazo_secure (for masking transforms), hazo_pdf (for mask_pdf transform), hazo_audit (for migration audit entries).
Quick start
1. Create hazo_env_config.ini (copy from config/hazo_env_config.ini.sample)
[env]
pattern = dev, prod
app = myapp
[db.dev]
type = sqlite
database_path = ${DATA_ROOT}/dev.sqlite
[db.prod]
type = postgrest
base_url = ${POSTGREST_PROD_URL}
api_key = ${POSTGREST_API_KEY}2. Create .env.local (gitignored, one per deployment)
POSTGREST_PROD_URL=https://db.myapp.com
POSTGREST_API_KEY=...3. Boot
import { assertEnv } from 'hazo_env';
import { resolveConnectConfig } from 'hazo_env';
import { createHazoConnect } from 'hazo_connect';
assertEnv(); // throws if HAZO_ENV not in pattern
const adapter = await createHazoConnect(resolveConnectConfig());API
Env resolution (client-safe — also in hazo_env/client)
import { getEnv, getEnvRole, getRoleMap, getPattern, listEnvs, assertEnv, describeEnv,
isDev, isTest, isStaging, isProd } from 'hazo_env';
getEnv() // 'dev' | 'test' | 'staging' | 'prod'
getEnvRole() // 'development' | 'test' | 'staging' | 'production'
getRoleMap() // EnvRoleMap — full env→role mapping (config-driven; safe defaults)
getPattern() // e.g. 'dev_prod'
listEnvs() // ['dev', 'prod'] — valid envs for the declared pattern
assertEnv() // throws HazoError(ENV_INVALID) if HAZO_ENV is outside the pattern
describeEnv() // { env, role, pattern, app, dataRoot, hostHint, roles? }
isDev() / isTest() / isStaging() / isProd()getEnvRole() uses a config-driven role map so custom env names (e.g. preview, qa) resolve to the correct broad role. Unknown names default to 'development' — never silently treated as production. Configure custom mappings in [env.roles]:
[env.roles]
preview = staging
qa = testConfiguring environment roles
Canonical env names (dev, test, staging, prod) are pre-mapped and require no configuration. Non-canonical names (anything else) must declare their role in the [env.roles] INI section, or doctor will flag an error. Unknown names without a declaration fall back to 'development' (safe: no masking, no prod-protection).
[env.roles]
; Map arbitrary env names to the four fixed roles:
; development | test | staging | production
qa = test
live = production
rc = stagingValid role values: development, test, staging, production.
Client-side global injection
On the client (browser), the INI file is not available. Inject the resolved values at render time using globalThis globals — the same pattern used for the env pattern:
// In your Next.js layout server component or _app (server-side):
// globalThis.__HAZO_ENV_PATTERN__ = 'dev,prod'; // comma-separated pattern
// globalThis.__HAZO_ENV_ROLES__ = { live: 'production', qa: 'test' };
// Client bundle reads these automatically:
import { getPattern, getRoleMap } from 'hazo_env/client';
getPattern() // reads __HAZO_ENV_PATTERN__
getRoleMap() // merges DEFAULT_ROLE_MAP → __HAZO_ENV_ROLES__Both globals are optional; omitting them makes the client fall back to canonical defaults.
Utility
import { normalize } from 'hazo_env';
normalize(' Dev ') // → 'dev' (trim + lowercase)Resolvers (server-only)
import { resolveConnectConfig } from 'hazo_env';
import { resolveFilesConfig } from 'hazo_env';
import { getSecret } from 'hazo_env';
// Returns the hazo_connect config for the current env
resolveConnectConfig()
// Returns the hazo_files config for the current env (base_path from data_root)
resolveFilesConfig()
// Read a value from .env.local — throws HazoError(SECRET_MISSING) when required + absent
getSecret('POSTGREST_API_KEY', { required: true })Doctor
import { doctor } from 'hazo_env';
const report = await doctor({ probe: true, all: true });
// report.passed boolean
// report.checks DoctorCheck[] — { label, status: 'ok'|'warn'|'error', detail? }
// Migration-readiness checks (--probe --all): _migrations parity across envs,
// masking ruleset column validation against live schema.Migration engine (server-only)
import { runMigration, verifyFiles, takeSnapshot, restoreSnapshot,
writeMigrationProgress, readMigrationProgress, clearMigrationProgress } from 'hazo_env';
// Copy prod → staging (with PII masking applied automatically)
const result = await runMigration({
from: 'prod',
to: 'staging',
dryRun: false, // set true for a plan with counts, no writes
// allowProdTarget: true, // required when `to` is 'prod'
// confirmToken: '<token>', // required together with allowProdTarget
onProgress: (p) => console.log(p.phase, p.message),
// jobId: '<id>', // optional — persist progress to progressDir/<id>.json
// progressDir: '/tmp/hazo_jobs', // required when jobId is set
});
// result.ok, result.db, result.files, result.snapshotId, result.warnings, result.durationMs
// Stand-alone verification (with optional hazo_connect adapter for L2–L5 checks)
await verifyFiles('staging', dataRoot, { hash: 'sample', checkOrphans: true, adapter });
// report.ok, report.checked, report.hashed, report.missing[], report.sizeMismatch[],
// report.hashMismatch[], report.orphans[], report.skippedReason?
// Manual snapshot / restore
const snap = takeSnapshot(connectConfig); // returns { snapshotId }
restoreSnapshot(connectConfig, snapshotId);
// Filesystem progress store (used by hazo_jobs integration)
writeMigrationProgress(dir, jobId, progress);
readMigrationProgress(dir, jobId); // → MigrationProgress | null
clearMigrationProgress(dir, jobId);Masking — customize per table/column in config/hazo_env_masking.ini. Sync to the DB with hazo-env mask sync. Register custom transforms with registerMask(name, fn).
Masking ruleset API — load, parse, and sync rulesets programmatically:
import { loadRuleset, syncRulesetFromIni, parseIniRules } from 'hazo_env';
import type { MaskRule } from 'hazo_env';
const rules: MaskRule[] = parseIniRules(iniText); // parse INI text → rules array
const loaded = await loadRuleset(adapter); // read from hazo_app_config DB
await syncRulesetFromIni(adapter, '/path/to/masking.ini'); // write parsed rules to DBCLI
hazo-env current # prints env, role, pattern, app, data_root
hazo-env doctor [--env <e>] [--all] # red/green validation table
hazo-env snapshot <env> # snapshot the target env DB
hazo-env migrate --from <env> --to <env> [options]
--transport auto|local|ssh|api # default auto
--no-db | --no-files # skip DB or file copy
--scrub auto|none # default auto (mask when target is test/staging)
--tables a,b,c # restrict to specific tables
--dry-run # plan + counts, no writes
--allow-prod-target --confirm <tok> # required when writing prod
hazo-env verify <env> [--hash none|sample|full] [--no-orphans]
hazo-env restore <env> --snapshot <snapshot-id>
hazo-env mask sync # sync hazo_env_masking.ini → hazo_app_config
hazo-env mask list # print active masking rulesConfig reference (hazo_env_config.ini)
See config/hazo_env_config.ini.sample for the full annotated template.
Key sections:
[env]—pattern(comma-separated env names),app(app identifier)[data]—root(data_root; defaultapp_data)[db.<env>]—type(sqlite|postgrest),database_path/base_url/api_key[host.<env>]—location(local|remote)
Secret placeholders use ${ENV_VAR_NAME} syntax — hazo_env substitutes them from .env.local at runtime.
dump-restore transport
The dump-restore transport copies a database via SSH pg_dump/pg_restore (binary format). Use it when the source and target share the same Postgres server (e.g. prod→dev on the same VPS) and you want a fast binary copy without going through the PostgREST row-copy loop.
Config: [backup.<env>]
Add a section to config/hazo_env_config.ini:
[backup.dev]
ssh_env = prod ; reuse [transport.ssh.prod] SSH connection
target_db = myapp_dev ; DB to drop/recreate
owner = adminuser ; createdb -O <owner>
dump_source = /var/backups/myapp-*.pgdump ; glob (newest used) or "fresh"
fresh_dump_db = myapp ; source DB for pg_dump when dump_source=fresh
pre_restore_cmd = sudo systemctl stop myapp-postgrest-dev
post_restore_cmd= sudo systemctl start myapp-postgrest-devNo PII scrubbing on this path. The binary
pg_restorecopies raw rows; hazo_env'sscrubHookonly runs inside the PostgREST row-copy loop. If you need scrubbed data in dev, use the defaultapitransport instead.
CLI
npx hazo-env migrate --from prod --to dev --transport dump-restoreProgrammatic API
import { resolveBackupConfig, restoreDbViaDump } from 'hazo_env';
const backup = resolveBackupConfig('dev'); // reads [backup.dev] from INI
if (backup) {
const result = await restoreDbViaDump(backup, {
onProgress: (msg) => console.log(msg),
});
// result.mode === 'dump-restore'
}License
MIT
