@selvajs/cli 4.20.0-beta.20 → 4.20.0-beta.21
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/package.json +2 -2
- package/src/caddyfile.js +3 -3
- package/src/checks/config.js +68 -1
- package/src/cli.js +23 -7
- package/src/commands/config.js +196 -0
- package/src/commands/doctor.js +13 -5
- package/src/commands/init.js +15 -7
- package/src/commands/keys.js +112 -33
- package/src/commands/migrate.js +131 -36
- package/src/commands/proxy.js +12 -0
- package/src/env-migration.js +148 -0
- package/src/env.js +77 -36
- package/src/generated/config.js +1099 -0
- package/src/prompts.js +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@selvajs/cli",
|
|
3
|
-
"version": "4.20.0-beta.
|
|
3
|
+
"version": "4.20.0-beta.21",
|
|
4
4
|
"description": "Scaffold and operate a Selva white-label deployment. `npx @selvajs/cli <dir>` to bootstrap, `selva <cmd>` to manage.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "VektorNode",
|
|
@@ -46,6 +46,6 @@
|
|
|
46
46
|
"scripts": {
|
|
47
47
|
"start": "node ./bin/cli.js",
|
|
48
48
|
"selva": "node ./bin/selva.js",
|
|
49
|
-
"test": "node --check ./bin/cli.js && node --check ./bin/selva.js && node --input-type=module -e \"for (const m of ['./src/cli.js','./src/prompts.js','./src/env.js','./src/paths.js','./src/secrets.js','./src/node-range.js','./src/deployment-package.js','./src/commands/create.js','./src/commands/init.js','./src/commands/doctor.js','./src/commands/pm2.js','./src/commands/migrate.js','./src/commands/keys.js']) { await import(m); }\" && node --test \"./src/__tests__/*.test.js\""
|
|
49
|
+
"test": "node --check ./bin/cli.js && node --check ./bin/selva.js && node --input-type=module -e \"for (const m of ['./src/cli.js','./src/prompts.js','./src/env.js','./src/paths.js','./src/secrets.js','./src/node-range.js','./src/deployment-package.js','./src/commands/create.js','./src/commands/init.js','./src/commands/doctor.js','./src/commands/pm2.js','./src/commands/migrate.js','./src/commands/keys.js','./src/commands/config.js','./src/env-migration.js']) { await import(m); }\" && node --test \"./src/__tests__/*.test.js\""
|
|
50
50
|
}
|
|
51
51
|
}
|
package/src/caddyfile.js
CHANGED
|
@@ -46,10 +46,10 @@ export function renderCaddyfile({ domain, acmeEmail, port = 3000 }) {
|
|
|
46
46
|
header @api Cache-Control "no-cache, no-store, must-revalidate"
|
|
47
47
|
|
|
48
48
|
# Must clear the app's own caps (COMPUTE_REQUEST_MAX_BYTES / BODY_SIZE_LIMIT,
|
|
49
|
-
# both 256
|
|
50
|
-
# every upload no matter what .env says.
|
|
49
|
+
# both 256 MiB) — Caddy rejects first, so a lower value here silently caps
|
|
50
|
+
# every upload no matter what .env says. Write MiB: Caddy's "mb" is 10^6 bytes.
|
|
51
51
|
request_body {
|
|
52
|
-
max_size
|
|
52
|
+
max_size 256MiB
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
log {
|
package/src/checks/config.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// table, so a rule changed here and not there fails CI.
|
|
12
12
|
|
|
13
13
|
import { RENAMED_ENV_VARS, REPLACED_ENV_VARS } from '../env.js';
|
|
14
|
+
import { keyRotationStatus } from '../generated/config.js';
|
|
14
15
|
import { green, red, yellow } from './result.js';
|
|
15
16
|
|
|
16
17
|
const HEX_64 = /^[0-9a-f]{64}$/i;
|
|
@@ -28,10 +29,33 @@ const VALID_DATA = ['local', 'supabase'];
|
|
|
28
29
|
export function checkSecret(value, label) {
|
|
29
30
|
if (!value) return red(`${label} — unset`);
|
|
30
31
|
if (value === PLACEHOLDER) return red(`${label} — still the placeholder`);
|
|
31
|
-
if (!HEX_64.test(value)
|
|
32
|
+
if (!HEX_64.test(value) && !isBase64Of32Bytes(value)) {
|
|
33
|
+
return red(`${label} — not 32 bytes as 64 hex chars or base64`);
|
|
34
|
+
}
|
|
32
35
|
return green(label);
|
|
33
36
|
}
|
|
34
37
|
|
|
38
|
+
/**
|
|
39
|
+
* SELVA_HMAC_KEY takes any string of 32+ characters (the token codec's rule),
|
|
40
|
+
* not only hex: reding a valid key would send an operator rotating it.
|
|
41
|
+
*/
|
|
42
|
+
export function checkHmacKey(value) {
|
|
43
|
+
const label = 'SELVA_HMAC_KEY is at least 32 characters';
|
|
44
|
+
if (!value) return red(`${label} — unset`);
|
|
45
|
+
if (value === PLACEHOLDER) return red(`${label} — still the placeholder`);
|
|
46
|
+
if (value.length < 32) return red(`${label} — only ${value.length}`);
|
|
47
|
+
return green(label);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Mirrors decodeSecretKey in @selvajs/platform: only a round trip proves the
|
|
51
|
+
// whole string was base64, since Buffer.from skips what it can't decode.
|
|
52
|
+
function isBase64Of32Bytes(value) {
|
|
53
|
+
const buf = Buffer.from(value, 'base64');
|
|
54
|
+
return (
|
|
55
|
+
buf.length === 32 && buf.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
35
59
|
// Lowercased and defaulted the same way create-selva-providers.ts defaults them.
|
|
36
60
|
export function resolveProviders(env) {
|
|
37
61
|
return {
|
|
@@ -291,6 +315,19 @@ export function checkClientAddress(env) {
|
|
|
291
315
|
);
|
|
292
316
|
}
|
|
293
317
|
|
|
318
|
+
// A trusted address header is only safe if every request passes through the
|
|
319
|
+
// proxy: on a public bind a direct caller sets the header itself and picks
|
|
320
|
+
// its own rate-limit bucket.
|
|
321
|
+
const host = env.HOST ?? '0.0.0.0';
|
|
322
|
+
if (!['127.0.0.1', 'localhost', '::1'].includes(host)) {
|
|
323
|
+
return yellow(
|
|
324
|
+
`ADDRESS_HEADER=${header} is trusted but HOST=${host} leaves the app reachable ` +
|
|
325
|
+
`without the proxy, so a direct caller can set the header and pick its own ` +
|
|
326
|
+
`rate-limit bucket. Set HOST=127.0.0.1 and keep port 3000 closed in the ` +
|
|
327
|
+
`firewall (ignore this only if the proxy runs on another host behind network isolation).`
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
294
331
|
return green(`ADDRESS_HEADER=${header}${depth ? ` (XFF_DEPTH=${depth})` : ''}`);
|
|
295
332
|
}
|
|
296
333
|
|
|
@@ -316,3 +353,33 @@ export function checkDeprecatedEnv(env) {
|
|
|
316
353
|
|
|
317
354
|
return checks;
|
|
318
355
|
}
|
|
356
|
+
|
|
357
|
+
/** Where each root key's rotation overlap stands. Nothing while no rotation is in progress. */
|
|
358
|
+
export function checkKeyRotation(env, now = Date.now()) {
|
|
359
|
+
return keyRotationStatus(env, now).flatMap(({ key, state, expires, daysLeft }) => {
|
|
360
|
+
const previous = `${key}_PREVIOUS`;
|
|
361
|
+
const target = key === 'SELVA_HMAC_KEY' ? 'hmac' : 'at-rest';
|
|
362
|
+
if (state === 'active') {
|
|
363
|
+
return [
|
|
364
|
+
green(`${previous} accepted for ${daysLeft} more days (until ${expires.slice(0, 10)})`)
|
|
365
|
+
];
|
|
366
|
+
}
|
|
367
|
+
if (state === 'expired') {
|
|
368
|
+
return [
|
|
369
|
+
yellow(
|
|
370
|
+
`${previous} passed its deadline (${expires}) and is no longer accepted. ` +
|
|
371
|
+
`Remove it: npx selva keys rotate ${target} --finish`
|
|
372
|
+
)
|
|
373
|
+
];
|
|
374
|
+
}
|
|
375
|
+
if (state === 'no-deadline') {
|
|
376
|
+
return [
|
|
377
|
+
yellow(
|
|
378
|
+
`${previous} has no deadline, so the old key is accepted for good. Set ` +
|
|
379
|
+
`${previous}_EXPIRES, or remove it: npx selva keys rotate ${target} --finish`
|
|
380
|
+
)
|
|
381
|
+
];
|
|
382
|
+
}
|
|
383
|
+
return [];
|
|
384
|
+
});
|
|
385
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -16,7 +16,8 @@ const COMMANDS = {
|
|
|
16
16
|
update: () => import('./commands/pm2.js').then((m) => m.runUpdate),
|
|
17
17
|
migrate: () => import('./commands/migrate.js').then((m) => m.runMigrate),
|
|
18
18
|
'setup-proxy': () => import('./commands/proxy.js').then((m) => m.runSetupProxy),
|
|
19
|
-
keys: () => import('./commands/keys.js').then((m) => keysDispatch(m))
|
|
19
|
+
keys: () => import('./commands/keys.js').then((m) => keysDispatch(m)),
|
|
20
|
+
config: () => import('./commands/config.js').then((m) => m.runConfig)
|
|
20
21
|
};
|
|
21
22
|
|
|
22
23
|
// `selva doctor --help` used to run the full doctor: commands own their argv,
|
|
@@ -49,9 +50,13 @@ const USAGE = {
|
|
|
49
50
|
'npm update @selvajs/cli + @selvajs/selva, then restart. Rolls the\ndependency tree back if the install fails.'
|
|
50
51
|
},
|
|
51
52
|
migrate: {
|
|
52
|
-
usage: 'selva migrate',
|
|
53
|
+
usage: 'selva migrate [--dry-run | --undo]',
|
|
53
54
|
blurb:
|
|
54
|
-
'Bring package.json
|
|
55
|
+
'Bring package.json, the deployment layout and .env onto the current release.\nRenames old keys and proves every setting keeps its meaning before writing.',
|
|
56
|
+
flags: [
|
|
57
|
+
['--dry-run', 'Show what would change. Writes nothing.'],
|
|
58
|
+
['--undo', 'Restore the newest .env backup.']
|
|
59
|
+
]
|
|
55
60
|
},
|
|
56
61
|
'setup-proxy': {
|
|
57
62
|
usage: 'selva setup-proxy [--domain <fqdn>] [--acme-email <addr>]',
|
|
@@ -62,10 +67,20 @@ const USAGE = {
|
|
|
62
67
|
['--acme-email', "Address Let's Encrypt sends expiry notices to."]
|
|
63
68
|
]
|
|
64
69
|
},
|
|
70
|
+
config: {
|
|
71
|
+
usage: 'selva config <show [--all] | set KEY=VALUE | unset KEY>',
|
|
72
|
+
blurb:
|
|
73
|
+
'Show what .env sets, what is default and what is wrong; set or unset one\nkey after checking it. Edits only that line and keeps a backup.',
|
|
74
|
+
flags: [['--all', 'show: list every setting, defaults included.']]
|
|
75
|
+
},
|
|
65
76
|
keys: {
|
|
66
|
-
usage: 'selva keys rotate <hmac|at-rest>',
|
|
77
|
+
usage: 'selva keys rotate <hmac|at-rest> [--finish | --leaked]',
|
|
67
78
|
blurb:
|
|
68
|
-
'Rotate one secret in .env.
|
|
79
|
+
'Rotate one secret in .env. The old key stays accepted as <KEY>_PREVIOUS, so\nnothing breaks; --finish drops it later. --leaked drops it at once.',
|
|
80
|
+
flags: [
|
|
81
|
+
['--finish', 'Remove <KEY>_PREVIOUS once the overlap has passed.'],
|
|
82
|
+
['--leaked', 'Replace the key and drop the old one now. Breaks what it signed.']
|
|
83
|
+
]
|
|
69
84
|
}
|
|
70
85
|
};
|
|
71
86
|
|
|
@@ -85,7 +100,7 @@ function keysDispatch(m) {
|
|
|
85
100
|
return async (argv) => {
|
|
86
101
|
const sub = argv[0];
|
|
87
102
|
if (sub === 'rotate') return m.runKeysRotate(argv.slice(1));
|
|
88
|
-
console.error(`Usage: selva keys rotate <hmac|at-rest
|
|
103
|
+
console.error(`Usage: selva keys rotate <hmac|at-rest> [--finish | --leaked]`);
|
|
89
104
|
process.exit(1);
|
|
90
105
|
};
|
|
91
106
|
}
|
|
@@ -140,7 +155,8 @@ function printHelp() {
|
|
|
140
155
|
' update npm update @selvajs/cli + @selvajs/selva, then restart',
|
|
141
156
|
' migrate Bring package.json onto the current layout',
|
|
142
157
|
' setup-proxy Install + configure Caddy with TLS for this deployment',
|
|
143
|
-
'
|
|
158
|
+
' config show|set|unset What .env sets; change one key safely',
|
|
159
|
+
' keys rotate <hmac|at-rest> Rotate a secret; the old one keeps working',
|
|
144
160
|
'',
|
|
145
161
|
pc.dim('`npx selva <command> --help` explains one command.'),
|
|
146
162
|
'',
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// `selva config show|set|unset`: what this deployment's .env sets, checked
|
|
2
|
+
// against the manifest the runtime declares.
|
|
3
|
+
|
|
4
|
+
import { copyFileSync, existsSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import pc from 'picocolors';
|
|
7
|
+
import { parseEnv, setEnvValues, writeEnvText } from '../env.js';
|
|
8
|
+
import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
|
|
9
|
+
import {
|
|
10
|
+
ALL_SETTINGS,
|
|
11
|
+
deploymentReport,
|
|
12
|
+
deploymentSnapshot,
|
|
13
|
+
diffSnapshots,
|
|
14
|
+
resolveConfig
|
|
15
|
+
} from '../generated/config.js';
|
|
16
|
+
import { backupPathFor, backupStamp } from './migrate.js';
|
|
17
|
+
|
|
18
|
+
export const SNAPSHOT_FILE = '.selva-config-snapshot.json';
|
|
19
|
+
|
|
20
|
+
/** The same report the app builds for /admin/system. */
|
|
21
|
+
export const reportFor = deploymentReport;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* How `.env` differs from what the app last booted with. Non-empty means it
|
|
25
|
+
* was edited without a restart, or something (pm2's saved env, compose)
|
|
26
|
+
* overrides it.
|
|
27
|
+
*/
|
|
28
|
+
export function driftFromLastBoot(dir, env) {
|
|
29
|
+
const path = join(dir, SNAPSHOT_FILE);
|
|
30
|
+
if (!existsSync(path)) return null;
|
|
31
|
+
let snapshot;
|
|
32
|
+
try {
|
|
33
|
+
snapshot = JSON.parse(readFileSync(path, 'utf8'));
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
return diffSnapshots(snapshot, deploymentSnapshot(env, snapshot.salt));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Validate one KEY=VALUE against the manifest. Returns the spec, or an error message. */
|
|
41
|
+
export function checkSetting(key, value) {
|
|
42
|
+
const spec = ALL_SETTINGS.find((s) => s.key === key);
|
|
43
|
+
if (!spec) {
|
|
44
|
+
const alias = ALL_SETTINGS.find((s) => s.aliases?.includes(key));
|
|
45
|
+
return {
|
|
46
|
+
error: alias
|
|
47
|
+
? `${key} was renamed to ${alias.key}.`
|
|
48
|
+
: `${key} is not a setting Selva reads. \`selva config show --all\` lists them.`
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
// Setting a key by hand skips _PREVIOUS and breaks what it protected.
|
|
52
|
+
if (spec.class === 'secret' && spec.generated) {
|
|
53
|
+
const target = key.startsWith('SELVA_HMAC_KEY') ? 'hmac' : 'at-rest';
|
|
54
|
+
return {
|
|
55
|
+
error: `${key} is replaced with \`selva keys rotate ${target}\`, which keeps the old key working.`
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const report = resolveConfig([spec], { [key]: value }).keys[key];
|
|
59
|
+
if (report.status !== 'ok') return { error: `${key} ${report.problem}.` };
|
|
60
|
+
return { spec };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function printRow(row) {
|
|
64
|
+
const value = row.display === '' ? pc.dim('—') : row.display;
|
|
65
|
+
const source =
|
|
66
|
+
row.source === 'default'
|
|
67
|
+
? pc.dim('default')
|
|
68
|
+
: row.source === 'unset'
|
|
69
|
+
? pc.dim('not set')
|
|
70
|
+
: row.source === 'alias'
|
|
71
|
+
? pc.yellow(`from ${row.alias}`)
|
|
72
|
+
: '.env';
|
|
73
|
+
const mark =
|
|
74
|
+
row.status === 'error' ? pc.red('✗') : row.status === 'invalid' ? pc.yellow('!') : ' ';
|
|
75
|
+
console.log(`${mark} ${row.key.padEnd(36)} ${String(value).padEnd(30)} ${source}`);
|
|
76
|
+
if (row.problem) console.log(` ${pc.red(`value ${row.problem}`)}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function runConfig(argv) {
|
|
80
|
+
const [sub, ...rest] = argv;
|
|
81
|
+
if (sub === 'show' || sub === undefined) return runShow(rest);
|
|
82
|
+
if (sub === 'set') return runSet(rest);
|
|
83
|
+
if (sub === 'unset') return runUnset(rest);
|
|
84
|
+
console.error('Usage: selva config <show [--all] | set KEY=VALUE | unset KEY>');
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readDeploymentEnv() {
|
|
89
|
+
const dir = resolveDeploymentDir();
|
|
90
|
+
requireDeploymentDir(dir);
|
|
91
|
+
const envPath = join(dir, '.env');
|
|
92
|
+
const text = existsSync(envPath) ? readFileSync(envPath, 'utf8') : '';
|
|
93
|
+
return { dir, envPath, text, env: parseEnv(text) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function runShow(argv) {
|
|
97
|
+
const all = argv.includes('--all');
|
|
98
|
+
const { dir, env } = readDeploymentEnv();
|
|
99
|
+
const report = reportFor(env);
|
|
100
|
+
|
|
101
|
+
console.log(pc.bold('\nThe important ones'));
|
|
102
|
+
for (const row of report.rows.filter((r) => r.summary)) printRow(row);
|
|
103
|
+
|
|
104
|
+
const rest = report.rows.filter(
|
|
105
|
+
(r) => !r.summary && (all || r.source !== 'default') && r.source !== 'unset'
|
|
106
|
+
);
|
|
107
|
+
if (rest.length > 0) {
|
|
108
|
+
console.log(pc.bold(all ? '\nEverything else' : '\nAlso set in .env'));
|
|
109
|
+
for (const row of rest) printRow(row);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (report.unknown.length > 0) {
|
|
113
|
+
console.log(pc.bold('\nNot settings Selva reads'));
|
|
114
|
+
for (const u of report.unknown) {
|
|
115
|
+
console.log(
|
|
116
|
+
`${pc.yellow('!')} ${u.key}${u.suggestion ? pc.dim(` did you mean ${u.suggestion}?`) : ''}`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const drift = driftFromLastBoot(dir, env);
|
|
122
|
+
if (drift && drift.length > 0) {
|
|
123
|
+
console.log(pc.bold('\nDiffers from what the app last started with'));
|
|
124
|
+
console.log(
|
|
125
|
+
pc.dim(' Restart to apply, or check whether pm2 or the environment overrides .env.')
|
|
126
|
+
);
|
|
127
|
+
for (const c of drift) {
|
|
128
|
+
const detail =
|
|
129
|
+
c.from !== undefined || c.to !== undefined ? ` ${c.from ?? '—'} → ${c.to ?? '—'}` : '';
|
|
130
|
+
console.log(` ${c.key} ${pc.dim(c.change)}${detail}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const errors = report.problems.filter((p) => p.status === 'error').length;
|
|
135
|
+
console.log(
|
|
136
|
+
'\n' +
|
|
137
|
+
(errors > 0
|
|
138
|
+
? pc.red(`${errors} setting${errors === 1 ? '' : 's'} would stop the app from starting.`)
|
|
139
|
+
: pc.green('Nothing stops the app from starting.')) +
|
|
140
|
+
(all ? '' : pc.dim(' `selva config show --all` lists every setting.'))
|
|
141
|
+
);
|
|
142
|
+
if (errors > 0) process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function backUp(envPath) {
|
|
146
|
+
if (!existsSync(envPath)) return null;
|
|
147
|
+
const bak = backupPathFor(envPath, backupStamp());
|
|
148
|
+
copyFileSync(envPath, bak);
|
|
149
|
+
writeEnvText(bak, readFileSync(bak, 'utf8'));
|
|
150
|
+
return bak;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function runSet(argv) {
|
|
154
|
+
const arg = argv[0] ?? '';
|
|
155
|
+
const eq = arg.indexOf('=');
|
|
156
|
+
if (eq <= 0) {
|
|
157
|
+
console.error('Usage: selva config set KEY=VALUE');
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
const key = arg.slice(0, eq);
|
|
161
|
+
const value = arg.slice(eq + 1);
|
|
162
|
+
const { spec, error } = checkSetting(key, value);
|
|
163
|
+
if (error) {
|
|
164
|
+
console.error(pc.red(`✗ ${error}`));
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
const { envPath, text } = readDeploymentEnv();
|
|
168
|
+
if (spec.impact) console.log(pc.yellow(`! ${spec.impact}`));
|
|
169
|
+
const bak = backUp(envPath);
|
|
170
|
+
writeEnvText(envPath, setEnvValues(text, { [key]: value }));
|
|
171
|
+
console.log(pc.green(`✓ ${key} set.`) + (bak ? pc.dim(` Backup: ${bak}`) : ''));
|
|
172
|
+
console.log(pc.dim('Restart to apply: ') + pc.cyan('selva restart'));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function runUnset(argv) {
|
|
176
|
+
const key = argv[0];
|
|
177
|
+
if (!key) {
|
|
178
|
+
console.error('Usage: selva config unset KEY');
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
const { envPath, text, env } = readDeploymentEnv();
|
|
182
|
+
if (!(key in env)) {
|
|
183
|
+
console.log(pc.dim(`${key} is not set in .env.`));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const spec = ALL_SETTINGS.find((s) => s.key === key);
|
|
187
|
+
if (spec?.required) {
|
|
188
|
+
console.error(pc.red(`✗ ${key} is required; the app would not start without it.`));
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
if (spec?.impact) console.log(pc.yellow(`! ${spec.impact}`));
|
|
192
|
+
const bak = backUp(envPath);
|
|
193
|
+
writeEnvText(envPath, setEnvValues(text, { [key]: undefined }));
|
|
194
|
+
console.log(pc.green(`✓ ${key} removed.`) + (bak ? pc.dim(` Backup: ${bak}`) : ''));
|
|
195
|
+
console.log(pc.dim('Restart to apply: ') + pc.cyan('selva restart'));
|
|
196
|
+
}
|
package/src/commands/doctor.js
CHANGED
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
import {
|
|
9
9
|
existsSync,
|
|
10
10
|
readFileSync,
|
|
11
|
-
writeFileSync,
|
|
12
11
|
copyFileSync,
|
|
13
12
|
readdirSync,
|
|
14
13
|
accessSync,
|
|
@@ -20,7 +19,13 @@ import { join, resolve, dirname } from 'node:path';
|
|
|
20
19
|
import { fileURLToPath } from 'node:url';
|
|
21
20
|
import * as p from '@clack/prompts';
|
|
22
21
|
import pc from 'picocolors';
|
|
23
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
readEnvFile,
|
|
24
|
+
parseEnv,
|
|
25
|
+
countEnvCommentLines,
|
|
26
|
+
stripEnvComments,
|
|
27
|
+
writeEnvText
|
|
28
|
+
} from '../env.js';
|
|
24
29
|
import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
|
|
25
30
|
import { satisfiesNodeRange } from '../node-range.js';
|
|
26
31
|
import { green, yellow, red, fixable } from '../checks/result.js';
|
|
@@ -32,6 +37,8 @@ import {
|
|
|
32
37
|
checkDeprecatedEnv,
|
|
33
38
|
checkEnvDocumentation,
|
|
34
39
|
checkHeaderNames,
|
|
40
|
+
checkHmacKey,
|
|
41
|
+
checkKeyRotation,
|
|
35
42
|
checkOrigin,
|
|
36
43
|
checkProviders,
|
|
37
44
|
checkSecret,
|
|
@@ -55,8 +62,9 @@ export async function runDoctor(argv = []) {
|
|
|
55
62
|
checks.push(checkLayoutDrift(dir));
|
|
56
63
|
checks.push(checkMigrationLeftovers(dir));
|
|
57
64
|
checks.push(checkAgedBackups(dir));
|
|
58
|
-
checks.push(
|
|
59
|
-
checks.push(checkSecret(env.SELVA_AT_REST_KEY, 'SELVA_AT_REST_KEY is a 32-byte
|
|
65
|
+
checks.push(checkHmacKey(env.SELVA_HMAC_KEY));
|
|
66
|
+
checks.push(checkSecret(env.SELVA_AT_REST_KEY, 'SELVA_AT_REST_KEY is a 32-byte key'));
|
|
67
|
+
checks.push(...checkKeyRotation(env));
|
|
60
68
|
|
|
61
69
|
const providers = resolveProviders(env);
|
|
62
70
|
checks.push(...checkProviders(providers));
|
|
@@ -460,7 +468,7 @@ function checkEnvDocs(dir) {
|
|
|
460
468
|
if (drift) return red(`refused to strip .env — ${drift}`);
|
|
461
469
|
|
|
462
470
|
copyFileSync(envPath, envPath + '.bak');
|
|
463
|
-
|
|
471
|
+
writeEnvText(envPath, strippedText);
|
|
464
472
|
return green(`stripped ${removed} comment lines from .env (backup: .env.bak)`);
|
|
465
473
|
} catch (err) {
|
|
466
474
|
return red(`could not rewrite .env: ${err instanceof Error ? err.message : err}`);
|
package/src/commands/init.js
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
// the current .env as prompt defaults and never touches package.json or runs
|
|
3
3
|
// npm install.
|
|
4
4
|
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
5
6
|
import { join } from 'node:path';
|
|
6
7
|
import * as p from '@clack/prompts';
|
|
7
8
|
import pc from 'picocolors';
|
|
8
9
|
import { collectConfig } from '../prompts.js';
|
|
9
|
-
import {
|
|
10
|
+
import { parseEnv, seededTemplateDefaults, setEnvValues, writeEnvText } from '../env.js';
|
|
10
11
|
import { generateKey } from '../secrets.js';
|
|
11
12
|
import { readEnvTemplate, requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
|
|
12
13
|
|
|
@@ -15,7 +16,8 @@ export async function runInit() {
|
|
|
15
16
|
requireDeploymentDir(dir);
|
|
16
17
|
|
|
17
18
|
const envPath = join(dir, '.env');
|
|
18
|
-
const
|
|
19
|
+
const text = existsSync(envPath) ? readFileSync(envPath, 'utf8') : '';
|
|
20
|
+
const current = parseEnv(text);
|
|
19
21
|
const values = await collectConfig({ defaults: current, mode: 'init' });
|
|
20
22
|
|
|
21
23
|
// Never regenerate an existing key: rotating SELVA_HMAC_KEY or
|
|
@@ -42,11 +44,17 @@ export async function runInit() {
|
|
|
42
44
|
p.log.warn('SELVA_AT_REST_KEY was missing — generated a fresh one.');
|
|
43
45
|
}
|
|
44
46
|
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
// Line-level: keys the prompts don't cover, and the operator's comments, stay as they are.
|
|
48
|
+
// A prompt answered blank means unset, as it did when the file was re-rendered.
|
|
49
|
+
const updates = Object.fromEntries(
|
|
50
|
+
Object.entries(values).map(([k, v]) => [k, v === '' ? undefined : v])
|
|
51
|
+
);
|
|
52
|
+
// A template default missing from .env (BODY_SIZE_LIMIT: unset, every upload
|
|
53
|
+
// 413s at adapter-node's 512 KB) is filled in; one the operator set is kept.
|
|
54
|
+
for (const [k, v] of Object.entries(seededTemplateDefaults(readEnvTemplate(dir)))) {
|
|
55
|
+
if (current[k] === undefined && !(k in updates)) updates[k] = v;
|
|
56
|
+
}
|
|
57
|
+
writeEnvText(envPath, setEnvValues(text, updates));
|
|
50
58
|
|
|
51
59
|
p.outro(
|
|
52
60
|
[
|
package/src/commands/keys.js
CHANGED
|
@@ -1,67 +1,146 @@
|
|
|
1
|
-
//
|
|
1
|
+
// `selva keys rotate <hmac|at-rest> [--finish | --leaked]`.
|
|
2
|
+
//
|
|
3
|
+
// A routine rotation keeps the old key as <KEY>_PREVIOUS, which the app still
|
|
4
|
+
// accepts, so nothing breaks. `--finish` drops the previous key once its
|
|
5
|
+
// overlap has passed. `--leaked` replaces the key and drops the old one at
|
|
6
|
+
// once: that is the destructive path, for a key that must stop working now.
|
|
2
7
|
|
|
8
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
3
9
|
import { join } from 'node:path';
|
|
4
10
|
import * as p from '@clack/prompts';
|
|
5
11
|
import pc from 'picocolors';
|
|
6
|
-
import {
|
|
12
|
+
import { parseEnv, setEnvValues, writeEnvText } from '../env.js';
|
|
13
|
+
import { resolveProviders } from '../checks/config.js';
|
|
7
14
|
import { generateKey } from '../secrets.js';
|
|
8
|
-
import {
|
|
15
|
+
import { PREVIOUS_KEY_DAYS } from '../generated/config.js';
|
|
16
|
+
import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
|
|
9
17
|
|
|
10
18
|
const TARGETS = {
|
|
11
19
|
hmac: {
|
|
12
20
|
envVar: 'SELVA_HMAC_KEY',
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
leakedBlastRadius: hmacBlastRadius,
|
|
22
|
+
finishNote:
|
|
23
|
+
'The app stops accepting the previous key on its own at SELVA_HMAC_KEY_PREVIOUS_EXPIRES; ' +
|
|
24
|
+
'finishing ends it now. Either way this stops working: MCP clients registered before the ' +
|
|
25
|
+
'rotation (they register again), and share links, invites and API tokens stored under the ' +
|
|
26
|
+
'previous key before tokens were hashed without one. Sessions it signed last 8 hours at most.'
|
|
18
27
|
},
|
|
19
28
|
'at-rest': {
|
|
20
29
|
envVar: 'SELVA_AT_REST_KEY',
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
].join('\n')
|
|
30
|
+
leakedBlastRadius: atRestBlastRadius,
|
|
31
|
+
finishNote:
|
|
32
|
+
'The app re-seals stored API keys under the new key at boot, and stops accepting the previous ' +
|
|
33
|
+
'key at SELVA_AT_REST_KEY_PREVIOUS_EXPIRES. Check that /admin/system shows none left on the ' +
|
|
34
|
+
'previous key before finishing, or they become unreadable.'
|
|
27
35
|
}
|
|
28
36
|
};
|
|
29
37
|
|
|
38
|
+
/** What dropping the old SELVA_HMAC_KEY at once invalidates on this deployment's providers. */
|
|
39
|
+
export function hmacBlastRadius(env) {
|
|
40
|
+
const { auth } = resolveProviders(env);
|
|
41
|
+
const lines = [];
|
|
42
|
+
// Only the local provider signs sessions with it: Supabase sessions are
|
|
43
|
+
// Supabase JWTs, and under header auth the proxy owns the session.
|
|
44
|
+
if (auth === 'local') lines.push('log every signed-in user out');
|
|
45
|
+
lines.push(
|
|
46
|
+
'invalidate share links, invites and API tokens created before stored tokens were hashed without a key',
|
|
47
|
+
'disconnect every MCP client (each has to register and sign in again)',
|
|
48
|
+
'fail live solve-event streams of solves already running'
|
|
49
|
+
);
|
|
50
|
+
return lines;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function atRestBlastRadius() {
|
|
54
|
+
return [
|
|
55
|
+
'make the stored Rhino.Compute API keys unreadable',
|
|
56
|
+
'require re-entering each API key at /admin/compute'
|
|
57
|
+
];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The .env change for each mode, as `setEnvValues` updates. */
|
|
61
|
+
export function rotationUpdates(env, envVar, mode, fresh = generateKey(), now = Date.now()) {
|
|
62
|
+
const previousVar = `${envVar}_PREVIOUS`;
|
|
63
|
+
const expiresVar = `${previousVar}_EXPIRES`;
|
|
64
|
+
if (mode === 'finish') return { [previousVar]: undefined, [expiresVar]: undefined };
|
|
65
|
+
if (mode === 'leaked')
|
|
66
|
+
return { [envVar]: fresh, [previousVar]: undefined, [expiresVar]: undefined };
|
|
67
|
+
const expires = new Date(now + PREVIOUS_KEY_DAYS * 86_400_000).toISOString();
|
|
68
|
+
return { [envVar]: fresh, [previousVar]: env[envVar], [expiresVar]: expires };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function backupsHolding(dir, value) {
|
|
72
|
+
if (!value) return [];
|
|
73
|
+
return readdirSync(dir)
|
|
74
|
+
.filter((f) => f.startsWith('.env.') && f.endsWith('.bak'))
|
|
75
|
+
.filter((f) => readFileSync(join(dir, f), 'utf8').includes(value));
|
|
76
|
+
}
|
|
77
|
+
|
|
30
78
|
export async function runKeysRotate(argv) {
|
|
31
79
|
const target = argv[0];
|
|
80
|
+
const mode = argv.includes('--finish')
|
|
81
|
+
? 'finish'
|
|
82
|
+
: argv.includes('--leaked')
|
|
83
|
+
? 'leaked'
|
|
84
|
+
: 'rotate';
|
|
32
85
|
if (!target || !(target in TARGETS)) {
|
|
33
|
-
console.error(
|
|
86
|
+
console.error('Usage: selva keys rotate <hmac|at-rest> [--finish | --leaked]');
|
|
34
87
|
process.exit(1);
|
|
35
88
|
}
|
|
36
89
|
|
|
37
90
|
const dir = resolveDeploymentDir();
|
|
38
91
|
requireDeploymentDir(dir);
|
|
39
92
|
|
|
40
|
-
const { envVar,
|
|
93
|
+
const { envVar, leakedBlastRadius, finishNote } = TARGETS[target];
|
|
94
|
+
const previousVar = `${envVar}_PREVIOUS`;
|
|
41
95
|
const envPath = join(dir, '.env');
|
|
42
|
-
const
|
|
96
|
+
const text = existsSync(envPath) ? readFileSync(envPath, 'utf8') : '';
|
|
97
|
+
const env = parseEnv(text);
|
|
43
98
|
|
|
44
|
-
p.intro(pc.bgYellow(pc.black(` rotate ${envVar} `)));
|
|
45
|
-
|
|
99
|
+
p.intro(pc.bgYellow(pc.black(` ${mode === 'rotate' ? 'rotate' : mode} ${envVar} `)));
|
|
100
|
+
|
|
101
|
+
if (mode === 'rotate') {
|
|
102
|
+
if (env[previousVar]) {
|
|
103
|
+
p.cancel(
|
|
104
|
+
`${previousVar} is still set from the last rotation. Run \`selva keys rotate ${target} --finish\` first.`
|
|
105
|
+
);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
p.note(
|
|
109
|
+
`The current key moves to ${previousVar}, which the app keeps accepting for ${PREVIOUS_KEY_DAYS} days.\nNothing stops working now. After that it is refused on its own; \`selva keys rotate ${target} --finish\`\nremoves it from .env.`,
|
|
110
|
+
'Safe rotation'
|
|
111
|
+
);
|
|
112
|
+
} else if (mode === 'finish') {
|
|
113
|
+
if (!env[previousVar]) {
|
|
114
|
+
p.outro(pc.dim(`${previousVar} is not set: nothing to finish.`));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
p.note(finishNote, 'Before you finish');
|
|
118
|
+
} else {
|
|
119
|
+
p.note(
|
|
120
|
+
['This will:', ...leakedBlastRadius(env).map((l) => pc.red(` • ${l}`))].join('\n'),
|
|
121
|
+
'Blast radius'
|
|
122
|
+
);
|
|
123
|
+
}
|
|
46
124
|
|
|
47
|
-
const confirmed = await p.confirm({
|
|
48
|
-
message: `Rotate ${envVar}?`,
|
|
49
|
-
initialValue: false
|
|
50
|
-
});
|
|
125
|
+
const confirmed = await p.confirm({ message: 'Continue?', initialValue: mode === 'rotate' });
|
|
51
126
|
if (p.isCancel(confirmed) || !confirmed) {
|
|
52
127
|
p.cancel('Cancelled.');
|
|
53
128
|
return;
|
|
54
129
|
}
|
|
55
130
|
|
|
56
|
-
|
|
57
|
-
current[envVar] = fresh;
|
|
131
|
+
writeEnvText(envPath, setEnvValues(text, rotationUpdates(env, envVar, mode)));
|
|
58
132
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
133
|
+
const lines = [pc.green(mode === 'finish' ? `${previousVar} removed.` : `${envVar} rotated.`)];
|
|
134
|
+
if (mode !== 'rotate') {
|
|
135
|
+
const stale = backupsHolding(dir, mode === 'finish' ? env[previousVar] : env[envVar]);
|
|
136
|
+
if (stale.length > 0) {
|
|
137
|
+
lines.push(
|
|
138
|
+
pc.yellow(
|
|
139
|
+
`These backups still hold the old key: ${stale.join(', ')}. Delete them if it leaked.`
|
|
140
|
+
)
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
lines.push(pc.dim('Restart the app to apply: ') + pc.cyan('selva restart'));
|
|
145
|
+
p.outro(lines.join('\n'));
|
|
67
146
|
}
|