@selvajs/cli 4.20.0-beta.2 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selvajs/cli",
3
- "version": "4.20.0-beta.2",
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 MB) — Caddy rejects first, so a lower value here silently caps
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 256mb
52
+ max_size 256MiB
53
53
  }
54
54
 
55
55
  log {
@@ -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)) return red(`${label} — not 64 hex chars`);
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 and the deployment layout onto the current release.\nRewrites keys, never comments.'
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. Destructive and not reversible — the command\nspells out the blast radius and asks before writing.'
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
- ' keys rotate <hmac|at-rest> Rotate a secret in .env (destructive)',
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
+ }
@@ -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 { readEnvFile, parseEnv, countEnvCommentLines, stripEnvComments } from '../env.js';
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(checkSecret(env.SELVA_HMAC_KEY, 'SELVA_HMAC_KEY is a 32-byte hex string'));
59
- checks.push(checkSecret(env.SELVA_AT_REST_KEY, 'SELVA_AT_REST_KEY is a 32-byte hex string'));
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
- writeFileSync(envPath, strippedText, 'utf8');
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}`);
@@ -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 { readEnvFile, writeEnvFile } from '../env.js';
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 current = readEnvFile(envPath);
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
- // A values-only rewrite drops anything it isn't handed, and the prompts cover
46
- // only the vars they ask about — every tuned knob the operator set by hand
47
- // (BODY_SIZE_LIMIT, COMPUTE_*, LOG_LEVEL, …) lives outside that set. Carry
48
- // the existing file forward and let the prompt answers win on top.
49
- writeEnvFile(envPath, readEnvTemplate(dir), { ...current, ...values });
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
  [
@@ -1,67 +1,146 @@
1
- // Rotate SELVA_HMAC_KEY or SELVA_AT_REST_KEY; requires explicit confirm (blast radius in TARGETS).
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 { readEnvFile, writeEnvFile } from '../env.js';
12
+ import { parseEnv, setEnvValues, writeEnvText } from '../env.js';
13
+ import { resolveProviders } from '../checks/config.js';
7
14
  import { generateKey } from '../secrets.js';
8
- import { readEnvTemplate, requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
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
- warning: [
14
- 'This will:',
15
- pc.red(' • log every signed-in user out (existing session cookies stop verifying)'),
16
- pc.red(' • invalidate every share link and pending invite')
17
- ].join('\n')
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
- warning: [
22
- 'This will:',
23
- pc.red(' • make the encrypted Rhino.Compute API key undecryptable'),
24
- pc.red(' • require re-entering the API key at /admin/compute'),
25
- pc.dim(' (other data is plaintext on disk — only the compute API key is encrypted)')
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(`Usage: selva keys rotate <hmac|at-rest>`);
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, warning } = TARGETS[target];
93
+ const { envVar, leakedBlastRadius, finishNote } = TARGETS[target];
94
+ const previousVar = `${envVar}_PREVIOUS`;
41
95
  const envPath = join(dir, '.env');
42
- const current = readEnvFile(envPath);
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
- p.note(warning, 'Blast radius');
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
- const fresh = generateKey();
57
- current[envVar] = fresh;
131
+ writeEnvText(envPath, setEnvValues(text, rotationUpdates(env, envVar, mode)));
58
132
 
59
- writeEnvFile(envPath, readEnvTemplate(dir), current);
60
-
61
- p.outro(
62
- [
63
- pc.green(`${envVar} rotated.`),
64
- pc.dim('Restart the app to apply: ') + pc.cyan('selva restart')
65
- ].join('\n')
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
  }