@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
|
@@ -0,0 +1,1099 @@
|
|
|
1
|
+
// GENERATED by packages/selva/scripts/generate-cli-config.js. Do not edit.
|
|
2
|
+
import { createHash, createHmac, randomBytes } from 'node:crypto';
|
|
3
|
+
// ../platform/src/config/manifest.ts
|
|
4
|
+
function defineSetting(spec) {
|
|
5
|
+
return spec;
|
|
6
|
+
}
|
|
7
|
+
/** Flattens slices and rejects a key declared twice: two readers, two defaults. */
|
|
8
|
+
function composeManifest(parts) {
|
|
9
|
+
const out = [];
|
|
10
|
+
const seen = /* @__PURE__ */ new Set();
|
|
11
|
+
for (const part of parts)
|
|
12
|
+
for (const spec of Array.isArray(part) ? part : [part]) {
|
|
13
|
+
for (const name of [spec.key, ...(spec.aliases ?? [])]) {
|
|
14
|
+
if (seen.has(name)) throw new Error(`Config key ${name} is declared twice`);
|
|
15
|
+
seen.add(name);
|
|
16
|
+
}
|
|
17
|
+
out.push(spec);
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
// ../platform/src/config/resolve.ts
|
|
22
|
+
var TRUE = /* @__PURE__ */ new Set(['true', '1', 'yes', 'on']);
|
|
23
|
+
var FALSE = /* @__PURE__ */ new Set(['false', '0', 'no', 'off']);
|
|
24
|
+
function parse(spec, raw) {
|
|
25
|
+
const v = raw.trim();
|
|
26
|
+
switch (spec.type) {
|
|
27
|
+
case 'string':
|
|
28
|
+
if (spec.minLength !== void 0 && v.length < spec.minLength)
|
|
29
|
+
return {
|
|
30
|
+
ok: false,
|
|
31
|
+
problem: `must be at least ${spec.minLength} characters`
|
|
32
|
+
};
|
|
33
|
+
return {
|
|
34
|
+
ok: true,
|
|
35
|
+
value: v
|
|
36
|
+
};
|
|
37
|
+
case 'key':
|
|
38
|
+
return isKey32(v)
|
|
39
|
+
? {
|
|
40
|
+
ok: true,
|
|
41
|
+
value: v
|
|
42
|
+
}
|
|
43
|
+
: {
|
|
44
|
+
ok: false,
|
|
45
|
+
problem: 'must be 32 bytes as 64 hex characters or base64'
|
|
46
|
+
};
|
|
47
|
+
case 'url':
|
|
48
|
+
try {
|
|
49
|
+
new URL(v);
|
|
50
|
+
return {
|
|
51
|
+
ok: true,
|
|
52
|
+
value: v.replace(/\/+$/, '')
|
|
53
|
+
};
|
|
54
|
+
} catch {
|
|
55
|
+
return {
|
|
56
|
+
ok: false,
|
|
57
|
+
problem: 'must be an absolute URL'
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
case 'int': {
|
|
61
|
+
const n = Number(v);
|
|
62
|
+
if (!Number.isInteger(n))
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
problem: 'must be a whole number'
|
|
66
|
+
};
|
|
67
|
+
if (spec.min !== void 0 && n < spec.min)
|
|
68
|
+
return {
|
|
69
|
+
ok: false,
|
|
70
|
+
problem: `must be ≥ ${spec.min}`
|
|
71
|
+
};
|
|
72
|
+
if (spec.max !== void 0 && n > spec.max)
|
|
73
|
+
return {
|
|
74
|
+
ok: false,
|
|
75
|
+
problem: `must be ≤ ${spec.max}`
|
|
76
|
+
};
|
|
77
|
+
return {
|
|
78
|
+
ok: true,
|
|
79
|
+
value: n
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
case 'bool': {
|
|
83
|
+
const lower = v.toLowerCase();
|
|
84
|
+
if (TRUE.has(lower))
|
|
85
|
+
return {
|
|
86
|
+
ok: true,
|
|
87
|
+
value: true
|
|
88
|
+
};
|
|
89
|
+
if (FALSE.has(lower))
|
|
90
|
+
return {
|
|
91
|
+
ok: true,
|
|
92
|
+
value: false
|
|
93
|
+
};
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
problem: 'must be true or false'
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
case 'enum': {
|
|
100
|
+
const lower = v.toLowerCase();
|
|
101
|
+
const match = spec.values.find((m) => m.toLowerCase() === lower);
|
|
102
|
+
return match !== void 0
|
|
103
|
+
? {
|
|
104
|
+
ok: true,
|
|
105
|
+
value: match
|
|
106
|
+
}
|
|
107
|
+
: {
|
|
108
|
+
ok: false,
|
|
109
|
+
problem: `must be one of ${spec.values.join(' | ')}`
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
case 'list':
|
|
113
|
+
return {
|
|
114
|
+
ok: true,
|
|
115
|
+
value: v
|
|
116
|
+
.split(',')
|
|
117
|
+
.map((s) => s.trim())
|
|
118
|
+
.filter(Boolean)
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function isKey32(v) {
|
|
123
|
+
if (/^[0-9a-fA-F]{64}$/.test(v)) return true;
|
|
124
|
+
const buf = Buffer.from(v, 'base64');
|
|
125
|
+
return buf.length === 32 && buf.toString('base64').replace(/=+$/, '') === v.replace(/=+$/, '');
|
|
126
|
+
}
|
|
127
|
+
/** What `raw` meant to the reader before it parsed strictly. */
|
|
128
|
+
function legacyValue(spec, raw) {
|
|
129
|
+
const legacy = spec.legacy;
|
|
130
|
+
if (legacy && 'bool' in legacy)
|
|
131
|
+
return legacy.bool === 'exact-true' ? raw === 'true' : raw !== 'false';
|
|
132
|
+
if (legacy && 'enumFallback' in legacy && spec.type === 'enum')
|
|
133
|
+
return spec.values.includes(raw) ? raw : legacy.enumFallback;
|
|
134
|
+
const parsed = parse(spec, raw);
|
|
135
|
+
return parsed.ok ? parsed.value : void 0;
|
|
136
|
+
}
|
|
137
|
+
function resolveKey(spec, env) {
|
|
138
|
+
let raw = env[spec.key];
|
|
139
|
+
let source = 'env';
|
|
140
|
+
let alias;
|
|
141
|
+
if (raw === void 0 || raw === '') {
|
|
142
|
+
alias = spec.aliases?.find((a) => env[a] !== void 0 && env[a] !== '');
|
|
143
|
+
raw = alias ? env[alias] : void 0;
|
|
144
|
+
source = alias ? 'alias' : spec.default !== void 0 ? 'default' : 'unset';
|
|
145
|
+
}
|
|
146
|
+
const base = {
|
|
147
|
+
key: spec.key,
|
|
148
|
+
spec,
|
|
149
|
+
...(alias ? { alias } : {})
|
|
150
|
+
};
|
|
151
|
+
if (raw === void 0) {
|
|
152
|
+
if (spec.required)
|
|
153
|
+
return {
|
|
154
|
+
...base,
|
|
155
|
+
source,
|
|
156
|
+
status: 'error',
|
|
157
|
+
value: void 0,
|
|
158
|
+
problem: 'is required'
|
|
159
|
+
};
|
|
160
|
+
return {
|
|
161
|
+
...base,
|
|
162
|
+
source,
|
|
163
|
+
status: 'ok',
|
|
164
|
+
value: spec.default
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
const parsed = parse(spec, raw);
|
|
168
|
+
if (parsed.ok)
|
|
169
|
+
return {
|
|
170
|
+
...base,
|
|
171
|
+
source,
|
|
172
|
+
status: 'ok',
|
|
173
|
+
value: parsed.value
|
|
174
|
+
};
|
|
175
|
+
if (spec.onInvalid === 'fail')
|
|
176
|
+
return {
|
|
177
|
+
...base,
|
|
178
|
+
source,
|
|
179
|
+
status: 'error',
|
|
180
|
+
value: void 0,
|
|
181
|
+
problem: parsed.problem
|
|
182
|
+
};
|
|
183
|
+
return {
|
|
184
|
+
...base,
|
|
185
|
+
source,
|
|
186
|
+
status: 'invalid',
|
|
187
|
+
value: spec.default,
|
|
188
|
+
problem: parsed.problem
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
/** Resolve every declared key from an env map. Pure: reads nothing ambient. */
|
|
192
|
+
function resolveConfig(manifest, env, options = {}) {
|
|
193
|
+
const values = {};
|
|
194
|
+
const keys = {};
|
|
195
|
+
const errors = [];
|
|
196
|
+
const known = new Set(options.external ?? []);
|
|
197
|
+
for (const spec of manifest) {
|
|
198
|
+
known.add(spec.key);
|
|
199
|
+
for (const a of spec.aliases ?? []) known.add(a);
|
|
200
|
+
const report = resolveKey(spec, env);
|
|
201
|
+
keys[spec.key] = report;
|
|
202
|
+
values[spec.key] = report.value;
|
|
203
|
+
if (report.status === 'error') errors.push(report);
|
|
204
|
+
}
|
|
205
|
+
const declared = manifest.map((s) => s.key);
|
|
206
|
+
const prefixes = options.prefixes ?? [];
|
|
207
|
+
const unknown = [];
|
|
208
|
+
for (const key of Object.keys(env)) {
|
|
209
|
+
if (known.has(key) || !prefixes.some((p) => key.startsWith(p))) continue;
|
|
210
|
+
const suggestion = nearest(key, declared);
|
|
211
|
+
unknown.push(
|
|
212
|
+
suggestion
|
|
213
|
+
? {
|
|
214
|
+
key,
|
|
215
|
+
suggestion
|
|
216
|
+
}
|
|
217
|
+
: { key }
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
values,
|
|
222
|
+
keys,
|
|
223
|
+
errors,
|
|
224
|
+
unknown
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
/** A declared name within a typo's reach: a prefix of it, or a few edits away. */
|
|
228
|
+
function nearest(key, declared) {
|
|
229
|
+
let best;
|
|
230
|
+
let bestDistance = Infinity;
|
|
231
|
+
for (const candidate of declared) {
|
|
232
|
+
const d =
|
|
233
|
+
candidate.startsWith(key) || key.startsWith(candidate) ? 0 : editDistance(key, candidate);
|
|
234
|
+
if (d < bestDistance) {
|
|
235
|
+
best = candidate;
|
|
236
|
+
bestDistance = d;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return bestDistance <= 3 ? best : void 0;
|
|
240
|
+
}
|
|
241
|
+
function editDistance(a, b) {
|
|
242
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
243
|
+
for (let i = 1; i <= a.length; i++) {
|
|
244
|
+
const row = [i];
|
|
245
|
+
for (let j = 1; j <= b.length; j++)
|
|
246
|
+
row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
247
|
+
prev = row;
|
|
248
|
+
}
|
|
249
|
+
return prev[b.length];
|
|
250
|
+
}
|
|
251
|
+
// ../platform/src/config/report.ts
|
|
252
|
+
/**
|
|
253
|
+
* What `selva config show` and the admin page render. Pure, and free of
|
|
254
|
+
* secret values, so the result can go to a browser or a terminal as is.
|
|
255
|
+
*/
|
|
256
|
+
function buildConfigReport(manifest, env, options = {}) {
|
|
257
|
+
const resolved = resolveConfig(manifest, env, options);
|
|
258
|
+
const rows = manifest.map((spec) => {
|
|
259
|
+
const k = resolved.keys[spec.key];
|
|
260
|
+
const raw = k.source === 'alias' ? env[k.alias] : env[spec.key];
|
|
261
|
+
return {
|
|
262
|
+
key: spec.key,
|
|
263
|
+
class: spec.class,
|
|
264
|
+
summary: spec.summary === true,
|
|
265
|
+
description: spec.description,
|
|
266
|
+
...(spec.impact ? { impact: spec.impact } : {}),
|
|
267
|
+
source: k.source,
|
|
268
|
+
status: k.status,
|
|
269
|
+
display: display(spec.sensitive === true, spec.generated === true, k.source, raw, k.value),
|
|
270
|
+
...(k.problem ? { problem: k.problem } : {}),
|
|
271
|
+
...(k.alias ? { alias: k.alias } : {})
|
|
272
|
+
};
|
|
273
|
+
});
|
|
274
|
+
return {
|
|
275
|
+
rows: [...rows.filter((r) => r.summary), ...rows.filter((r) => !r.summary)],
|
|
276
|
+
problems: rows.filter((r) => r.status !== 'ok'),
|
|
277
|
+
unknown: resolved.unknown
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
function display(sensitive, generated, source, raw, value) {
|
|
281
|
+
if (source === 'unset') return '';
|
|
282
|
+
if (sensitive && raw !== void 0) return generated ? fingerprint(raw) : 'set';
|
|
283
|
+
if (sensitive) return 'set';
|
|
284
|
+
if (source === 'default' || raw === void 0) return formatValue(value);
|
|
285
|
+
return raw.trim();
|
|
286
|
+
}
|
|
287
|
+
function formatValue(value) {
|
|
288
|
+
if (Array.isArray(value)) return value.join(',');
|
|
289
|
+
return value === void 0 ? '' : String(value);
|
|
290
|
+
}
|
|
291
|
+
/** Short, stable id for a generated key, so two machines can be compared. */
|
|
292
|
+
function fingerprint(value) {
|
|
293
|
+
return `sha256:${createHash('sha256').update(value.trim()).digest('hex').slice(0, 8)}`;
|
|
294
|
+
}
|
|
295
|
+
// ../platform/src/config/snapshot.ts
|
|
296
|
+
function takeSnapshot(manifest, env, salt = randomBytes(16).toString('hex')) {
|
|
297
|
+
const values = {};
|
|
298
|
+
const sensitive = [];
|
|
299
|
+
for (const spec of manifest) {
|
|
300
|
+
const name = [spec.key, ...(spec.aliases ?? [])].find(
|
|
301
|
+
(k) => env[k] !== void 0 && env[k] !== ''
|
|
302
|
+
);
|
|
303
|
+
if (name === void 0) continue;
|
|
304
|
+
const raw = env[name].trim();
|
|
305
|
+
if (spec.sensitive) {
|
|
306
|
+
values[spec.key] = createHmac('sha256', salt).update(raw).digest('hex').slice(0, 16);
|
|
307
|
+
sensitive.push(spec.key);
|
|
308
|
+
} else values[spec.key] = raw;
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
version: 1,
|
|
312
|
+
salt,
|
|
313
|
+
values,
|
|
314
|
+
sensitive
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
/** Only meaningful when both snapshots share a salt; pass `before.salt` when taking `after`. */
|
|
318
|
+
function diffSnapshots(before, after) {
|
|
319
|
+
const secret = /* @__PURE__ */ new Set([...before.sensitive, ...after.sensitive]);
|
|
320
|
+
const changes = [];
|
|
321
|
+
const plain = (key, from, to) =>
|
|
322
|
+
secret.has(key)
|
|
323
|
+
? {}
|
|
324
|
+
: {
|
|
325
|
+
...(from !== void 0 ? { from } : {}),
|
|
326
|
+
...(to !== void 0 ? { to } : {})
|
|
327
|
+
};
|
|
328
|
+
for (const [key, to] of Object.entries(after.values)) {
|
|
329
|
+
const from = before.values[key];
|
|
330
|
+
if (from === void 0)
|
|
331
|
+
changes.push({
|
|
332
|
+
key,
|
|
333
|
+
change: 'added',
|
|
334
|
+
...plain(key, void 0, to)
|
|
335
|
+
});
|
|
336
|
+
else if (from !== to)
|
|
337
|
+
changes.push({
|
|
338
|
+
key,
|
|
339
|
+
change: 'changed',
|
|
340
|
+
...plain(key, from, to)
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
for (const [key, from] of Object.entries(before.values))
|
|
344
|
+
if (!(key in after.values))
|
|
345
|
+
changes.push({
|
|
346
|
+
key,
|
|
347
|
+
change: 'removed',
|
|
348
|
+
...plain(key, from)
|
|
349
|
+
});
|
|
350
|
+
return changes;
|
|
351
|
+
}
|
|
352
|
+
// ../platform/src/config/previous-key.ts
|
|
353
|
+
/** How long `selva keys rotate` keeps the previous key accepted. */
|
|
354
|
+
var PREVIOUS_KEY_DAYS = 30;
|
|
355
|
+
var DAY_MS = 864e5;
|
|
356
|
+
/**
|
|
357
|
+
* The previous key for `base` (`SELVA_HMAC_KEY` or `SELVA_AT_REST_KEY`), if it
|
|
358
|
+
* is still within its deadline. A rotation is meant to bound how long a key
|
|
359
|
+
* that leaked unnoticed stays useful, so the overlap ends on its own.
|
|
360
|
+
*/
|
|
361
|
+
function previousKey(env, base, now = Date.now()) {
|
|
362
|
+
const key = env[`${base}_PREVIOUS`]?.trim();
|
|
363
|
+
if (!key) return { state: 'none' };
|
|
364
|
+
const expires = env[`${base}_PREVIOUS_EXPIRES`]?.trim();
|
|
365
|
+
if (!expires)
|
|
366
|
+
return {
|
|
367
|
+
state: 'no-deadline',
|
|
368
|
+
key
|
|
369
|
+
};
|
|
370
|
+
const at = Date.parse(expires);
|
|
371
|
+
if (!Number.isFinite(at) || at <= now)
|
|
372
|
+
return {
|
|
373
|
+
state: 'expired',
|
|
374
|
+
expires
|
|
375
|
+
};
|
|
376
|
+
return {
|
|
377
|
+
state: 'active',
|
|
378
|
+
key,
|
|
379
|
+
expires,
|
|
380
|
+
daysLeft: Math.ceil((at - now) / DAY_MS)
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
// ../platform/src/logging/interface.ts
|
|
384
|
+
/**
|
|
385
|
+
* Default `ILogger` — discards everything. Lets library code log
|
|
386
|
+
* unconditionally without a configured backend, and keeps test output clean.
|
|
387
|
+
* The app wires a real logger at its composition root; a consumer that wires
|
|
388
|
+
* nothing gets silence rather than unsolicited stdout writes.
|
|
389
|
+
*/
|
|
390
|
+
var NoopLogger = class {
|
|
391
|
+
debug(_message, _fields) {}
|
|
392
|
+
info(_message, _fields) {}
|
|
393
|
+
warn(_message, _fields) {}
|
|
394
|
+
error(_message, _fields) {}
|
|
395
|
+
child(_fields) {
|
|
396
|
+
return this;
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
new NoopLogger();
|
|
400
|
+
// ../server/src/api/rate-limit.ts
|
|
401
|
+
var DEFAULT_API_RATE_LIMIT = Object.freeze({
|
|
402
|
+
windowMs: 6e4,
|
|
403
|
+
maxPerWindow: 600
|
|
404
|
+
});
|
|
405
|
+
Object.freeze({
|
|
406
|
+
windowMs: 36e5,
|
|
407
|
+
maxPerWindow: 20
|
|
408
|
+
});
|
|
409
|
+
// ../server/src/config/settings.ts
|
|
410
|
+
var flag = (key, description, impact) =>
|
|
411
|
+
defineSetting({
|
|
412
|
+
key: `SELVA_FLAG_${key}`,
|
|
413
|
+
type: 'bool',
|
|
414
|
+
default: false,
|
|
415
|
+
onInvalid: 'fail',
|
|
416
|
+
class: 'setting',
|
|
417
|
+
description,
|
|
418
|
+
...(impact ? { impact } : {})
|
|
419
|
+
});
|
|
420
|
+
var tunableInt = (key, fallback, description, extra = {}) =>
|
|
421
|
+
defineSetting({
|
|
422
|
+
key,
|
|
423
|
+
type: 'int',
|
|
424
|
+
min: 1,
|
|
425
|
+
default: fallback,
|
|
426
|
+
onInvalid: 'default',
|
|
427
|
+
class: 'tunable',
|
|
428
|
+
description,
|
|
429
|
+
...extra
|
|
430
|
+
});
|
|
431
|
+
var MB = 1048576;
|
|
432
|
+
var ENGINE_SETTINGS = composeManifest([
|
|
433
|
+
...['AUTH', 'DATA', 'STORAGE'].map((slot) =>
|
|
434
|
+
defineSetting({
|
|
435
|
+
key: `SELVA_${slot}_PROVIDER`,
|
|
436
|
+
type: 'string',
|
|
437
|
+
default: 'local',
|
|
438
|
+
onInvalid: 'fail',
|
|
439
|
+
class: 'bootstrap',
|
|
440
|
+
summary: true,
|
|
441
|
+
description: `${slot.toLowerCase()} provider: local, supabase${slot === 'AUTH' ? ' or header' : ''}.`,
|
|
442
|
+
impact:
|
|
443
|
+
'Switching it does not move any data: the instance starts on what the new provider holds.'
|
|
444
|
+
})
|
|
445
|
+
),
|
|
446
|
+
defineSetting({
|
|
447
|
+
key: 'SELVA_TENANCY',
|
|
448
|
+
type: 'enum',
|
|
449
|
+
values: ['single', 'multi'],
|
|
450
|
+
default: 'single',
|
|
451
|
+
onInvalid: 'fail',
|
|
452
|
+
class: 'bootstrap',
|
|
453
|
+
summary: true,
|
|
454
|
+
description: 'single: one org per deployment. multi: users create their own orgs.'
|
|
455
|
+
}),
|
|
456
|
+
defineSetting({
|
|
457
|
+
key: 'SELVA_CONFIG_PATH',
|
|
458
|
+
type: 'string',
|
|
459
|
+
onInvalid: 'fail',
|
|
460
|
+
class: 'bootstrap',
|
|
461
|
+
description: 'A selva.config.js that replaces env-driven provider wiring.'
|
|
462
|
+
}),
|
|
463
|
+
defineSetting({
|
|
464
|
+
key: 'SELVA_HMAC_KEY',
|
|
465
|
+
type: 'string',
|
|
466
|
+
minLength: 32,
|
|
467
|
+
required: true,
|
|
468
|
+
onInvalid: 'fail',
|
|
469
|
+
class: 'secret',
|
|
470
|
+
sensitive: true,
|
|
471
|
+
generated: true,
|
|
472
|
+
summary: true,
|
|
473
|
+
description: 'Signs sessions (local auth), OAuth codes, MCP client ids and solve-event tokens.',
|
|
474
|
+
impact:
|
|
475
|
+
'Changing it without SELVA_HMAC_KEY_PREVIOUS breaks what it signed: run `selva keys rotate hmac`.'
|
|
476
|
+
}),
|
|
477
|
+
defineSetting({
|
|
478
|
+
key: 'SELVA_AT_REST_KEY',
|
|
479
|
+
type: 'key',
|
|
480
|
+
required: true,
|
|
481
|
+
onInvalid: 'fail',
|
|
482
|
+
class: 'secret',
|
|
483
|
+
sensitive: true,
|
|
484
|
+
generated: true,
|
|
485
|
+
summary: true,
|
|
486
|
+
description: 'Encrypts stored Rhino.Compute API keys.',
|
|
487
|
+
impact:
|
|
488
|
+
'Changing it without SELVA_AT_REST_KEY_PREVIOUS makes stored API keys unreadable: run `selva keys rotate at-rest`.'
|
|
489
|
+
}),
|
|
490
|
+
defineSetting({
|
|
491
|
+
key: 'SELVA_HMAC_KEY_PREVIOUS',
|
|
492
|
+
type: 'string',
|
|
493
|
+
minLength: 32,
|
|
494
|
+
onInvalid: 'fail',
|
|
495
|
+
class: 'secret',
|
|
496
|
+
sensitive: true,
|
|
497
|
+
generated: true,
|
|
498
|
+
summary: true,
|
|
499
|
+
description: 'The HMAC key before the last rotation. Still accepted for what it signed.',
|
|
500
|
+
impact: 'Removing it ends sessions, MCP clients and links signed before the rotation.'
|
|
501
|
+
}),
|
|
502
|
+
defineSetting({
|
|
503
|
+
key: 'SELVA_AT_REST_KEY_PREVIOUS',
|
|
504
|
+
type: 'key',
|
|
505
|
+
onInvalid: 'fail',
|
|
506
|
+
class: 'secret',
|
|
507
|
+
sensitive: true,
|
|
508
|
+
generated: true,
|
|
509
|
+
summary: true,
|
|
510
|
+
description:
|
|
511
|
+
'The encryption key before the last rotation. Stored API keys are re-sealed at boot.',
|
|
512
|
+
impact: 'Remove it only once /admin/system shows no API key on the previous key.'
|
|
513
|
+
}),
|
|
514
|
+
defineSetting({
|
|
515
|
+
key: 'SELVA_HMAC_KEY_PREVIOUS_EXPIRES',
|
|
516
|
+
type: 'string',
|
|
517
|
+
onInvalid: 'fail',
|
|
518
|
+
class: 'secret',
|
|
519
|
+
description:
|
|
520
|
+
'When SELVA_HMAC_KEY_PREVIOUS stops being accepted (ISO date). Set by `selva keys rotate`.',
|
|
521
|
+
impact:
|
|
522
|
+
'After it, anything only the previous key opens is refused. An unreadable date counts as passed.'
|
|
523
|
+
}),
|
|
524
|
+
defineSetting({
|
|
525
|
+
key: 'SELVA_AT_REST_KEY_PREVIOUS_EXPIRES',
|
|
526
|
+
type: 'string',
|
|
527
|
+
onInvalid: 'fail',
|
|
528
|
+
class: 'secret',
|
|
529
|
+
description:
|
|
530
|
+
'When SELVA_AT_REST_KEY_PREVIOUS stops being accepted (ISO date). Set by `selva keys rotate`.',
|
|
531
|
+
impact:
|
|
532
|
+
'After it, anything only the previous key opens is refused. An unreadable date counts as passed.'
|
|
533
|
+
}),
|
|
534
|
+
defineSetting({
|
|
535
|
+
key: 'BOOTSTRAP_INSTANCE_ADMIN_EMAIL',
|
|
536
|
+
type: 'string',
|
|
537
|
+
onInvalid: 'fail',
|
|
538
|
+
class: 'bootstrap',
|
|
539
|
+
sensitive: true,
|
|
540
|
+
summary: true,
|
|
541
|
+
description:
|
|
542
|
+
'Only this address may claim instance admin while none exists. Break-glass recovery.'
|
|
543
|
+
}),
|
|
544
|
+
defineSetting({
|
|
545
|
+
key: 'SIGN_IN_MAX_AGE_DAYS',
|
|
546
|
+
type: 'int',
|
|
547
|
+
min: 0,
|
|
548
|
+
default: 0,
|
|
549
|
+
onInvalid: 'fail',
|
|
550
|
+
class: 'setting',
|
|
551
|
+
summary: true,
|
|
552
|
+
description: 'How long one sign-in at the identity provider lasts. 0 is off.',
|
|
553
|
+
impact: 'API tokens of anyone who has not signed in within this many days are revoked.'
|
|
554
|
+
}),
|
|
555
|
+
flag('ALLOW_ORG_CREATION', 'Self-service org creation (read by host apps).'),
|
|
556
|
+
flag(
|
|
557
|
+
'ALLOW_CROSS_ORG_PUBLIC',
|
|
558
|
+
'Public projects are visible to every signed-in user, not just the publishing org.',
|
|
559
|
+
'Turning it on widens who sees existing public projects.'
|
|
560
|
+
),
|
|
561
|
+
flag('ALLOW_ORG_COMPUTE_OVERRIDE', 'Orgs may configure their own Rhino.Compute server.'),
|
|
562
|
+
flag(
|
|
563
|
+
'ENABLE_SHARING',
|
|
564
|
+
'Per-definition share links for anonymous access.',
|
|
565
|
+
'Turning it off stops every existing share link from resolving.'
|
|
566
|
+
),
|
|
567
|
+
flag(
|
|
568
|
+
'ENABLE_PLATFORM_PROJECTS',
|
|
569
|
+
'Projects owned by instance admins and granted to orgs or users.'
|
|
570
|
+
),
|
|
571
|
+
...[
|
|
572
|
+
['NAME', 'Product name in the header and titles.'],
|
|
573
|
+
['COPYRIGHT_NAME', 'Footer copyright owner. Defaults to the brand name.'],
|
|
574
|
+
['TAGLINE', 'Subtitle on the landing page.'],
|
|
575
|
+
['DESCRIPTION', 'Meta description for search and link previews.']
|
|
576
|
+
].map(([key, description]) =>
|
|
577
|
+
defineSetting({
|
|
578
|
+
key: `SELVA_BRAND_${key}`,
|
|
579
|
+
type: 'string',
|
|
580
|
+
onInvalid: 'fail',
|
|
581
|
+
class: 'setting',
|
|
582
|
+
description
|
|
583
|
+
})
|
|
584
|
+
),
|
|
585
|
+
defineSetting({
|
|
586
|
+
key: 'SMTP_HOST',
|
|
587
|
+
type: 'string',
|
|
588
|
+
onInvalid: 'fail',
|
|
589
|
+
class: 'setting',
|
|
590
|
+
description: 'SMTP server. Unset: invites return a link to send by hand.'
|
|
591
|
+
}),
|
|
592
|
+
defineSetting({
|
|
593
|
+
key: 'SMTP_PORT',
|
|
594
|
+
type: 'int',
|
|
595
|
+
min: 1,
|
|
596
|
+
max: 65535,
|
|
597
|
+
default: 587,
|
|
598
|
+
onInvalid: 'fail',
|
|
599
|
+
class: 'setting',
|
|
600
|
+
description: '587 for STARTTLS, 465 for implicit TLS.'
|
|
601
|
+
}),
|
|
602
|
+
defineSetting({
|
|
603
|
+
key: 'SMTP_USER',
|
|
604
|
+
type: 'string',
|
|
605
|
+
onInvalid: 'fail',
|
|
606
|
+
class: 'setting',
|
|
607
|
+
sensitive: true,
|
|
608
|
+
description: 'SMTP login.'
|
|
609
|
+
}),
|
|
610
|
+
defineSetting({
|
|
611
|
+
key: 'SMTP_PASS',
|
|
612
|
+
type: 'string',
|
|
613
|
+
onInvalid: 'fail',
|
|
614
|
+
class: 'secret',
|
|
615
|
+
sensitive: true,
|
|
616
|
+
description: 'SMTP password.'
|
|
617
|
+
}),
|
|
618
|
+
defineSetting({
|
|
619
|
+
key: 'SMTP_FROM',
|
|
620
|
+
type: 'string',
|
|
621
|
+
onInvalid: 'fail',
|
|
622
|
+
class: 'setting',
|
|
623
|
+
sensitive: true,
|
|
624
|
+
description: 'Sender address. Required once SMTP_HOST is set.'
|
|
625
|
+
}),
|
|
626
|
+
defineSetting({
|
|
627
|
+
key: 'SMTP_SECURE',
|
|
628
|
+
type: 'bool',
|
|
629
|
+
onInvalid: 'fail',
|
|
630
|
+
class: 'tunable',
|
|
631
|
+
description: 'Override the TLS mode inferred from the port.',
|
|
632
|
+
legacy: { bool: 'exact-true' }
|
|
633
|
+
}),
|
|
634
|
+
tunableInt('COMPUTE_SOLVE_DEADLINE_MS', 1e5, 'Longest one solve may run.', {
|
|
635
|
+
aliases: ['MAX_SOLVE_DURATION_MS'],
|
|
636
|
+
impact: 'Past the reverse proxy read timeout, solves fail there with a 502 instead.'
|
|
637
|
+
}),
|
|
638
|
+
tunableInt('COMPUTE_RATE_LIMIT_WINDOW_MS', 1e5, 'Compute rate-limit window.'),
|
|
639
|
+
tunableInt('COMPUTE_RATE_LIMIT_MAX', 120, 'Solves per user or share link per window.'),
|
|
640
|
+
tunableInt('MAX_DEFINITION_FILE_SIZE_BYTES', 50 * MB, 'Largest .gh upload.'),
|
|
641
|
+
tunableInt('MAX_IMAGE_FILE_SIZE_BYTES', 10 * MB, 'Largest cover image upload.'),
|
|
642
|
+
tunableInt('COMPUTE_REQUEST_MAX_BYTES', 256 * MB, 'Largest solve request body.', {
|
|
643
|
+
impact: 'Must stay at or below BODY_SIZE_LIMIT, or that limit rejects first.'
|
|
644
|
+
}),
|
|
645
|
+
tunableInt('COMPUTE_RESPONSE_MAX_BYTES', 300 * MB, 'Largest solve response body.'),
|
|
646
|
+
tunableInt('REMOTE_DEFINITION_FETCH_TIMEOUT_MS', 3e4, 'Deadline for fetching a remote .gh.'),
|
|
647
|
+
tunableInt('REMOTE_DEFINITION_CACHE_TTL_MS', 3e5, 'How long remote .gh bytes stay cached.', {
|
|
648
|
+
aliases: ['DEFINITION_CACHE_TTL_MS']
|
|
649
|
+
}),
|
|
650
|
+
tunableInt(
|
|
651
|
+
'COMPUTE_MAX_QUEUE_DEPTH',
|
|
652
|
+
0,
|
|
653
|
+
'Solves allowed to wait per compute server. 0 is unbounded.',
|
|
654
|
+
{ min: 0 }
|
|
655
|
+
),
|
|
656
|
+
tunableInt('COMPUTE_QUEUE_WAIT_MS', 0, 'Longest a solve may wait queued. 0 is no deadline.', {
|
|
657
|
+
min: 0
|
|
658
|
+
}),
|
|
659
|
+
tunableInt('COMPUTE_DEFINITION_CACHE_MB', 256, 'Definition cache budget. 0 turns it off.', {
|
|
660
|
+
min: 0,
|
|
661
|
+
aliases: ['COMPUTE_DEFINITION_BYTE_CACHE_MB']
|
|
662
|
+
}),
|
|
663
|
+
tunableInt(
|
|
664
|
+
'COMPUTE_SOLVE_CACHE_MB',
|
|
665
|
+
256,
|
|
666
|
+
'Solve cache budget per warm compute server. 0 turns it off.',
|
|
667
|
+
{
|
|
668
|
+
min: 0,
|
|
669
|
+
aliases: ['COMPUTE_RESPONSE_CACHE_MB']
|
|
670
|
+
}
|
|
671
|
+
),
|
|
672
|
+
defineSetting({
|
|
673
|
+
key: 'COMPUTE_REUSE_DEFINITION_CACHE',
|
|
674
|
+
type: 'bool',
|
|
675
|
+
default: true,
|
|
676
|
+
onInvalid: 'default',
|
|
677
|
+
class: 'tunable',
|
|
678
|
+
description: 'Reuse the compute server’s cached definition instead of re-uploading it.',
|
|
679
|
+
impact:
|
|
680
|
+
'Leave on only with a compute server that reports a stale pointer (the VektorNode fork).'
|
|
681
|
+
}),
|
|
682
|
+
defineSetting({
|
|
683
|
+
key: 'COMPUTE_SERVER_CACHESOLVE',
|
|
684
|
+
type: 'bool',
|
|
685
|
+
default: true,
|
|
686
|
+
onInvalid: 'default',
|
|
687
|
+
class: 'tunable',
|
|
688
|
+
description: 'Ask Rhino.Compute to cache solve results server-side.'
|
|
689
|
+
}),
|
|
690
|
+
tunableInt(
|
|
691
|
+
'API_TOKEN_RATE_LIMIT_WINDOW_MS',
|
|
692
|
+
DEFAULT_API_RATE_LIMIT.windowMs,
|
|
693
|
+
'API token rate-limit window.'
|
|
694
|
+
),
|
|
695
|
+
tunableInt(
|
|
696
|
+
'API_TOKEN_RATE_LIMIT_MAX',
|
|
697
|
+
DEFAULT_API_RATE_LIMIT.maxPerWindow,
|
|
698
|
+
'Requests per API token per window. 0 is off.',
|
|
699
|
+
{ min: 0 }
|
|
700
|
+
)
|
|
701
|
+
]);
|
|
702
|
+
// ../providers/header-auth/src/config.ts
|
|
703
|
+
var header = (key, fallback, slot) =>
|
|
704
|
+
defineSetting({
|
|
705
|
+
key,
|
|
706
|
+
type: 'string',
|
|
707
|
+
default: fallback,
|
|
708
|
+
onInvalid: 'fail',
|
|
709
|
+
class: 'bootstrap',
|
|
710
|
+
description: `Header the proxy puts the user's ${slot} in.`,
|
|
711
|
+
impact: 'A name the proxy never sends signs nobody in. Override all three or none.'
|
|
712
|
+
});
|
|
713
|
+
var HEADER_AUTH_SETTINGS = composeManifest([
|
|
714
|
+
defineSetting({
|
|
715
|
+
key: 'HEADER_AUTH_DATA_DIR',
|
|
716
|
+
type: 'string',
|
|
717
|
+
onInvalid: 'fail',
|
|
718
|
+
class: 'bootstrap',
|
|
719
|
+
description: 'Directory holding header-allowlist.json. Falls back to DATA_PATH.'
|
|
720
|
+
}),
|
|
721
|
+
header('HEADER_AUTH_UPN_HEADER', 'SELVA-UserPrincipalName', 'UPN'),
|
|
722
|
+
header('HEADER_AUTH_EMAIL_HEADER', 'SELVA-Email', 'email'),
|
|
723
|
+
header('HEADER_AUTH_DISPLAY_NAME_HEADER', 'SELVA-DisplayName', 'display name')
|
|
724
|
+
]);
|
|
725
|
+
// ../providers/local/src/config.ts
|
|
726
|
+
var LOCAL_SETTINGS = composeManifest([
|
|
727
|
+
defineSetting({
|
|
728
|
+
key: 'DATA_PATH',
|
|
729
|
+
type: 'string',
|
|
730
|
+
required: true,
|
|
731
|
+
onInvalid: 'fail',
|
|
732
|
+
class: 'bootstrap',
|
|
733
|
+
summary: true,
|
|
734
|
+
description: 'Directory holding users, orgs, projects and uploaded files.',
|
|
735
|
+
impact: 'Pointing it elsewhere starts an empty instance; the old data stays where it was.'
|
|
736
|
+
})
|
|
737
|
+
]);
|
|
738
|
+
// ../providers/supabase/src/config.ts
|
|
739
|
+
var SUPABASE_SETTINGS = composeManifest([
|
|
740
|
+
defineSetting({
|
|
741
|
+
key: 'SUPABASE_URL',
|
|
742
|
+
type: 'url',
|
|
743
|
+
required: true,
|
|
744
|
+
onInvalid: 'fail',
|
|
745
|
+
class: 'bootstrap',
|
|
746
|
+
summary: true,
|
|
747
|
+
description: 'Supabase project URL.',
|
|
748
|
+
impact: 'Pointing it at another project shows that project’s users and data.'
|
|
749
|
+
}),
|
|
750
|
+
defineSetting({
|
|
751
|
+
key: 'SUPABASE_ANON_KEY',
|
|
752
|
+
type: 'string',
|
|
753
|
+
required: true,
|
|
754
|
+
onInvalid: 'fail',
|
|
755
|
+
class: 'secret',
|
|
756
|
+
description: 'Publishable key (sb_publishable_…).'
|
|
757
|
+
}),
|
|
758
|
+
defineSetting({
|
|
759
|
+
key: 'SUPABASE_SERVICE_ROLE_KEY',
|
|
760
|
+
type: 'string',
|
|
761
|
+
required: true,
|
|
762
|
+
onInvalid: 'fail',
|
|
763
|
+
class: 'secret',
|
|
764
|
+
sensitive: true,
|
|
765
|
+
description: 'Secret key (sb_secret_…). Bypasses row security: server only.'
|
|
766
|
+
}),
|
|
767
|
+
defineSetting({
|
|
768
|
+
key: 'SUPABASE_JWT_SIGNING_KEY',
|
|
769
|
+
type: 'string',
|
|
770
|
+
onInvalid: 'fail',
|
|
771
|
+
class: 'secret',
|
|
772
|
+
sensitive: true,
|
|
773
|
+
description: 'Private ES256 JWK, one line. Needed for API tokens.',
|
|
774
|
+
impact: 'Without it, or SUPABASE_JWT_SECRET, API tokens answer 503.'
|
|
775
|
+
}),
|
|
776
|
+
defineSetting({
|
|
777
|
+
key: 'SUPABASE_JWT_SECRET',
|
|
778
|
+
type: 'string',
|
|
779
|
+
onInvalid: 'fail',
|
|
780
|
+
class: 'secret',
|
|
781
|
+
sensitive: true,
|
|
782
|
+
description: 'Legacy HS256 JWT secret. Used only without SUPABASE_JWT_SIGNING_KEY.'
|
|
783
|
+
}),
|
|
784
|
+
defineSetting({
|
|
785
|
+
key: 'SUPABASE_ENABLE_SELF_SIGNUP',
|
|
786
|
+
type: 'bool',
|
|
787
|
+
default: false,
|
|
788
|
+
onInvalid: 'fail',
|
|
789
|
+
class: 'setting',
|
|
790
|
+
description: 'Allow self-service sign-up at /signup.',
|
|
791
|
+
legacy: { bool: 'exact-true' }
|
|
792
|
+
}),
|
|
793
|
+
defineSetting({
|
|
794
|
+
key: 'SUPABASE_ALLOW_EMAIL_LINK_SIGNUP',
|
|
795
|
+
type: 'bool',
|
|
796
|
+
default: true,
|
|
797
|
+
onInvalid: 'fail',
|
|
798
|
+
class: 'setting',
|
|
799
|
+
description: 'A magic link to an unknown address creates an account.',
|
|
800
|
+
legacy: { bool: 'not-false' }
|
|
801
|
+
}),
|
|
802
|
+
defineSetting({
|
|
803
|
+
key: 'SUPABASE_OAUTH_PROVIDERS',
|
|
804
|
+
type: 'list',
|
|
805
|
+
default: [],
|
|
806
|
+
onInvalid: 'fail',
|
|
807
|
+
class: 'setting',
|
|
808
|
+
description: 'OAuth buttons on the login page (google, github, azure, gitlab).'
|
|
809
|
+
}),
|
|
810
|
+
defineSetting({
|
|
811
|
+
key: 'SUPABASE_TOKEN_VERIFICATION',
|
|
812
|
+
type: 'enum',
|
|
813
|
+
values: ['hybrid', 'strict'],
|
|
814
|
+
default: 'hybrid',
|
|
815
|
+
onInvalid: 'fail',
|
|
816
|
+
class: 'tunable',
|
|
817
|
+
description:
|
|
818
|
+
'hybrid verifies JWTs locally and rechecks periodically; strict asks Supabase every request.',
|
|
819
|
+
legacy: { enumFallback: 'hybrid' }
|
|
820
|
+
}),
|
|
821
|
+
defineSetting({
|
|
822
|
+
key: 'SUPABASE_REVALIDATE_MS',
|
|
823
|
+
type: 'int',
|
|
824
|
+
min: 1,
|
|
825
|
+
default: 6e4,
|
|
826
|
+
onInvalid: 'default',
|
|
827
|
+
class: 'tunable',
|
|
828
|
+
description: 'Hybrid recheck window: the longest a revoked session keeps working.'
|
|
829
|
+
}),
|
|
830
|
+
defineSetting({
|
|
831
|
+
key: 'SUPABASE_PUBLIC_BUCKET',
|
|
832
|
+
type: 'string',
|
|
833
|
+
default: 'selva-public',
|
|
834
|
+
onInvalid: 'fail',
|
|
835
|
+
class: 'tunable',
|
|
836
|
+
description: 'Storage bucket for public files.'
|
|
837
|
+
}),
|
|
838
|
+
defineSetting({
|
|
839
|
+
key: 'SUPABASE_PRIVATE_BUCKET',
|
|
840
|
+
type: 'string',
|
|
841
|
+
default: 'selva-private',
|
|
842
|
+
onInvalid: 'fail',
|
|
843
|
+
class: 'tunable',
|
|
844
|
+
description: 'Storage bucket for private files.'
|
|
845
|
+
}),
|
|
846
|
+
defineSetting({
|
|
847
|
+
key: 'SUPABASE_PRIVATE_URL_PREFIX',
|
|
848
|
+
type: 'string',
|
|
849
|
+
default: '/api/files',
|
|
850
|
+
onInvalid: 'fail',
|
|
851
|
+
class: 'tunable',
|
|
852
|
+
description: 'URL prefix the app serves private files at.'
|
|
853
|
+
})
|
|
854
|
+
]);
|
|
855
|
+
// src/lib/server/config/settings.ts
|
|
856
|
+
/** Keys only this app reads. adapter-node's own keys are here too: they decide whether it works. */
|
|
857
|
+
var APP_SETTINGS = composeManifest([
|
|
858
|
+
defineSetting({
|
|
859
|
+
key: 'ORIGIN',
|
|
860
|
+
type: 'url',
|
|
861
|
+
onInvalid: 'fail',
|
|
862
|
+
class: 'bootstrap',
|
|
863
|
+
summary: true,
|
|
864
|
+
description: 'Public URL of the app. Required behind a reverse proxy.',
|
|
865
|
+
impact:
|
|
866
|
+
'Wrong or unset behind a proxy: form posts fail the CSRF check and invite links carry an internal host.'
|
|
867
|
+
}),
|
|
868
|
+
defineSetting({
|
|
869
|
+
key: 'PORT',
|
|
870
|
+
type: 'int',
|
|
871
|
+
min: 1,
|
|
872
|
+
max: 65535,
|
|
873
|
+
default: 3e3,
|
|
874
|
+
onInvalid: 'fail',
|
|
875
|
+
class: 'bootstrap',
|
|
876
|
+
description: 'Port the app listens on.'
|
|
877
|
+
}),
|
|
878
|
+
defineSetting({
|
|
879
|
+
key: 'HOST',
|
|
880
|
+
type: 'string',
|
|
881
|
+
default: '0.0.0.0',
|
|
882
|
+
onInvalid: 'fail',
|
|
883
|
+
class: 'bootstrap',
|
|
884
|
+
summary: true,
|
|
885
|
+
description: 'Address the app binds to. 127.0.0.1 behind a proxy.',
|
|
886
|
+
impact: 'Under header auth, anything that reaches the port directly can claim to be any user.'
|
|
887
|
+
}),
|
|
888
|
+
defineSetting({
|
|
889
|
+
key: 'BODY_SIZE_LIMIT',
|
|
890
|
+
type: 'string',
|
|
891
|
+
default: '512K',
|
|
892
|
+
onInvalid: 'fail',
|
|
893
|
+
class: 'bootstrap',
|
|
894
|
+
summary: true,
|
|
895
|
+
description: 'Largest request body adapter-node accepts (K, M or G suffix).',
|
|
896
|
+
impact: 'Below COMPUTE_REQUEST_MAX_BYTES, uploads and file inputs fail with 413.'
|
|
897
|
+
}),
|
|
898
|
+
defineSetting({
|
|
899
|
+
key: 'ADDRESS_HEADER',
|
|
900
|
+
type: 'string',
|
|
901
|
+
onInvalid: 'fail',
|
|
902
|
+
class: 'bootstrap',
|
|
903
|
+
summary: true,
|
|
904
|
+
description: 'Header carrying the client IP behind a proxy (X-Forwarded-For).',
|
|
905
|
+
impact: 'Unset behind a proxy: every client shares one rate-limit bucket.'
|
|
906
|
+
}),
|
|
907
|
+
defineSetting({
|
|
908
|
+
key: 'XFF_DEPTH',
|
|
909
|
+
type: 'int',
|
|
910
|
+
min: 1,
|
|
911
|
+
onInvalid: 'fail',
|
|
912
|
+
class: 'bootstrap',
|
|
913
|
+
summary: true,
|
|
914
|
+
description: 'Number of proxies in front of the app.'
|
|
915
|
+
}),
|
|
916
|
+
defineSetting({
|
|
917
|
+
key: 'PROTOCOL_HEADER',
|
|
918
|
+
type: 'string',
|
|
919
|
+
onInvalid: 'fail',
|
|
920
|
+
class: 'bootstrap',
|
|
921
|
+
description: 'Header carrying the original protocol, when ORIGIN is unset.'
|
|
922
|
+
}),
|
|
923
|
+
defineSetting({
|
|
924
|
+
key: 'HOST_HEADER',
|
|
925
|
+
type: 'string',
|
|
926
|
+
onInvalid: 'fail',
|
|
927
|
+
class: 'bootstrap',
|
|
928
|
+
description: 'Header carrying the original host, when ORIGIN is unset.'
|
|
929
|
+
}),
|
|
930
|
+
defineSetting({
|
|
931
|
+
key: 'NODE_ENV',
|
|
932
|
+
type: 'string',
|
|
933
|
+
onInvalid: 'fail',
|
|
934
|
+
class: 'bootstrap',
|
|
935
|
+
description: 'production in deployments: hides stack traces and makes cookies Secure.'
|
|
936
|
+
}),
|
|
937
|
+
defineSetting({
|
|
938
|
+
key: 'ALLOW_INSECURE_COOKIES',
|
|
939
|
+
type: 'bool',
|
|
940
|
+
default: false,
|
|
941
|
+
onInvalid: 'fail',
|
|
942
|
+
class: 'bootstrap',
|
|
943
|
+
description: 'Drop the Secure cookie flag so sign-in works over plain http.',
|
|
944
|
+
legacy: { bool: 'exact-true' }
|
|
945
|
+
}),
|
|
946
|
+
defineSetting({
|
|
947
|
+
key: 'INSTALL_DIR',
|
|
948
|
+
type: 'string',
|
|
949
|
+
onInvalid: 'fail',
|
|
950
|
+
class: 'bootstrap',
|
|
951
|
+
description:
|
|
952
|
+
'Deployment directory the admin updater runs in. Defaults to the working directory.'
|
|
953
|
+
}),
|
|
954
|
+
defineSetting({
|
|
955
|
+
key: 'SELVA_EVENT_CALLBACK_ORIGIN',
|
|
956
|
+
type: 'url',
|
|
957
|
+
onInvalid: 'fail',
|
|
958
|
+
class: 'bootstrap',
|
|
959
|
+
description:
|
|
960
|
+
'Origin Rhino.Compute posts live solve events to, when it differs from the browser’s.'
|
|
961
|
+
}),
|
|
962
|
+
defineSetting({
|
|
963
|
+
key: 'MCP_ALLOWED_ORIGINS',
|
|
964
|
+
type: 'list',
|
|
965
|
+
default: [],
|
|
966
|
+
onInvalid: 'fail',
|
|
967
|
+
class: 'setting',
|
|
968
|
+
description: 'Browser origins /mcp accepts besides its own.'
|
|
969
|
+
}),
|
|
970
|
+
defineSetting({
|
|
971
|
+
key: 'LOG_LEVEL',
|
|
972
|
+
type: 'enum',
|
|
973
|
+
values: ['debug', 'info', 'warn', 'error'],
|
|
974
|
+
onInvalid: 'default',
|
|
975
|
+
class: 'setting',
|
|
976
|
+
description: 'Lowest severity written to the log. Default info (debug in development).'
|
|
977
|
+
}),
|
|
978
|
+
defineSetting({
|
|
979
|
+
key: 'SELVA_FLAG_COMPUTE_DEBUG',
|
|
980
|
+
type: 'string',
|
|
981
|
+
default: 'off',
|
|
982
|
+
onInvalid: 'default',
|
|
983
|
+
class: 'setting',
|
|
984
|
+
description:
|
|
985
|
+
'off, on (cache and timing lines per solve) or verbose (adds Compute’s raw error bodies).',
|
|
986
|
+
impact: 'verbose can write what was solved to the log, which erasure cannot reach.'
|
|
987
|
+
}),
|
|
988
|
+
defineSetting({
|
|
989
|
+
key: 'SELVA_FLAG_COMPUTE_DEBUG_VERBOSE',
|
|
990
|
+
type: 'bool',
|
|
991
|
+
onInvalid: 'default',
|
|
992
|
+
class: 'setting',
|
|
993
|
+
description: 'Replaced by SELVA_FLAG_COMPUTE_DEBUG=verbose.'
|
|
994
|
+
}),
|
|
995
|
+
defineSetting({
|
|
996
|
+
key: 'SENTRY_DSN',
|
|
997
|
+
type: 'url',
|
|
998
|
+
onInvalid: 'fail',
|
|
999
|
+
class: 'secret',
|
|
1000
|
+
sensitive: true,
|
|
1001
|
+
description: 'Ship unexpected server errors to Sentry.'
|
|
1002
|
+
}),
|
|
1003
|
+
defineSetting({
|
|
1004
|
+
key: 'SELVA_RELEASE',
|
|
1005
|
+
type: 'string',
|
|
1006
|
+
onInvalid: 'fail',
|
|
1007
|
+
class: 'tunable',
|
|
1008
|
+
description: 'Release tag on Sentry events and log records.'
|
|
1009
|
+
})
|
|
1010
|
+
]);
|
|
1011
|
+
/** Prefixes whose undeclared keys are reported as unknown (likely typos). */
|
|
1012
|
+
var WATCHED_PREFIXES = [
|
|
1013
|
+
'SELVA_',
|
|
1014
|
+
'SUPABASE_',
|
|
1015
|
+
'COMPUTE_',
|
|
1016
|
+
'SMTP_',
|
|
1017
|
+
'HEADER_AUTH_',
|
|
1018
|
+
'API_TOKEN_',
|
|
1019
|
+
'MAX_',
|
|
1020
|
+
'REMOTE_DEFINITION_',
|
|
1021
|
+
'SIGN_IN_',
|
|
1022
|
+
'BOOTSTRAP_'
|
|
1023
|
+
];
|
|
1024
|
+
/** Read by tooling, not by the app: not unknown, not reported. */
|
|
1025
|
+
var EXTERNAL_KEYS = ['SELVA_REQUIRE_SUPABASE'];
|
|
1026
|
+
/**
|
|
1027
|
+
* The manifest for this deployment: engine and app keys, plus the slices of
|
|
1028
|
+
* the providers it selects. A provider's required keys only apply when it is
|
|
1029
|
+
* in use, so slices are chosen here rather than all composed at once.
|
|
1030
|
+
*/
|
|
1031
|
+
function deploymentManifest(env) {
|
|
1032
|
+
const chosen = new Set(
|
|
1033
|
+
['SELVA_AUTH_PROVIDER', 'SELVA_DATA_PROVIDER', 'SELVA_STORAGE_PROVIDER'].map((k) =>
|
|
1034
|
+
(env[k] ?? 'local').toLowerCase()
|
|
1035
|
+
)
|
|
1036
|
+
);
|
|
1037
|
+
return composeManifest([
|
|
1038
|
+
ENGINE_SETTINGS,
|
|
1039
|
+
APP_SETTINGS,
|
|
1040
|
+
...(chosen.has('local') ? [LOCAL_SETTINGS] : []),
|
|
1041
|
+
...(chosen.has('supabase') ? [SUPABASE_SETTINGS] : []),
|
|
1042
|
+
...(chosen.has('header') ? [HEADER_AUTH_SETTINGS] : [])
|
|
1043
|
+
]);
|
|
1044
|
+
}
|
|
1045
|
+
/** Every key any slice declares, whichever providers are chosen. */
|
|
1046
|
+
var ALL_SETTINGS = composeManifest([
|
|
1047
|
+
ENGINE_SETTINGS,
|
|
1048
|
+
APP_SETTINGS,
|
|
1049
|
+
LOCAL_SETTINGS,
|
|
1050
|
+
SUPABASE_SETTINGS,
|
|
1051
|
+
HEADER_AUTH_SETTINGS
|
|
1052
|
+
]);
|
|
1053
|
+
/** The report for this deployment. Keys of providers it doesn't use are known, not typos. */
|
|
1054
|
+
function deploymentReport(env) {
|
|
1055
|
+
const manifest = deploymentManifest(env);
|
|
1056
|
+
const active = new Set(manifest.flatMap((s) => [s.key, ...(s.aliases ?? [])]));
|
|
1057
|
+
const inactive = ALL_SETTINGS.flatMap((s) => [s.key, ...(s.aliases ?? [])]).filter(
|
|
1058
|
+
(k) => !active.has(k)
|
|
1059
|
+
);
|
|
1060
|
+
return buildConfigReport(manifest, env, {
|
|
1061
|
+
prefixes: WATCHED_PREFIXES,
|
|
1062
|
+
external: [...EXTERNAL_KEYS, ...inactive]
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
var NOT_FROM_ENV_FILE = /* @__PURE__ */ new Set(['NODE_ENV']);
|
|
1066
|
+
/** What the boot snapshot and the CLI's drift check compare. */
|
|
1067
|
+
function deploymentSnapshot(env, salt) {
|
|
1068
|
+
return takeSnapshot(
|
|
1069
|
+
deploymentManifest(env).filter((s) => !NOT_FROM_ENV_FILE.has(s.key)),
|
|
1070
|
+
env,
|
|
1071
|
+
salt
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
1074
|
+
/** The rotation overlap of each root key: what the admin page and doctor count down. */
|
|
1075
|
+
function keyRotationStatus(env, now = Date.now()) {
|
|
1076
|
+
return ['SELVA_HMAC_KEY', 'SELVA_AT_REST_KEY'].map((key) => {
|
|
1077
|
+
const p = previousKey(env, key, now);
|
|
1078
|
+
return {
|
|
1079
|
+
key,
|
|
1080
|
+
state: p.state,
|
|
1081
|
+
...('expires' in p ? { expires: p.expires } : {}),
|
|
1082
|
+
...('daysLeft' in p ? { daysLeft: p.daysLeft } : {})
|
|
1083
|
+
};
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
export {
|
|
1087
|
+
ALL_SETTINGS,
|
|
1088
|
+
PREVIOUS_KEY_DAYS,
|
|
1089
|
+
buildConfigReport,
|
|
1090
|
+
deploymentManifest,
|
|
1091
|
+
deploymentReport,
|
|
1092
|
+
deploymentSnapshot,
|
|
1093
|
+
diffSnapshots,
|
|
1094
|
+
fingerprint,
|
|
1095
|
+
keyRotationStatus,
|
|
1096
|
+
legacyValue,
|
|
1097
|
+
resolveConfig,
|
|
1098
|
+
takeSnapshot
|
|
1099
|
+
};
|