@jwilleke/ngdpbase-types 5.19.0 → 5.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/src/managers/AddonsManager.d.ts +26 -0
- package/lib/src/managers/AuthManager.d.ts +10 -1
- package/lib/src/managers/BackgroundJobManager.d.ts +6 -0
- package/lib/src/managers/BaseManager.d.ts +6 -0
- package/lib/src/managers/ConfigurationManager.d.ts +27 -2
- package/lib/src/managers/DatabaseManager.d.ts +31 -1
- package/lib/src/managers/DatabaseManager.js +108 -17
- package/lib/src/providers/SqliteDatabaseProvider.d.ts +5 -0
- package/lib/src/providers/SqliteDatabaseProvider.js +34 -7
- package/lib/src/types/WikiEngine.d.ts +12 -1
- package/lib/src/utils/addonConfigLayer.d.ts +51 -1
- package/lib/src/utils/addonConfigLayer.js +104 -8
- package/lib/src/utils/configPaths.d.ts +55 -0
- package/lib/src/utils/configPaths.js +133 -0
- package/lib/src/utils/pluginFormatters.d.ts +6 -1
- package/lib/src/utils/pluginFormatters.js +5 -2
- package/lib/src/utils/redactSecrets.js +31 -11
- package/lib/src/utils/sensitiveValues.d.ts +49 -3
- package/lib/src/utils/sensitiveValues.js +139 -11
- package/lib/src/utils/startupState.d.ts +65 -0
- package/lib/src/utils/startupState.js +115 -0
- package/package.json +1 -1
|
@@ -40,6 +40,17 @@
|
|
|
40
40
|
* never merged into the configuration: DatabaseManager reads the declarations
|
|
41
41
|
* from here, so the operator's file cannot change one. An invalid declaration
|
|
42
42
|
* stops its addon loading, as a refused system-category does.
|
|
43
|
+
*
|
|
44
|
+
* A third, `ngdpbase.addons.<slug>.site-critical` (#1883): whether a failure of
|
|
45
|
+
* the addon to load puts the whole site in maintenance mode. Read from the
|
|
46
|
+
* addon's own file and from the operator's, which wins either way, and kept
|
|
47
|
+
* apart from the merge so a refused addon's value is still known. A value that
|
|
48
|
+
* is not exactly `true` or `false` refuses the addon and counts as `true`.
|
|
49
|
+
*
|
|
50
|
+
* A fourth, `ngdpbase.config.sensitive-values` (#1889): an enabled addon may
|
|
51
|
+
* classify only its own keys, `ngdpbase.addons.<slug>` and what is under it
|
|
52
|
+
* (paths inside its values included). An entry for any other key refuses the
|
|
53
|
+
* addon, since it could unhide core's or another addon's secret.
|
|
43
54
|
*/
|
|
44
55
|
import fs from 'fs';
|
|
45
56
|
import path from 'path';
|
|
@@ -47,6 +58,7 @@ import { isDeepStrictEqual } from 'util';
|
|
|
47
58
|
import { configFilePaths, deepMergeConfigs, deepMergeObjects, instanceDataFolder, isPlainObject, readConfigFilesSync } from './configFiles.js';
|
|
48
59
|
import { configuredInstallSlugs, findNodeModulesDir, installedAddonsDir, matchNpmPackageDirs, resolveAddonSlug, splitAddonsPath } from './addonsPathResolver.js';
|
|
49
60
|
import { lockedAddonDirsSync } from './addonLock.js';
|
|
61
|
+
import { LEGACY_SECRET_KEYS_KEY, SENSITIVE_VALUES_KEY } from './sensitiveValues.js';
|
|
50
62
|
import { parsePrivatestore } from './privateStorePath.js';
|
|
51
63
|
import { OWNER_DECIDED_FIELDS, SYSTEM_CATEGORY_KEY, systemCategoryValueProblems } from './systemCategoryRules.js';
|
|
52
64
|
import { addonDatabaseKey, addonDatabaseKeySlug, addonDatabaseProblems } from './addonDatabaseRules.js';
|
|
@@ -127,6 +139,10 @@ export function discoverAddonDefaults(base, cwd = process.cwd(), dataFolder = in
|
|
|
127
139
|
}
|
|
128
140
|
return out;
|
|
129
141
|
}
|
|
142
|
+
/** How a problem line names an addon; AddonsManager matches it to fold a refusal into its load-failure notice (#1883). */
|
|
143
|
+
export function addonProblemSubject(slug, enabled) {
|
|
144
|
+
return `add-on '${slug}'${enabled ? '' : ' (turned off)'}`;
|
|
145
|
+
}
|
|
130
146
|
function vaultIdOf(entry) {
|
|
131
147
|
return parsePrivatestore(entry?.storageLocation?.privatestore)?.vaultId;
|
|
132
148
|
}
|
|
@@ -154,7 +170,7 @@ function addonCategoryLayer(shipped, custom, addons) {
|
|
|
154
170
|
const declared = addon.defaults[SYSTEM_CATEGORY_KEY];
|
|
155
171
|
if (declared === undefined)
|
|
156
172
|
continue;
|
|
157
|
-
const who =
|
|
173
|
+
const who = addonProblemSubject(addon.slug, addon.enabled);
|
|
158
174
|
if (!isPlainObject(declared)) {
|
|
159
175
|
const reason = `${SYSTEM_CATEGORY_KEY} must be a map of entries`;
|
|
160
176
|
layer.problems.push(`${who}: ${reason}; it is refused and the add-on is not loaded`);
|
|
@@ -246,7 +262,7 @@ export function addonDatabaseLayer(addons, custom) {
|
|
|
246
262
|
}
|
|
247
263
|
}
|
|
248
264
|
if (reasons.length > 0) {
|
|
249
|
-
const who =
|
|
265
|
+
const who = addonProblemSubject(addon.slug, addon.enabled);
|
|
250
266
|
layer.problems.push(`${who}: its database declaration is refused (${reasons.join('; ')}); the add-on is not loaded`);
|
|
251
267
|
layer.refused.set(addon.slug, `its database declaration was refused: ${reasons.join('; ')}`);
|
|
252
268
|
continue;
|
|
@@ -262,6 +278,80 @@ export function addonDatabaseLayer(addons, custom) {
|
|
|
262
278
|
}
|
|
263
279
|
return layer;
|
|
264
280
|
}
|
|
281
|
+
/** Every entry an addon's file names in a sensitive-values map or the legacy list, whatever its level. */
|
|
282
|
+
function sensitiveEntriesOf(defaults) {
|
|
283
|
+
const names = [];
|
|
284
|
+
for (const listKey of [SENSITIVE_VALUES_KEY, LEGACY_SECRET_KEYS_KEY]) {
|
|
285
|
+
const declared = defaults[listKey];
|
|
286
|
+
if (Array.isArray(declared))
|
|
287
|
+
names.push(...declared.filter((v) => typeof v === 'string'));
|
|
288
|
+
else if (isPlainObject(declared))
|
|
289
|
+
names.push(...Object.keys(declared));
|
|
290
|
+
}
|
|
291
|
+
return names;
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Which values an addon may classify (#1889): its own, `ngdpbase.addons.<slug>`
|
|
295
|
+
* and anything under it, top-level keys and paths alike. An entry for core's
|
|
296
|
+
* keys or another addon's could unhide them (`false`, or `sensitive` for a
|
|
297
|
+
* `secret`), so it refuses the addon, as a database declaration under another
|
|
298
|
+
* slug does (#1860): it is not loaded and none of its keys apply. Only an
|
|
299
|
+
* enabled addon's entries reach the merge, so only an enabled addon is checked.
|
|
300
|
+
*/
|
|
301
|
+
export function addonSensitiveValuesLayer(addons) {
|
|
302
|
+
const layer = { refused: new Map(), problems: [] };
|
|
303
|
+
for (const addon of addons) {
|
|
304
|
+
if (!addon.enabled)
|
|
305
|
+
continue;
|
|
306
|
+
const own = `ngdpbase.addons.${addon.slug}`;
|
|
307
|
+
const foreign = sensitiveEntriesOf(addon.defaults).filter((entry) => entry.trim() !== own && !entry.trim().startsWith(`${own}.`));
|
|
308
|
+
if (foreign.length === 0)
|
|
309
|
+
continue;
|
|
310
|
+
const reason = `its sensitive-values entries ${foreign.map((e) => `'${e}'`).join(', ')} are not its own; an add-on classifies only ${own} and what is under it`;
|
|
311
|
+
layer.refused.set(addon.slug, reason);
|
|
312
|
+
layer.problems.push(`${addonProblemSubject(addon.slug, addon.enabled)}: ${reason}; the add-on is not loaded`);
|
|
313
|
+
}
|
|
314
|
+
return layer;
|
|
315
|
+
}
|
|
316
|
+
/** The configuration key that makes an addon site-critical (#1883). */
|
|
317
|
+
export function addonSiteCriticalKey(slug) {
|
|
318
|
+
return `ngdpbase.addons.${slug}.site-critical`;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Which addons are site-critical (#1883): the operator's value if the
|
|
322
|
+
* operator's file sets one, else the addon's own (only under its own slug),
|
|
323
|
+
* else not. The operator's file wins in both directions, as for every addon
|
|
324
|
+
* key. The value is exactly `true` or `false`; anything else (the string
|
|
325
|
+
* `"true"` included) refuses the addon and counts as site-critical, so a
|
|
326
|
+
* mistyped value fails closed. `custom` keys for an addon with no file, or
|
|
327
|
+
* one that is not installed, count too.
|
|
328
|
+
*/
|
|
329
|
+
export function addonSiteCriticalLayer(addons, custom) {
|
|
330
|
+
const layer = { siteCritical: new Map(), refused: new Map(), problems: [] };
|
|
331
|
+
const take = (slug, enabled, own) => {
|
|
332
|
+
const key = addonSiteCriticalKey(slug);
|
|
333
|
+
const fromSite = custom[key] !== undefined;
|
|
334
|
+
const value = fromSite ? custom[key] : own;
|
|
335
|
+
if (value === undefined)
|
|
336
|
+
return;
|
|
337
|
+
if (typeof value === 'boolean') {
|
|
338
|
+
layer.siteCritical.set(slug, value);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
const reason = `${key} must be true or false, got ${JSON.stringify(value)} in ${fromSite ? 'app-custom-config.json' : 'its config/default-config.json'}; it is treated as true`;
|
|
342
|
+
layer.siteCritical.set(slug, true);
|
|
343
|
+
layer.refused.set(slug, reason);
|
|
344
|
+
layer.problems.push(`${addonProblemSubject(slug, enabled)}: ${reason}; the add-on is not loaded`);
|
|
345
|
+
};
|
|
346
|
+
for (const addon of addons)
|
|
347
|
+
take(addon.slug, addon.enabled, addon.defaults[addonSiteCriticalKey(addon.slug)]);
|
|
348
|
+
for (const key of Object.keys(custom)) {
|
|
349
|
+
const slug = /^ngdpbase\.addons\.([^.]+)\.site-critical$/.exec(key)?.[1];
|
|
350
|
+
if (slug && !layer.siteCritical.has(slug))
|
|
351
|
+
take(slug, custom[`ngdpbase.addons.${slug}.enabled`] === true, undefined);
|
|
352
|
+
}
|
|
353
|
+
return layer;
|
|
354
|
+
}
|
|
265
355
|
/**
|
|
266
356
|
* The owner's declaration stands (operator, 2026-09-29): for each entry an
|
|
267
357
|
* addon declared, the fields in OWNER_DECIDED_FIELDS are the addon's, never
|
|
@@ -310,8 +400,11 @@ export function mergeWithAddonLayer(shipped, addons, custom) {
|
|
|
310
400
|
const customCategories = custom[SYSTEM_CATEGORY_KEY];
|
|
311
401
|
const layer = addonCategoryLayer(shipped[SYSTEM_CATEGORY_KEY], customCategories, addons);
|
|
312
402
|
const databaseLayer = addonDatabaseLayer(addons, custom);
|
|
403
|
+
const criticalLayer = addonSiteCriticalLayer(addons, custom);
|
|
404
|
+
const sensitiveLayer = addonSensitiveValuesLayer(addons);
|
|
405
|
+
const siteCritical = criticalLayer.siteCritical;
|
|
313
406
|
const refused = new Map(layer.refused);
|
|
314
|
-
for (const [slug, reason] of databaseLayer.refused)
|
|
407
|
+
for (const [slug, reason] of [...databaseLayer.refused, ...criticalLayer.refused, ...sensitiveLayer.refused])
|
|
315
408
|
refused.set(slug, refused.has(slug) ? `${refused.get(slug)}; ${reason}` : reason);
|
|
316
409
|
const databases = databaseLayer.declarations.map((d) => ({ ...d, enabled: d.enabled && !refused.has(d.declaredBy) }));
|
|
317
410
|
const withAddons = addons
|
|
@@ -325,14 +418,14 @@ export function mergeWithAddonLayer(shipped, addons, custom) {
|
|
|
325
418
|
return deepMergeObjects(acc, rest);
|
|
326
419
|
}, shipped);
|
|
327
420
|
if (Object.keys(layer.entries).length === 0) {
|
|
328
|
-
return { merged: deepMergeConfigs(withAddons, custom), problems: [...layer.problems, ...databaseLayer.problems], refused, databases };
|
|
421
|
+
return { merged: deepMergeConfigs(withAddons, custom), problems: [...layer.problems, ...databaseLayer.problems, ...criticalLayer.problems, ...sensitiveLayer.problems], refused, databases, siteCritical };
|
|
329
422
|
}
|
|
330
423
|
const shippedCategories = isPlainObject(withAddons[SYSTEM_CATEGORY_KEY]) ? withAddons[SYSTEM_CATEGORY_KEY] : {};
|
|
331
424
|
const layered = { ...withAddons, [SYSTEM_CATEGORY_KEY]: deepMergeObjects(shippedCategories, layer.entries) };
|
|
332
425
|
const merged = deepMergeObjects(layered, custom);
|
|
333
426
|
const stood = ownerDeclarationsStand(merged[SYSTEM_CATEGORY_KEY], layer, customCategories);
|
|
334
427
|
merged[SYSTEM_CATEGORY_KEY] = stood.categories;
|
|
335
|
-
return { merged: merged, problems: [...layer.problems, ...stood.problems, ...databaseLayer.problems], refused, databases };
|
|
428
|
+
return { merged: merged, problems: [...layer.problems, ...stood.problems, ...databaseLayer.problems, ...criticalLayer.problems, ...sensitiveLayer.problems], refused, databases, siteCritical };
|
|
336
429
|
}
|
|
337
430
|
/**
|
|
338
431
|
* The full merge for a caller with the two files already read (the manager,
|
|
@@ -344,12 +437,14 @@ export function mergeWithAddonLayer(shipped, addons, custom) {
|
|
|
344
437
|
* could not be read; `refused` names, by slug, each addon that must not load
|
|
345
438
|
* because an entry it declares was refused, and why (#1559), or because its
|
|
346
439
|
* database declaration was (#1860); `databases` is every sound database
|
|
347
|
-
* declaration of every installed addon, on or off (#1860)
|
|
440
|
+
* declaration of every installed addon, on or off (#1860); `siteCritical`
|
|
441
|
+
* says, by slug, which addons put the site in maintenance mode when they fail
|
|
442
|
+
* to load (#1883).
|
|
348
443
|
*/
|
|
349
444
|
export function mergeConfigWithAddons(shipped, custom, cwd = process.cwd(), dataFolder = instanceDataFolder()) {
|
|
350
445
|
const base = deepMergeConfigs(shipped, custom);
|
|
351
446
|
const all = discoverAddonDefaults(base, cwd, dataFolder);
|
|
352
|
-
const { merged, problems, refused, databases } = mergeWithAddonLayer(shipped, all, custom);
|
|
447
|
+
const { merged, problems, refused, databases, siteCritical } = mergeWithAddonLayer(shipped, all, custom);
|
|
353
448
|
const unreadable = all
|
|
354
449
|
.filter((a) => !a.enabled && a.error)
|
|
355
450
|
.map((a) => `add-on '${a.slug}' (turned off): config/default-config.json could not be read (${a.error}); its system-categories and database are not known`);
|
|
@@ -359,7 +454,8 @@ export function mergeConfigWithAddons(shipped, custom, cwd = process.cwd(), data
|
|
|
359
454
|
addons: all.filter((a) => a.enabled && !refused.has(a.slug)),
|
|
360
455
|
problems: [...unreadable, ...problems],
|
|
361
456
|
refused,
|
|
362
|
-
databases
|
|
457
|
+
databases,
|
|
458
|
+
siteCritical
|
|
363
459
|
};
|
|
364
460
|
}
|
|
365
461
|
/**
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Paths inside a configuration value (#1889).
|
|
3
|
+
*
|
|
4
|
+
* A configuration key names a value; when the value is a map or a list, each
|
|
5
|
+
* thing inside it has a path below the key: its map keys and list indices, in
|
|
6
|
+
* order. `ngdpbase.addons.accounting.accounts` → `checking` → `account-number`
|
|
7
|
+
* is written `ngdpbase.addons.accounting.accounts.checking.account-number`.
|
|
8
|
+
*
|
|
9
|
+
* Two readers walk these paths, and they walk them here, the same way:
|
|
10
|
+
*
|
|
11
|
+
* - `ConfigurationManager` resolves a bare `$NAME` environment reference
|
|
12
|
+
* wherever it sits in a value, naming the full path when the variable is
|
|
13
|
+
* unset;
|
|
14
|
+
* - `sensitiveValues` hides what `ngdpbase.config.sensitive-values` names by
|
|
15
|
+
* a path, where `*` stands for any one map key (or list index).
|
|
16
|
+
*
|
|
17
|
+
* @module utils/configPaths
|
|
18
|
+
*/
|
|
19
|
+
/** A bare, whole-string environment reference: `$NAME`, upper case, digits and `_`. */
|
|
20
|
+
export declare const ENV_REF_PATTERN: RegExp;
|
|
21
|
+
/** The variable a bare `$NAME` reference names, or null when the value is not one. */
|
|
22
|
+
export declare function envRefName(value: unknown): string | null;
|
|
23
|
+
/** One path segment that stands for any map key or list index. */
|
|
24
|
+
export declare const WILDCARD = "*";
|
|
25
|
+
/** A key and the path below it, written as configuration keys are: dot-joined. */
|
|
26
|
+
export declare function pathString(key: string, below: readonly string[]): string;
|
|
27
|
+
/**
|
|
28
|
+
* Visit a value and everything inside it, top first. `visit` returns a
|
|
29
|
+
* replacement for the node, or `undefined` to keep it and go inside. Copy on
|
|
30
|
+
* write: a map or list nothing inside changed is returned as itself, so a read
|
|
31
|
+
* of a value with nothing to replace allocates nothing.
|
|
32
|
+
*/
|
|
33
|
+
export declare function mapConfigValue(value: unknown, visit: (node: unknown, below: readonly string[]) => unknown, below?: string[]): unknown;
|
|
34
|
+
/**
|
|
35
|
+
* The segments of a path entry, or null when it is not a well-formed one: no
|
|
36
|
+
* empty segment, and `*` only as a whole segment with at least two written-out
|
|
37
|
+
* segments before the first one (`ngdpbase.<category>`), since a key is never
|
|
38
|
+
* a wildcard.
|
|
39
|
+
*/
|
|
40
|
+
export declare function parsePathEntry(entry: string): string[] | null;
|
|
41
|
+
/**
|
|
42
|
+
* Whether `entry` names something inside `key`'s value, and the path pattern
|
|
43
|
+
* below the key if so. The key is matched literally: a `*` stands only for a
|
|
44
|
+
* segment below it.
|
|
45
|
+
*/
|
|
46
|
+
export declare function patternBelow(entry: string, key: string): string[] | null;
|
|
47
|
+
/** Whether a path below a key matches a pattern below it: same length, `*` any one segment. */
|
|
48
|
+
export declare function pathMatches(pattern: readonly string[], below: readonly string[]): boolean;
|
|
49
|
+
/** What sits at a path below a value, or undefined. */
|
|
50
|
+
export declare function valueAt(value: unknown, below: readonly string[]): unknown;
|
|
51
|
+
/**
|
|
52
|
+
* The configuration key a path entry lies inside: the longest prefix, ending
|
|
53
|
+
* before the first `*`, for which `has` is true. Null when none is set.
|
|
54
|
+
*/
|
|
55
|
+
export declare function keyOfEntry(entry: string, has: (key: string) => boolean): string | null;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Paths inside a configuration value (#1889).
|
|
3
|
+
*
|
|
4
|
+
* A configuration key names a value; when the value is a map or a list, each
|
|
5
|
+
* thing inside it has a path below the key: its map keys and list indices, in
|
|
6
|
+
* order. `ngdpbase.addons.accounting.accounts` → `checking` → `account-number`
|
|
7
|
+
* is written `ngdpbase.addons.accounting.accounts.checking.account-number`.
|
|
8
|
+
*
|
|
9
|
+
* Two readers walk these paths, and they walk them here, the same way:
|
|
10
|
+
*
|
|
11
|
+
* - `ConfigurationManager` resolves a bare `$NAME` environment reference
|
|
12
|
+
* wherever it sits in a value, naming the full path when the variable is
|
|
13
|
+
* unset;
|
|
14
|
+
* - `sensitiveValues` hides what `ngdpbase.config.sensitive-values` names by
|
|
15
|
+
* a path, where `*` stands for any one map key (or list index).
|
|
16
|
+
*
|
|
17
|
+
* @module utils/configPaths
|
|
18
|
+
*/
|
|
19
|
+
/** A bare, whole-string environment reference: `$NAME`, upper case, digits and `_`. */
|
|
20
|
+
export const ENV_REF_PATTERN = /^\$([A-Z_][A-Z0-9_]*)$/;
|
|
21
|
+
/** The variable a bare `$NAME` reference names, or null when the value is not one. */
|
|
22
|
+
export function envRefName(value) {
|
|
23
|
+
return typeof value === 'string' ? (ENV_REF_PATTERN.exec(value)?.[1] ?? null) : null;
|
|
24
|
+
}
|
|
25
|
+
/** One path segment that stands for any map key or list index. */
|
|
26
|
+
export const WILDCARD = '*';
|
|
27
|
+
/** A key and the path below it, written as configuration keys are: dot-joined. */
|
|
28
|
+
export function pathString(key, below) {
|
|
29
|
+
return below.length === 0 ? key : `${key}.${below.join('.')}`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Visit a value and everything inside it, top first. `visit` returns a
|
|
33
|
+
* replacement for the node, or `undefined` to keep it and go inside. Copy on
|
|
34
|
+
* write: a map or list nothing inside changed is returned as itself, so a read
|
|
35
|
+
* of a value with nothing to replace allocates nothing.
|
|
36
|
+
*/
|
|
37
|
+
export function mapConfigValue(value, visit, below = []) {
|
|
38
|
+
const replaced = visit(value, below);
|
|
39
|
+
if (replaced !== undefined)
|
|
40
|
+
return replaced;
|
|
41
|
+
if (Array.isArray(value)) {
|
|
42
|
+
let out = null;
|
|
43
|
+
for (let i = 0; i < value.length; i++) {
|
|
44
|
+
below.push(String(i));
|
|
45
|
+
const next = mapConfigValue(value[i], visit, below);
|
|
46
|
+
below.pop();
|
|
47
|
+
if (next !== value[i]) {
|
|
48
|
+
out ??= value.slice();
|
|
49
|
+
out[i] = next;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return out ?? value;
|
|
53
|
+
}
|
|
54
|
+
if (value !== null && typeof value === 'object') {
|
|
55
|
+
let out = null;
|
|
56
|
+
for (const [k, v] of Object.entries(value)) {
|
|
57
|
+
below.push(k);
|
|
58
|
+
const next = mapConfigValue(v, visit, below);
|
|
59
|
+
below.pop();
|
|
60
|
+
if (next !== v) {
|
|
61
|
+
out ??= { ...value };
|
|
62
|
+
out[k] = next;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return out ?? value;
|
|
66
|
+
}
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The segments of a path entry, or null when it is not a well-formed one: no
|
|
71
|
+
* empty segment, and `*` only as a whole segment with at least two written-out
|
|
72
|
+
* segments before the first one (`ngdpbase.<category>`), since a key is never
|
|
73
|
+
* a wildcard.
|
|
74
|
+
*/
|
|
75
|
+
export function parsePathEntry(entry) {
|
|
76
|
+
if (typeof entry !== 'string' || entry === '')
|
|
77
|
+
return null;
|
|
78
|
+
const segments = entry.split('.');
|
|
79
|
+
if (segments.some((s) => s === '' || (s.includes(WILDCARD) && s !== WILDCARD)))
|
|
80
|
+
return null;
|
|
81
|
+
const wild = segments.indexOf(WILDCARD);
|
|
82
|
+
if (wild !== -1 && wild < 2)
|
|
83
|
+
return null;
|
|
84
|
+
return segments;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Whether `entry` names something inside `key`'s value, and the path pattern
|
|
88
|
+
* below the key if so. The key is matched literally: a `*` stands only for a
|
|
89
|
+
* segment below it.
|
|
90
|
+
*/
|
|
91
|
+
export function patternBelow(entry, key) {
|
|
92
|
+
if (!entry.startsWith(`${key}.`))
|
|
93
|
+
return null;
|
|
94
|
+
const segments = parsePathEntry(entry);
|
|
95
|
+
return segments ? segments.slice(key.split('.').length) : null;
|
|
96
|
+
}
|
|
97
|
+
/** Whether a path below a key matches a pattern below it: same length, `*` any one segment. */
|
|
98
|
+
export function pathMatches(pattern, below) {
|
|
99
|
+
if (pattern.length !== below.length)
|
|
100
|
+
return false;
|
|
101
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
102
|
+
if (pattern[i] !== WILDCARD && pattern[i] !== below[i])
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
/** What sits at a path below a value, or undefined. */
|
|
108
|
+
export function valueAt(value, below) {
|
|
109
|
+
let node = value;
|
|
110
|
+
for (const segment of below) {
|
|
111
|
+
if (node === null || typeof node !== 'object')
|
|
112
|
+
return undefined;
|
|
113
|
+
node = node[segment];
|
|
114
|
+
}
|
|
115
|
+
return node;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The configuration key a path entry lies inside: the longest prefix, ending
|
|
119
|
+
* before the first `*`, for which `has` is true. Null when none is set.
|
|
120
|
+
*/
|
|
121
|
+
export function keyOfEntry(entry, has) {
|
|
122
|
+
const segments = parsePathEntry(entry);
|
|
123
|
+
if (!segments)
|
|
124
|
+
return null;
|
|
125
|
+
const wild = segments.indexOf(WILDCARD);
|
|
126
|
+
const longest = wild === -1 ? segments.length - 1 : wild;
|
|
127
|
+
for (let n = longest; n >= 2; n--) {
|
|
128
|
+
const key = segments.slice(0, n).join('.');
|
|
129
|
+
if (has(key))
|
|
130
|
+
return key;
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
@@ -416,10 +416,15 @@ export declare function resolveManagerFetch(spec: string | undefined, context: {
|
|
|
416
416
|
};
|
|
417
417
|
userContext?: unknown;
|
|
418
418
|
}): Promise<ManagerFetchResult>;
|
|
419
|
-
/**
|
|
419
|
+
/**
|
|
420
|
+
* One choice in a form's dropdown or checklist: the value submitted, the text
|
|
421
|
+
* shown, and optionally what it is worth in whole minor units (cents), which a
|
|
422
|
+
* checklist adds up as boxes are ticked (#1904).
|
|
423
|
+
*/
|
|
420
424
|
export interface FormOption {
|
|
421
425
|
value: string;
|
|
422
426
|
label: string;
|
|
427
|
+
amount?: number;
|
|
423
428
|
}
|
|
424
429
|
/** Outcome of resolveManagerOptions; the statuses mean what they mean for resolveManagerFetch. */
|
|
425
430
|
export type ManagerOptionsResult = {
|
|
@@ -676,9 +676,12 @@ function toFormOptionList(value) {
|
|
|
676
676
|
options.push({ value: item, label: item });
|
|
677
677
|
}
|
|
678
678
|
else if (item && typeof item === 'object') {
|
|
679
|
-
const { value: v, label } = item;
|
|
679
|
+
const { value: v, label, amount } = item;
|
|
680
680
|
if ((typeof v === 'string' || typeof v === 'number') && String(v) !== '') {
|
|
681
|
-
|
|
681
|
+
const option = { value: String(v), label: typeof label === 'string' && label !== '' ? label : String(v) };
|
|
682
|
+
if (typeof amount === 'number' && Number.isSafeInteger(amount))
|
|
683
|
+
option.amount = amount;
|
|
684
|
+
options.push(option);
|
|
682
685
|
}
|
|
683
686
|
}
|
|
684
687
|
}
|
|
@@ -37,7 +37,8 @@
|
|
|
37
37
|
* {@link refreshRedactedSecrets} and log them themselves.
|
|
38
38
|
*/
|
|
39
39
|
import { format } from 'winston';
|
|
40
|
-
import { secretKeys } from './sensitiveValues.js';
|
|
40
|
+
import { secretKeys, secretValuesBelow, valueLevels } from './sensitiveValues.js';
|
|
41
|
+
import { keyOfEntry } from './configPaths.js';
|
|
41
42
|
/**
|
|
42
43
|
* Values shorter than this are never redacted.
|
|
43
44
|
*
|
|
@@ -88,27 +89,46 @@ export function refreshRedactedSecrets(configManager) {
|
|
|
88
89
|
redactions = [];
|
|
89
90
|
return { active: 0, skipped };
|
|
90
91
|
}
|
|
91
|
-
const
|
|
92
|
+
const read = (k, d) => configManager.getProperty(k, d);
|
|
93
|
+
const keys = secretKeys(read);
|
|
92
94
|
const seen = new Set();
|
|
93
95
|
const next = [];
|
|
94
|
-
|
|
95
|
-
if (typeof rawKey !== 'string')
|
|
96
|
-
continue;
|
|
97
|
-
const key = rawKey.trim();
|
|
98
|
-
if (key === '')
|
|
99
|
-
continue;
|
|
100
|
-
const value = configManager.getProperty(key, undefined);
|
|
96
|
+
const take = (key, value) => {
|
|
101
97
|
const reason = rejectionReason(value);
|
|
102
98
|
if (reason) {
|
|
103
99
|
skipped.push({ key, reason });
|
|
104
|
-
|
|
100
|
+
return;
|
|
105
101
|
}
|
|
106
102
|
const literal = value.trim();
|
|
107
103
|
// Two keys holding the same value redact once; the first key names it.
|
|
108
104
|
if (seen.has(literal))
|
|
109
|
-
|
|
105
|
+
return;
|
|
110
106
|
seen.add(literal);
|
|
111
107
|
next.push({ key, value: literal });
|
|
108
|
+
};
|
|
109
|
+
// #1889: an entry that names a path inside a key's value (with or without
|
|
110
|
+
// a `*`) is redacted by each value it finds there, named by its full path.
|
|
111
|
+
const inside = new Set();
|
|
112
|
+
for (const rawKey of keys) {
|
|
113
|
+
if (typeof rawKey !== 'string')
|
|
114
|
+
continue;
|
|
115
|
+
const key = rawKey.trim();
|
|
116
|
+
if (key === '')
|
|
117
|
+
continue;
|
|
118
|
+
const value = configManager.getProperty(key, undefined);
|
|
119
|
+
if (value === undefined) {
|
|
120
|
+
const parent = keyOfEntry(key, (k) => configManager.getProperty(k, undefined) !== undefined);
|
|
121
|
+
if (parent) {
|
|
122
|
+
inside.add(parent);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
take(key, value);
|
|
127
|
+
}
|
|
128
|
+
const levels = valueLevels(read);
|
|
129
|
+
for (const parent of inside) {
|
|
130
|
+
for (const { path, value } of secretValuesBelow(parent, configManager.getProperty(parent, undefined), levels))
|
|
131
|
+
take(path, value);
|
|
112
132
|
}
|
|
113
133
|
// Longest first: where one secret contains another, the longer must be
|
|
114
134
|
// struck first or the shorter leaves a mangled remainder of the longer.
|
|
@@ -15,6 +15,13 @@
|
|
|
15
15
|
* hides secret-looking fields nested inside an object value (an OIDC client's
|
|
16
16
|
* `client_secret` inside `oidc-auth-server.clients`).
|
|
17
17
|
*
|
|
18
|
+
* An entry may also name something inside a key's value by its path (#1889):
|
|
19
|
+
* `ngdpbase.addons.accounting.accounts.*.account-number`, where `*` stands for
|
|
20
|
+
* any one map key (or list index). It hides what sits there, at the entry's
|
|
21
|
+
* level, wherever a value is shown, except a bare `$NAME` reference, which is
|
|
22
|
+
* a pointer to the environment, not the secret. The paths are walked by
|
|
23
|
+
* `utils/configPaths.ts`, the same walk that resolves `$NAME` inside a value.
|
|
24
|
+
*
|
|
18
25
|
* Hidden values are replaced on the server, before any HTML is built: they
|
|
19
26
|
* never reach a page, its source or the parse cache.
|
|
20
27
|
*
|
|
@@ -31,15 +38,31 @@ export interface ValueViewer {
|
|
|
31
38
|
}
|
|
32
39
|
/** Whether a key or field name looks like a secret (the backstop's test; also `npm run lint:sensitive`). */
|
|
33
40
|
export declare function looksSecret(name: string): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* What an administrator's screen, a log line and an audit record may hold:
|
|
43
|
+
* sensitive values, never secrets. Secrets there are revealed one key at a
|
|
44
|
+
* time, on demand (`secret-reveal`).
|
|
45
|
+
*/
|
|
46
|
+
export declare const NO_SECRETS: ValueViewer;
|
|
34
47
|
/** What a hidden value is shown as. */
|
|
35
48
|
export declare const HIDDEN = "(hidden)";
|
|
36
49
|
type Read = (key: string, fallback: unknown) => unknown;
|
|
37
50
|
/**
|
|
38
51
|
* The declared levels: the legacy list's keys as `secret`, then the new map
|
|
39
52
|
* (`"secret"`, `"sensitive"`, `true` = secret; `false` / `null` removes an
|
|
40
|
-
* entry, so an instance can override a shipped one).
|
|
53
|
+
* entry, so an instance can override a shipped one). An entry that is not a
|
|
54
|
+
* well-formed key or path (#1889: an empty segment, a `*` inside a segment, or
|
|
55
|
+
* a `*` before `ngdpbase.<category>`) is no entry, as a level that is none of
|
|
56
|
+
* these is no entry; {@link sensitiveValueProblems} names both.
|
|
41
57
|
*/
|
|
42
58
|
export declare function valueLevels(read: Read): Map<string, ValueLevel>;
|
|
59
|
+
/**
|
|
60
|
+
* What is wrong with the declared entries, one line each (#1889): an entry
|
|
61
|
+
* that is not a well-formed key or path, or a level that is none of
|
|
62
|
+
* `"secret"`, `"sensitive"`, `true`, `false`, `null`. {@link valueLevels}
|
|
63
|
+
* drops each such entry; the engine warns at boot.
|
|
64
|
+
*/
|
|
65
|
+
export declare function sensitiveValueProblems(read: Read): string[];
|
|
43
66
|
/** The keys declared `secret`: what logs, the audit log and the admin screen never print. */
|
|
44
67
|
export declare function secretKeys(read: Read): string[];
|
|
45
68
|
/**
|
|
@@ -52,8 +75,31 @@ export declare function levelOf(key: string, levels: Map<string, ValueLevel>, va
|
|
|
52
75
|
/** Whether the legacy list is still set (to warn at boot). */
|
|
53
76
|
export declare function usesLegacyName(read: Read): boolean;
|
|
54
77
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
78
|
+
* A key's value with what its path entries name hidden from this viewer
|
|
79
|
+
* (#1889). An empty value, and a bare `$NAME` reference, are kept: neither is
|
|
80
|
+
* the secret. The key's own level is {@link shownValue}'s business.
|
|
81
|
+
*/
|
|
82
|
+
export declare function hideDeclaredPaths(key: string, value: unknown, levels: Map<string, ValueLevel>, viewer: ValueViewer): unknown;
|
|
83
|
+
/**
|
|
84
|
+
* The strings (and numbers, as text) a key's secret path entries name inside
|
|
85
|
+
* its value, each with its full path (#1889): what log redaction strikes.
|
|
86
|
+
* Everything under a named map or list counts.
|
|
87
|
+
*/
|
|
88
|
+
export declare function secretValuesBelow(key: string, value: unknown, levels: Map<string, ValueLevel>): Array<{
|
|
89
|
+
path: string;
|
|
90
|
+
value: string;
|
|
91
|
+
}>;
|
|
92
|
+
/**
|
|
93
|
+
* A value submitted from a screen that showed it with paths hidden, with each
|
|
94
|
+
* `HIDDEN` placeholder at a declared path put back to the stored value there
|
|
95
|
+
* (#1889). Without this, saving an edit to an accounts map would write the
|
|
96
|
+
* placeholder over every account number.
|
|
97
|
+
*/
|
|
98
|
+
export declare function keepHiddenValues(key: string, submitted: unknown, stored: unknown, levels: Map<string, ValueLevel>): unknown;
|
|
99
|
+
/**
|
|
100
|
+
* The value a viewer may see for a key: itself, or `HIDDEN`; inside it, what
|
|
101
|
+
* its path entries name is hidden as well (#1889). An empty value is shown as
|
|
102
|
+
* it is: "not set" reveals nothing.
|
|
57
103
|
*/
|
|
58
104
|
export declare function shownValue(key: string, value: unknown, levels: Map<string, ValueLevel>, viewer: ValueViewer): unknown;
|
|
59
105
|
export {};
|