hazo_env 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -4
- package/SETUP_CHECKLIST.md +47 -0
- package/config/hazo_env_masking.ini.sample +16 -16
- package/dist/cli.js +167 -6
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/lib/index.d.ts +1 -1
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +2 -1
- package/dist/lib/secret_columns.d.ts +3 -0
- package/dist/lib/secret_columns.d.ts.map +1 -0
- package/dist/lib/secret_columns.js +14 -0
- package/dist/mask/registry.d.ts +8 -0
- package/dist/mask/registry.d.ts.map +1 -0
- package/dist/mask/registry.js +29 -0
- package/dist/mask/ruleset.d.ts +15 -0
- package/dist/mask/ruleset.d.ts.map +1 -0
- package/dist/mask/ruleset.js +77 -0
- package/dist/migrate/audit.d.ts +21 -0
- package/dist/migrate/audit.d.ts.map +1 -0
- package/dist/migrate/audit.js +36 -0
- package/dist/migrate/db.d.ts +13 -0
- package/dist/migrate/db.d.ts.map +1 -0
- package/dist/migrate/db.js +122 -0
- package/dist/migrate/files.d.ts +14 -0
- package/dist/migrate/files.d.ts.map +1 -0
- package/dist/migrate/files.js +72 -0
- package/dist/migrate/run.d.ts +3 -0
- package/dist/migrate/run.d.ts.map +1 -0
- package/dist/migrate/run.js +195 -0
- package/dist/migrate/snapshot.d.ts +8 -0
- package/dist/migrate/snapshot.d.ts.map +1 -0
- package/dist/migrate/snapshot.js +48 -0
- package/dist/migrate/transport.d.ts +3 -0
- package/dist/migrate/transport.d.ts.map +1 -0
- package/dist/migrate/transport.js +17 -0
- package/dist/migrate/verify.d.ts +8 -0
- package/dist/migrate/verify.d.ts.map +1 -0
- package/dist/migrate/verify.js +51 -0
- package/dist/types/index.d.ts +42 -27
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +15 -3
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# hazo_env
|
|
2
2
|
|
|
3
|
-
Canonical environment resolver for hazo apps. Typed env names, per-env DB/file/secret config, a `doctor` diagnostic command, and a `hazo-env` CLI.
|
|
3
|
+
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.
|
|
4
4
|
|
|
5
5
|
## What it does
|
|
6
6
|
|
|
@@ -8,8 +8,10 @@ Canonical environment resolver for hazo apps. Typed env names, per-env DB/file/s
|
|
|
8
8
|
- **Per-env DB config** — `resolveConnectConfig()` maps the current env to its `hazo_connect` config (SQLite or PostgREST) with zero hardcoded connection strings in app code.
|
|
9
9
|
- **Per-env file config** — `resolveFilesConfig()` maps the current env to a `hazo_files` config rooted at the declared `data_root`.
|
|
10
10
|
- **Secrets** — `getSecret()` resolves from `.env.local` only; placeholders in `hazo_env_config.ini` are substituted at runtime without storing secrets.
|
|
11
|
+
- **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.
|
|
12
|
+
- **PII masking** — masking rules declared per table/column in `hazo_env_masking.ini` (seed) and synced to `hazo_app_config` at runtime. Built-in transforms: `mask_email`, `fake_name`, `mask_phone`, `jitter_date`, `hash`, `tokenize`, `drop`, `nullify`. Custom transforms via `registerMask()`. Applied automatically when the migration target role is `testing` or `staging`.
|
|
11
13
|
- **Doctor** — `doctor()` / `hazo-env doctor` validates pattern, DB reachability, required secrets (no values printed), data_root writability, and schema level.
|
|
12
|
-
- **CLI** — `hazo-env current | doctor | snapshot`.
|
|
14
|
+
- **CLI** — `hazo-env current | doctor | snapshot | migrate | verify | restore | mask`.
|
|
13
15
|
|
|
14
16
|
## Installation
|
|
15
17
|
|
|
@@ -18,7 +20,7 @@ npm install hazo_env
|
|
|
18
20
|
```
|
|
19
21
|
|
|
20
22
|
Peer deps (required): `hazo_core`, `hazo_config`.
|
|
21
|
-
Peer deps (optional): `hazo_connect` (for `resolveConnectConfig`), `hazo_files` (for `resolveFilesConfig`).
|
|
23
|
+
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).
|
|
22
24
|
|
|
23
25
|
## Quick start
|
|
24
26
|
|
|
@@ -101,12 +103,52 @@ const report = await doctor();
|
|
|
101
103
|
// report.checks DoctorCheck[] — { label, ok, message }
|
|
102
104
|
```
|
|
103
105
|
|
|
106
|
+
### Migration engine (server-only)
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
import { runMigration, verifyFiles, takeSnapshot, restoreSnapshot } from 'hazo_env';
|
|
110
|
+
|
|
111
|
+
// Copy prod → staging (with PII masking applied automatically)
|
|
112
|
+
const result = await runMigration({
|
|
113
|
+
from: 'prod',
|
|
114
|
+
to: 'staging',
|
|
115
|
+
dryRun: false, // set true for a plan with counts, no writes
|
|
116
|
+
// allowProdTarget: true, // required when `to` is 'prod'
|
|
117
|
+
// confirmToken: '<token>', // required together with allowProdTarget
|
|
118
|
+
onProgress: (p) => console.log(p.phase, p.message),
|
|
119
|
+
});
|
|
120
|
+
// result.ok, result.db, result.files, result.snapshotId, result.warnings, result.durationMs
|
|
121
|
+
|
|
122
|
+
// Stand-alone verification
|
|
123
|
+
await verifyFiles('staging', dataRoot, { hash: 'sample' });
|
|
124
|
+
|
|
125
|
+
// Manual snapshot / restore
|
|
126
|
+
const snap = takeSnapshot(connectConfig); // returns { snapshotId }
|
|
127
|
+
restoreSnapshot(connectConfig, snapshotId);
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
**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)`.
|
|
131
|
+
|
|
104
132
|
## CLI
|
|
105
133
|
|
|
106
134
|
```
|
|
107
135
|
hazo-env current # prints env, role, pattern, app, data_root
|
|
108
136
|
hazo-env doctor [--env <e>] [--all] # red/green validation table
|
|
109
|
-
hazo-env snapshot <env> #
|
|
137
|
+
hazo-env snapshot <env> # snapshot the target env DB
|
|
138
|
+
|
|
139
|
+
hazo-env migrate --from <env> --to <env> [options]
|
|
140
|
+
--transport auto|local|ssh|api # default auto
|
|
141
|
+
--no-db | --no-files # skip DB or file copy
|
|
142
|
+
--scrub auto|none # default auto (mask when target is test/staging)
|
|
143
|
+
--tables a,b,c # restrict to specific tables
|
|
144
|
+
--dry-run # plan + counts, no writes
|
|
145
|
+
--allow-prod-target --confirm <tok> # required when writing prod
|
|
146
|
+
|
|
147
|
+
hazo-env verify <env> [--hash none|sample|full] [--no-orphans]
|
|
148
|
+
hazo-env restore <env> --snapshot <snapshot-id>
|
|
149
|
+
|
|
150
|
+
hazo-env mask sync # sync hazo_env_masking.ini → hazo_app_config
|
|
151
|
+
hazo-env mask list # print active masking rules
|
|
110
152
|
```
|
|
111
153
|
|
|
112
154
|
## Config reference (`hazo_env_config.ini`)
|
package/SETUP_CHECKLIST.md
CHANGED
|
@@ -8,6 +8,8 @@ Follow these steps when adding `hazo_env` to a consuming application.
|
|
|
8
8
|
npm install hazo_env hazo_core hazo_config
|
|
9
9
|
# optional — only if you use resolveConnectConfig / resolveFilesConfig:
|
|
10
10
|
npm install hazo_connect hazo_files
|
|
11
|
+
# optional — only if you use the migration engine with masking + PDF scrub + audit trail:
|
|
12
|
+
npm install hazo_secure hazo_pdf hazo_audit
|
|
11
13
|
```
|
|
12
14
|
|
|
13
15
|
## 2. Create `hazo_env_config.ini`
|
|
@@ -90,3 +92,48 @@ const nextConfig = {
|
|
|
90
92
|
transpilePackages: ['hazo_env', 'hazo_core', /* … */],
|
|
91
93
|
};
|
|
92
94
|
```
|
|
95
|
+
|
|
96
|
+
## 9. (Optional) Set up the migration engine
|
|
97
|
+
|
|
98
|
+
Skip this section if you only need env resolution and resolvers.
|
|
99
|
+
|
|
100
|
+
### 9a. Create `hazo_env_masking.ini`
|
|
101
|
+
|
|
102
|
+
Copy `node_modules/hazo_env/config/hazo_env_masking.ini.sample` to your app's `config/` directory and declare per-table masking rules:
|
|
103
|
+
|
|
104
|
+
```ini
|
|
105
|
+
[hazo_users]
|
|
106
|
+
email = mask_email
|
|
107
|
+
full_name = fake_name
|
|
108
|
+
password_hash = drop
|
|
109
|
+
|
|
110
|
+
[my_app_clients]
|
|
111
|
+
notes = redact_pii
|
|
112
|
+
dob = jitter_date
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Sync to the DB so the runtime engine can read them:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
npx hazo-env mask sync
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### 9b. Set `HAZO_ENV_MASK_KEY` in `.env.local`
|
|
122
|
+
|
|
123
|
+
Required for deterministic masking transforms (`hash`, `tokenize`). If absent, migration aborts when scrub mode is `auto`.
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
HAZO_ENV_MASK_KEY=<random-32-char-hex>
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### 9c. Run a dry-run migration to verify the plan
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
npx hazo-env migrate --from prod --to staging --dry-run
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Review row counts and scrub counts before running without `--dry-run`. The migration will snapshot the target env before writing so you can restore if needed:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
npx hazo-env restore staging --snapshot <snapshot-id>
|
|
139
|
+
```
|
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
; hazo_env masking ruleset sample
|
|
2
|
-
; Copy to hazo_env_masking.ini and
|
|
3
|
-
;
|
|
4
|
-
;
|
|
2
|
+
; Copy to config/hazo_env_masking.ini and customise.
|
|
3
|
+
; Load into hazo_app_config with: hazo-env mask sync
|
|
4
|
+
;
|
|
5
|
+
; Format: [table_name] then column = transform_name
|
|
6
|
+
; Transforms: mask_email, mask_phone, fake_name, jitter_date, hash, tokenize, drop, nullify
|
|
7
|
+
; Custom transforms can be registered via registerMask() in your app.
|
|
5
8
|
|
|
6
|
-
[
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
[hazo_users]
|
|
10
|
+
email = mask_email
|
|
11
|
+
full_name = fake_name
|
|
12
|
+
phone = mask_phone
|
|
13
|
+
dob = jitter_date
|
|
14
|
+
password_hash = drop
|
|
10
15
|
|
|
11
|
-
[
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
[rule.api_keys]
|
|
17
|
-
table = hazo_api_keys
|
|
18
|
-
column = key_hash
|
|
19
|
-
strategy = drop
|
|
16
|
+
[hazo_auth_users]
|
|
17
|
+
email = mask_email
|
|
18
|
+
full_name = fake_name
|
|
19
|
+
password_hash = drop
|
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// hazo_env/src/cli.ts — CLI entry: hazo-env current | doctor | snapshot
|
|
2
|
+
// hazo_env/src/cli.ts — CLI entry: hazo-env current | doctor | snapshot | migrate | verify | restore
|
|
3
3
|
import pc from 'picocolors';
|
|
4
4
|
import { getEnv, getEnvRole } from './index.client.js';
|
|
5
5
|
import { describeEnv } from './env.server.js';
|
|
@@ -60,10 +60,151 @@ async function runDoctor() {
|
|
|
60
60
|
}
|
|
61
61
|
async function runSnapshot() {
|
|
62
62
|
const targetEnv = args[0] ?? getEnv();
|
|
63
|
-
console.log(`\n${pc.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
63
|
+
console.log(`\n${pc.bold('hazo-env snapshot')} ${pc.dim(targetEnv)}\n`);
|
|
64
|
+
const { takeSnapshot } = await import('./migrate/snapshot.js');
|
|
65
|
+
const { resolveConnectConfig } = await import('./resolve/connect.js');
|
|
66
|
+
const toConfig = resolveConnectConfig({ env: targetEnv, allowOtherEnv: true });
|
|
67
|
+
const result = takeSnapshot(toConfig);
|
|
68
|
+
console.log(` ${pc.green('✓')} Snapshot: ${result.snapshotId}\n`);
|
|
69
|
+
}
|
|
70
|
+
async function runMigrate() {
|
|
71
|
+
const fromIdx = args.findIndex((a) => a === '--from');
|
|
72
|
+
const toIdx = args.findIndex((a) => a === '--to');
|
|
73
|
+
const from = fromIdx >= 0 ? args[fromIdx + 1] : undefined;
|
|
74
|
+
const to = toIdx >= 0 ? args[toIdx + 1] : undefined;
|
|
75
|
+
const dryRun = args.includes('--dry-run');
|
|
76
|
+
const noFiles = args.includes('--no-files');
|
|
77
|
+
const noDb = args.includes('--no-db');
|
|
78
|
+
const allowProdTarget = args.includes('--allow-prod-target');
|
|
79
|
+
const confirmIdx = args.findIndex((a) => a === '--confirm');
|
|
80
|
+
const confirmToken = confirmIdx >= 0 ? args[confirmIdx + 1] : undefined;
|
|
81
|
+
const tablesIdx = args.findIndex((a) => a === '--tables');
|
|
82
|
+
const tablesArg = tablesIdx >= 0 ? args[tablesIdx + 1] : undefined;
|
|
83
|
+
const tables = tablesArg ? tablesArg.split(',').map((t) => t.trim()) : undefined;
|
|
84
|
+
if (!from || !to) {
|
|
85
|
+
console.error(pc.red('Error: migrate requires --from <env> and --to <env>'));
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
console.log(`\n${pc.bold('hazo-env migrate')} ${pc.dim(`${from} → ${to}${dryRun ? ' (dry-run)' : ''}`)}\n`);
|
|
89
|
+
const { runMigration } = await import('./migrate/run.js');
|
|
90
|
+
try {
|
|
91
|
+
const result = await runMigration({
|
|
92
|
+
from,
|
|
93
|
+
to,
|
|
94
|
+
include: { db: !noDb, files: !noFiles },
|
|
95
|
+
tables: tables ?? '*',
|
|
96
|
+
dryRun,
|
|
97
|
+
allowProdTarget,
|
|
98
|
+
confirmToken,
|
|
99
|
+
onProgress: (p) => {
|
|
100
|
+
const pct = p.percent != null ? `${p.percent}%`.padStart(4) + ' ' : ' ';
|
|
101
|
+
console.log(` ${pct}${pc.dim(p.phase.padEnd(12))} ${p.message}`);
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
if (result.ok) {
|
|
105
|
+
console.log(`\n ${pc.green('✓')} Migration ${dryRun ? 'plan built (dry-run)' : 'complete'}`);
|
|
106
|
+
if (result.db)
|
|
107
|
+
console.log(` ${pc.dim('DB:')} ${result.db.tables} tables, ${result.db.rows} rows${result.db.scrubbed ? `, ${result.db.scrubbed} scrubbed` : ''}`);
|
|
108
|
+
if (result.files)
|
|
109
|
+
console.log(` ${pc.dim('Files:')} ${result.files.copied} copied`);
|
|
110
|
+
if (result.snapshotId && !result.snapshotId.startsWith('no-db:'))
|
|
111
|
+
console.log(` ${pc.dim('Snapshot:')} ${result.snapshotId}`);
|
|
112
|
+
if (result.warnings.length)
|
|
113
|
+
result.warnings.forEach((w) => console.log(` ${pc.yellow('⚠')} ${w}`));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch (e) {
|
|
117
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
118
|
+
console.error(`\n ${pc.red('✗')} ${msg}\n`);
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
console.log('');
|
|
122
|
+
}
|
|
123
|
+
async function runVerify() {
|
|
124
|
+
const targetEnv = args[0] ?? getEnv();
|
|
125
|
+
console.log(`\n${pc.bold('hazo-env verify')} ${pc.dim(targetEnv)}\n`);
|
|
126
|
+
const { verifyFiles } = await import('./migrate/verify.js');
|
|
127
|
+
const { resolveFilesConfig } = await import('./resolve/files.js');
|
|
128
|
+
const dataRoot = resolveFilesConfig().local.basePath;
|
|
129
|
+
const report = await verifyFiles(targetEnv, dataRoot, { hash: 'sample', checkOrphans: true });
|
|
130
|
+
console.log(` Checked: ${report.checked} files`);
|
|
131
|
+
if (report.ok) {
|
|
132
|
+
console.log(` ${pc.green('✓')} All files verified\n`);
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
if (report.missing.length)
|
|
136
|
+
console.log(` ${pc.red('✗')} Missing: ${report.missing.join(', ')}`);
|
|
137
|
+
if (report.sizeMismatch.length)
|
|
138
|
+
console.log(` ${pc.yellow('⚠')} Size mismatch: ${report.sizeMismatch.join(', ')}`);
|
|
139
|
+
console.log('');
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async function runRestore() {
|
|
144
|
+
const targetEnv = args[0];
|
|
145
|
+
const snapshotIdx = args.findIndex((a) => a === '--snapshot');
|
|
146
|
+
const snapshotId = snapshotIdx >= 0 ? args[snapshotIdx + 1] : undefined;
|
|
147
|
+
if (!targetEnv || !snapshotId) {
|
|
148
|
+
console.error(pc.red('Error: restore requires <env> --snapshot <snapshot-id>'));
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
console.log(`\n${pc.bold('hazo-env restore')} ${pc.dim(`${targetEnv} ← ${snapshotId}`)}\n`);
|
|
152
|
+
const { restoreSnapshot } = await import('./migrate/snapshot.js');
|
|
153
|
+
const { resolveConnectConfig } = await import('./resolve/connect.js');
|
|
154
|
+
const toConfig = resolveConnectConfig({ env: targetEnv, allowOtherEnv: true });
|
|
155
|
+
restoreSnapshot(toConfig, snapshotId);
|
|
156
|
+
console.log(` ${pc.green('✓')} Restored\n`);
|
|
157
|
+
}
|
|
158
|
+
async function runMask() {
|
|
159
|
+
const subCmd = args[0];
|
|
160
|
+
if (subCmd === 'sync') {
|
|
161
|
+
console.log(`\n${pc.bold('hazo-env mask sync')}\n`);
|
|
162
|
+
try {
|
|
163
|
+
const connectMod = await import('hazo_connect/server').catch(() => null);
|
|
164
|
+
if (!connectMod) {
|
|
165
|
+
console.error(pc.red('Error: hazo_connect is required for mask sync.'));
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
const { resolveConnectConfig } = await import('./resolve/connect.js');
|
|
169
|
+
const { syncRulesetFromIni } = await import('./mask/ruleset.js');
|
|
170
|
+
const dbConfig = resolveConnectConfig({ allowOtherEnv: false });
|
|
171
|
+
if (dbConfig.type !== 'sqlite' || !dbConfig.sqlite) {
|
|
172
|
+
console.error(pc.red('Error: mask sync only supports SQLite databases.'));
|
|
173
|
+
process.exit(1);
|
|
174
|
+
}
|
|
175
|
+
const adapter = connectMod.createHazoConnect({
|
|
176
|
+
type: 'sqlite',
|
|
177
|
+
sqlite: { database_path: dbConfig.sqlite.database_path },
|
|
178
|
+
});
|
|
179
|
+
const count = await syncRulesetFromIni(adapter);
|
|
180
|
+
console.log(` ${pc.green('✓')} Synced ${count} rules into hazo_app_config\n`);
|
|
181
|
+
}
|
|
182
|
+
catch (e) {
|
|
183
|
+
console.error(`\n ${pc.red('✗')} ${e instanceof Error ? e.message : String(e)}\n`);
|
|
184
|
+
process.exit(1);
|
|
185
|
+
}
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (subCmd === 'list') {
|
|
189
|
+
console.log(`\n${pc.bold('hazo-env mask list')}\n`);
|
|
190
|
+
const { listTransforms } = await import('./mask/registry.js');
|
|
191
|
+
const transforms = await listTransforms();
|
|
192
|
+
if (transforms.length === 0) {
|
|
193
|
+
console.log(' (no transforms registered)\n');
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
for (const name of transforms)
|
|
197
|
+
console.log(` ${pc.dim('•')} ${name}`);
|
|
198
|
+
console.log('');
|
|
199
|
+
}
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
console.log(`
|
|
203
|
+
${pc.bold('hazo-env mask')} — masking commands
|
|
204
|
+
|
|
205
|
+
hazo-env mask sync Load hazo_env_masking.ini into hazo_app_config
|
|
206
|
+
hazo-env mask list List registered transform functions
|
|
207
|
+
`);
|
|
67
208
|
}
|
|
68
209
|
function printHelp() {
|
|
69
210
|
console.log(`
|
|
@@ -75,7 +216,15 @@ Usage:
|
|
|
75
216
|
hazo-env doctor --all Run health checks for all declared environments
|
|
76
217
|
hazo-env doctor --env <env> Run health checks for a specific environment
|
|
77
218
|
hazo-env doctor --probe Also probe live DB reachability
|
|
78
|
-
hazo-env
|
|
219
|
+
hazo-env migrate --from <env> --to <env> Migrate DB + files between environments
|
|
220
|
+
--dry-run Show plan without executing
|
|
221
|
+
--no-db / --no-files Skip DB or file copy
|
|
222
|
+
--allow-prod-target --confirm <token> Override prod-target safety
|
|
223
|
+
hazo-env verify <env> Verify files for an environment
|
|
224
|
+
hazo-env restore <env> --snapshot <id> Restore a snapshot
|
|
225
|
+
hazo-env snapshot <env> Take a snapshot of an environment
|
|
226
|
+
hazo-env mask sync Load masking ruleset from INI into DB
|
|
227
|
+
hazo-env mask list List registered mask transforms
|
|
79
228
|
`);
|
|
80
229
|
}
|
|
81
230
|
(async () => {
|
|
@@ -89,6 +238,18 @@ Usage:
|
|
|
89
238
|
else if (command === 'snapshot') {
|
|
90
239
|
await runSnapshot();
|
|
91
240
|
}
|
|
241
|
+
else if (command === 'migrate') {
|
|
242
|
+
await runMigrate();
|
|
243
|
+
}
|
|
244
|
+
else if (command === 'verify') {
|
|
245
|
+
await runVerify();
|
|
246
|
+
}
|
|
247
|
+
else if (command === 'restore') {
|
|
248
|
+
await runRestore();
|
|
249
|
+
}
|
|
250
|
+
else if (command === 'mask') {
|
|
251
|
+
await runMask();
|
|
252
|
+
}
|
|
92
253
|
else {
|
|
93
254
|
printHelp();
|
|
94
255
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -5,4 +5,10 @@ export * from './resolve/secrets.js';
|
|
|
5
5
|
export * from './resolve/connect.js';
|
|
6
6
|
export * from './resolve/files.js';
|
|
7
7
|
export * from './doctor.js';
|
|
8
|
+
export { runMigration } from './migrate/run.js';
|
|
9
|
+
export { verifyFiles } from './migrate/verify.js';
|
|
10
|
+
export { takeSnapshot, restoreSnapshot } from './migrate/snapshot.js';
|
|
11
|
+
export { registerMask } from './mask/registry.js';
|
|
12
|
+
export type { MaskTransform } from './types/index.js';
|
|
13
|
+
export * from './lib/index.js';
|
|
8
14
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAEzF,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAE/E,cAAc,kBAAkB,CAAC;AAEjC,cAAc,sBAAsB,CAAC;AACrC,cAAc,sBAAsB,CAAC;AACrC,cAAc,oBAAoB,CAAC;AAEnC,cAAc,aAAa,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAEzF,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAE/E,cAAc,kBAAkB,CAAC;AAEjC,cAAc,sBAAsB,CAAC;AACrC,cAAc,sBAAsB,CAAC;AACrC,cAAc,oBAAoB,CAAC;AAEnC,cAAc,aAAa,CAAC;AAE5B,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAChD,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAClD,OAAO,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAEtE,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,YAAY,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEtD,cAAc,gBAAgB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -11,3 +11,11 @@ export * from './resolve/connect.js';
|
|
|
11
11
|
export * from './resolve/files.js';
|
|
12
12
|
// Doctor
|
|
13
13
|
export * from './doctor.js';
|
|
14
|
+
// Migration engine (Phase 2)
|
|
15
|
+
export { runMigration } from './migrate/run.js';
|
|
16
|
+
export { verifyFiles } from './migrate/verify.js';
|
|
17
|
+
export { takeSnapshot, restoreSnapshot } from './migrate/snapshot.js';
|
|
18
|
+
// Masking registry
|
|
19
|
+
export { registerMask } from './mask/registry.js';
|
|
20
|
+
// Lib
|
|
21
|
+
export * from './lib/index.js';
|
package/dist/lib/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export
|
|
1
|
+
export * from './secret_columns.js';
|
|
2
2
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/lib/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/lib/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/lib/index.ts"],"names":[],"mappings":"AACA,cAAc,qBAAqB,CAAC"}
|
package/dist/lib/index.js
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
// hazo_env/src/lib/index.ts — Core library exports
|
|
2
|
+
export * from './secret_columns.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"secret_columns.d.ts","sourceRoot":"","sources":["../../src/lib/secret_columns.ts"],"names":[],"mappings":"AAYA,wBAAgB,cAAc,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAE1D;AAED,eAAO,MAAM,mBAAmB,UAAsB,CAAC"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// hazo_env/src/lib/secret_columns.ts — columns that must never be copied as data
|
|
2
|
+
const SECRET_COLUMNS = new Set([
|
|
3
|
+
'password', 'password_hash', 'hashed_password',
|
|
4
|
+
'api_key', 'api_secret', 'secret', 'secret_key',
|
|
5
|
+
'token', 'access_token', 'refresh_token', 'auth_token',
|
|
6
|
+
'private_key', 'session_token', 'session_secret',
|
|
7
|
+
'otp_secret', 'totp_secret', 'mfa_secret',
|
|
8
|
+
'client_secret', 'signing_key', 'encryption_key',
|
|
9
|
+
'webhook_secret', 'stripe_key', 'twilio_auth_token',
|
|
10
|
+
]);
|
|
11
|
+
export function isSecretColumn(columnName) {
|
|
12
|
+
return SECRET_COLUMNS.has(columnName.toLowerCase());
|
|
13
|
+
}
|
|
14
|
+
export const SECRET_COLUMNS_LIST = [...SECRET_COLUMNS];
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { MaskTransform } from '../types/index.js';
|
|
2
|
+
/** Register a custom transform (or override a built-in). */
|
|
3
|
+
export declare function registerMask(name: string, fn: MaskTransform): void;
|
|
4
|
+
/** Get the transform function for a given name. Returns undefined if not registered. */
|
|
5
|
+
export declare function getTransform(name: string): Promise<MaskTransform | undefined>;
|
|
6
|
+
/** List all registered transform names. */
|
|
7
|
+
export declare function listTransforms(): Promise<string[]>;
|
|
8
|
+
//# sourceMappingURL=registry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/mask/registry.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAiBvD,4DAA4D;AAC5D,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,aAAa,GAAG,IAAI,CAElE;AAED,wFAAwF;AACxF,wBAAsB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,SAAS,CAAC,CAGnF;AAED,2CAA2C;AAC3C,wBAAsB,cAAc,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC,CAGxD"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// hazo_env/src/mask/registry.ts — mask transform registry
|
|
2
|
+
import { optional_import } from 'hazo_core';
|
|
3
|
+
const _registry = new Map();
|
|
4
|
+
let _seeded = false;
|
|
5
|
+
async function ensureSeeded() {
|
|
6
|
+
if (_seeded)
|
|
7
|
+
return;
|
|
8
|
+
_seeded = true;
|
|
9
|
+
const maskModule = await optional_import('hazo_secure/mask');
|
|
10
|
+
if (maskModule?.BUILTIN_TRANSFORMS) {
|
|
11
|
+
for (const [name, fn] of Object.entries(maskModule.BUILTIN_TRANSFORMS)) {
|
|
12
|
+
_registry.set(name, fn);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** Register a custom transform (or override a built-in). */
|
|
17
|
+
export function registerMask(name, fn) {
|
|
18
|
+
_registry.set(name, fn);
|
|
19
|
+
}
|
|
20
|
+
/** Get the transform function for a given name. Returns undefined if not registered. */
|
|
21
|
+
export async function getTransform(name) {
|
|
22
|
+
await ensureSeeded();
|
|
23
|
+
return _registry.get(name);
|
|
24
|
+
}
|
|
25
|
+
/** List all registered transform names. */
|
|
26
|
+
export async function listTransforms() {
|
|
27
|
+
await ensureSeeded();
|
|
28
|
+
return [..._registry.keys()].sort();
|
|
29
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { HazoConnectAdapter } from 'hazo_connect';
|
|
2
|
+
export interface MaskRule {
|
|
3
|
+
table: string;
|
|
4
|
+
column: string;
|
|
5
|
+
transform: string;
|
|
6
|
+
params?: Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
export declare function ensureAppConfigTable(adapter: HazoConnectAdapter): Promise<void>;
|
|
9
|
+
/** Load rules from hazo_env_masking.ini into hazo_app_config. */
|
|
10
|
+
export declare function syncRulesetFromIni(adapter: HazoConnectAdapter, iniPath?: string): Promise<number>;
|
|
11
|
+
/** Read the effective ruleset from hazo_app_config. */
|
|
12
|
+
export declare function loadRuleset(adapter: HazoConnectAdapter): Promise<MaskRule[]>;
|
|
13
|
+
/** Parse INI-format masking rules. Format: [table_name] / column = transform */
|
|
14
|
+
export declare function parseIniRules(content: string): MaskRule[];
|
|
15
|
+
//# sourceMappingURL=ruleset.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ruleset.d.ts","sourceRoot":"","sources":["../../src/mask/ruleset.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAEvD,MAAM,WAAW,QAAQ;IACvB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC;AASD,wBAAsB,oBAAoB,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAYrF;AAED,iEAAiE;AACjE,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,kBAAkB,EAC3B,OAAO,CAAC,EAAE,MAAM,GACf,OAAO,CAAC,MAAM,CAAC,CAwBjB;AAED,uDAAuD;AACvD,wBAAsB,WAAW,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC,CAYlF;AAED,gFAAgF;AAChF,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,QAAQ,EAAE,CAkBzD"}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// hazo_env/src/mask/ruleset.ts — ruleset persistence (hazo_app_config)
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
const CONFIG_SECTION = 'hazo_env_masking';
|
|
5
|
+
// Ensure hazo_app_config table exists in the given SQLite adapter
|
|
6
|
+
function rawQuery(adapter, sql, params = []) {
|
|
7
|
+
return adapter.rawQuery(sql, { params });
|
|
8
|
+
}
|
|
9
|
+
export async function ensureAppConfigTable(adapter) {
|
|
10
|
+
await rawQuery(adapter, `
|
|
11
|
+
CREATE TABLE IF NOT EXISTS hazo_app_config (
|
|
12
|
+
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
|
13
|
+
config_section TEXT NOT NULL,
|
|
14
|
+
config_name TEXT NOT NULL,
|
|
15
|
+
config_value_json TEXT,
|
|
16
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
17
|
+
updated_at TEXT DEFAULT (datetime('now')),
|
|
18
|
+
UNIQUE(config_section, config_name)
|
|
19
|
+
)
|
|
20
|
+
`);
|
|
21
|
+
}
|
|
22
|
+
/** Load rules from hazo_env_masking.ini into hazo_app_config. */
|
|
23
|
+
export async function syncRulesetFromIni(adapter, iniPath) {
|
|
24
|
+
const resolvedPath = iniPath ?? path.resolve(process.cwd(), 'config', 'hazo_env_masking.ini');
|
|
25
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
26
|
+
throw new Error(`hazo_env_masking.ini not found at ${resolvedPath}`);
|
|
27
|
+
}
|
|
28
|
+
const content = fs.readFileSync(resolvedPath, 'utf-8');
|
|
29
|
+
const rules = parseIniRules(content);
|
|
30
|
+
await ensureAppConfigTable(adapter);
|
|
31
|
+
let count = 0;
|
|
32
|
+
for (const rule of rules) {
|
|
33
|
+
const name = `${rule.table}.${rule.column}`;
|
|
34
|
+
const valueJson = JSON.stringify({ transform: rule.transform, params: rule.params ?? {} });
|
|
35
|
+
await rawQuery(adapter, `
|
|
36
|
+
INSERT INTO hazo_app_config (config_section, config_name, config_value_json)
|
|
37
|
+
VALUES (?, ?, ?)
|
|
38
|
+
ON CONFLICT(config_section, config_name) DO UPDATE SET
|
|
39
|
+
config_value_json = excluded.config_value_json,
|
|
40
|
+
updated_at = datetime('now')
|
|
41
|
+
`, [CONFIG_SECTION, name, valueJson]);
|
|
42
|
+
count++;
|
|
43
|
+
}
|
|
44
|
+
return count;
|
|
45
|
+
}
|
|
46
|
+
/** Read the effective ruleset from hazo_app_config. */
|
|
47
|
+
export async function loadRuleset(adapter) {
|
|
48
|
+
await ensureAppConfigTable(adapter);
|
|
49
|
+
const rows = await rawQuery(adapter, `SELECT config_name, config_value_json FROM hazo_app_config WHERE config_section = ? ORDER BY config_name`, [CONFIG_SECTION]);
|
|
50
|
+
return rows.map((row) => {
|
|
51
|
+
const [table, column] = row.config_name.split('.');
|
|
52
|
+
const parsed = JSON.parse(row.config_value_json ?? '{}');
|
|
53
|
+
return { table, column, transform: parsed.transform, params: parsed.params };
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
/** Parse INI-format masking rules. Format: [table_name] / column = transform */
|
|
57
|
+
export function parseIniRules(content) {
|
|
58
|
+
const rules = [];
|
|
59
|
+
let currentTable = '';
|
|
60
|
+
for (const rawLine of content.split('\n')) {
|
|
61
|
+
const line = rawLine.trim();
|
|
62
|
+
if (!line || line.startsWith(';') || line.startsWith('#'))
|
|
63
|
+
continue;
|
|
64
|
+
if (line.startsWith('[') && line.endsWith(']')) {
|
|
65
|
+
currentTable = line.slice(1, -1).trim();
|
|
66
|
+
}
|
|
67
|
+
else if (currentTable && line.includes('=')) {
|
|
68
|
+
const eqIdx = line.indexOf('=');
|
|
69
|
+
const column = line.slice(0, eqIdx).trim();
|
|
70
|
+
const transform = line.slice(eqIdx + 1).trim();
|
|
71
|
+
if (column && transform) {
|
|
72
|
+
rules.push({ table: currentTable, column, transform });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return rules;
|
|
77
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { HazoConnectAdapter } from 'hazo_connect';
|
|
2
|
+
export interface AuditMigrationPayload {
|
|
3
|
+
from: string;
|
|
4
|
+
to: string;
|
|
5
|
+
scrub: string;
|
|
6
|
+
db?: {
|
|
7
|
+
tables: number;
|
|
8
|
+
rows: number;
|
|
9
|
+
scrubbed: number;
|
|
10
|
+
};
|
|
11
|
+
files?: {
|
|
12
|
+
copied: number;
|
|
13
|
+
bytes: number;
|
|
14
|
+
};
|
|
15
|
+
verify?: {
|
|
16
|
+
ok: boolean;
|
|
17
|
+
checked: number;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export declare function auditMigration(adapter: HazoConnectAdapter, payload: AuditMigrationPayload): Promise<void>;
|
|
21
|
+
//# sourceMappingURL=audit.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"audit.d.ts","sourceRoot":"","sources":["../../src/migrate/audit.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAEvD,MAAM,WAAW,qBAAqB;IACpC,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,EAAE,CAAC,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IACxD,KAAK,CAAC,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;IAC1C,MAAM,CAAC,EAAE;QAAE,EAAE,EAAE,OAAO,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;CAC3C;AAOD,wBAAsB,cAAc,CAClC,OAAO,EAAE,kBAAkB,EAC3B,OAAO,EAAE,qBAAqB,GAC7B,OAAO,CAAC,IAAI,CAAC,CAgCf"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// hazo_env/src/migrate/audit.ts — emit audit intent event (wrapped, never fails migration)
|
|
2
|
+
import { optional_import } from 'hazo_core';
|
|
3
|
+
// Cast helper for SQLite rawQuery params extension
|
|
4
|
+
function rawQuery(adapter, sql, params = []) {
|
|
5
|
+
return adapter.rawQuery(sql, { params });
|
|
6
|
+
}
|
|
7
|
+
export async function auditMigration(adapter, payload) {
|
|
8
|
+
try {
|
|
9
|
+
// Ensure hazo_audit_intent table exists (CREATE TABLE IF NOT EXISTS)
|
|
10
|
+
await rawQuery(adapter, `
|
|
11
|
+
CREATE TABLE IF NOT EXISTS hazo_audit_intent (
|
|
12
|
+
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
|
13
|
+
event_name TEXT NOT NULL,
|
|
14
|
+
payload TEXT,
|
|
15
|
+
subject_kind TEXT,
|
|
16
|
+
subject_id TEXT,
|
|
17
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
18
|
+
)
|
|
19
|
+
`).catch(() => { });
|
|
20
|
+
const auditModule = await optional_import('hazo_audit/server');
|
|
21
|
+
if (auditModule?.emitIntentEvent) {
|
|
22
|
+
await auditModule.emitIntentEvent(adapter, {
|
|
23
|
+
event_name: 'hazo_env.migration',
|
|
24
|
+
payload: payload,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
// Fallback: insert directly
|
|
29
|
+
await rawQuery(adapter, `INSERT INTO hazo_audit_intent (event_name, payload, subject_kind) VALUES (?, ?, ?)`, ['hazo_env.migration', JSON.stringify(payload), 'migration']);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch (e) {
|
|
33
|
+
// Audit write failure never fails the migration
|
|
34
|
+
console.warn('[hazo_env] audit write failed (non-fatal):', e instanceof Error ? e.message : String(e));
|
|
35
|
+
}
|
|
36
|
+
}
|