@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
|
@@ -280,6 +280,8 @@ declare class AddonsManager extends BaseManager {
|
|
|
280
280
|
private dashboardCards;
|
|
281
281
|
/** Name of the first domain addon loaded — only one is permitted */
|
|
282
282
|
private domainAddonName;
|
|
283
|
+
/** #1883: the enabled add-ons that did not load this start, with why — each reported once. */
|
|
284
|
+
private readonly loadFailures;
|
|
283
285
|
constructor(engine: WikiEngine);
|
|
284
286
|
/**
|
|
285
287
|
* Initialize the AddonsManager
|
|
@@ -351,6 +353,30 @@ declare class AddonsManager extends BaseManager {
|
|
|
351
353
|
* bundled/drop-in addon wins.
|
|
352
354
|
*/
|
|
353
355
|
private registerAddonFromDir;
|
|
356
|
+
/**
|
|
357
|
+
* Register an add-on that will not be loaded, so Admin → Add-ons shows it
|
|
358
|
+
* with the reason, and report it when it is enabled (#1883). The first
|
|
359
|
+
* add-on of a slug wins, as for a loadable one.
|
|
360
|
+
*/
|
|
361
|
+
private refuseAddon;
|
|
362
|
+
/**
|
|
363
|
+
* #1883 (operator, 2026-10-18): the one rule for an enabled add-on that does
|
|
364
|
+
* not load, whatever the reason. Admins get a notification at the most
|
|
365
|
+
* severe level naming it and the reason, which says how to fix it where the
|
|
366
|
+
* reason does. A site-critical add-on (`ngdpbase.addons.<slug>.site-critical`)
|
|
367
|
+
* also holds the whole site in maintenance mode through core's survivable
|
|
368
|
+
* path (#1152): visitors see only the configured public message, with no
|
|
369
|
+
* details, and administrators can still sign in and reach /admin. Once per
|
|
370
|
+
* add-on per start. Nothing is opened in its place.
|
|
371
|
+
*/
|
|
372
|
+
private recordLoadFailure;
|
|
373
|
+
/**
|
|
374
|
+
* #1883: an enabled add-on that discovery never found — its install failed
|
|
375
|
+
* (#1830), or only a stray folder answers it — did not load either.
|
|
376
|
+
*/
|
|
377
|
+
private reportUndiscoveredAddons;
|
|
378
|
+
/** A notification to administrators; best-effort, like recordSeedFailure. */
|
|
379
|
+
private notifyAdmins;
|
|
354
380
|
/**
|
|
355
381
|
* Why an addon cannot run on this core (#1757): its manifest's `minVersion`
|
|
356
382
|
* is newer than this release. Null when it can, or when it states none.
|
|
@@ -1052,6 +1052,15 @@ declare class AuthManager extends BaseManager {
|
|
|
1052
1052
|
unlockVaults(username: string, password: string): Promise<string | undefined>;
|
|
1053
1053
|
private vaultUnlocker;
|
|
1054
1054
|
private warnedNoUnlocker;
|
|
1055
|
+
/**
|
|
1056
|
+
* The stored spelling of a username a sign-in brought (#1882), or null when
|
|
1057
|
+
* no account has it. Lookups compare normalized (#1436), so "ADMIN" finds
|
|
1058
|
+
* admin's record; the session, the audit record, the roles and the vault
|
|
1059
|
+
* paths must carry the stored name, never the typed variant, which is a
|
|
1060
|
+
* roleless twin. The one rule, asked by every door a sign-in passes:
|
|
1061
|
+
* `authenticate`, `beginTwoStep` and `completeSignIn`.
|
|
1062
|
+
*/
|
|
1063
|
+
private storedUsername;
|
|
1055
1064
|
/** A fresh session for a sign-in (#1043); the one already on it ends first (#1626, #1670). */
|
|
1056
1065
|
private regenerate;
|
|
1057
1066
|
/**
|
|
@@ -1109,7 +1118,7 @@ declare class AuthManager extends BaseManager {
|
|
|
1109
1118
|
ip?: string;
|
|
1110
1119
|
userAgent?: string;
|
|
1111
1120
|
privateStoreHandle?: string;
|
|
1112
|
-
}): string
|
|
1121
|
+
}): Promise<string>;
|
|
1113
1122
|
/** The pending sign-in, for the browser that started it; null when gone, expired, or another browser. */
|
|
1114
1123
|
pendingSignIn(handle: string | undefined, binding: string | undefined): PendingSignIn | null;
|
|
1115
1124
|
/**
|
|
@@ -202,6 +202,12 @@ declare class BackgroundJobManager extends BaseManager {
|
|
|
202
202
|
/** Set once shutdown begins: no slot starts after it (#1717). */
|
|
203
203
|
private stopping;
|
|
204
204
|
private handedOff;
|
|
205
|
+
/**
|
|
206
|
+
* #1892: why no job runs — the application database is configured but could
|
|
207
|
+
* not be opened safely, and the site is in maintenance mode. Jobs still
|
|
208
|
+
* register; none starts, on a schedule or by hand, until a restart opens it.
|
|
209
|
+
*/
|
|
210
|
+
private held;
|
|
205
211
|
/** Each running job's abort, by run id. */
|
|
206
212
|
private controllers;
|
|
207
213
|
/** For each running scheduled run: write it as interrupted and let go of its slot. */
|
|
@@ -57,6 +57,12 @@ export interface ManagerBackupOptions {
|
|
|
57
57
|
directory: string;
|
|
58
58
|
/** The document's filename without `.json.gz` / `.json`. */
|
|
59
59
|
stem: string;
|
|
60
|
+
/**
|
|
61
|
+
* The backup a restore takes of the live data first (#1892 D2). A database
|
|
62
|
+
* that can't be opened is left out and recorded in the manager's data, not
|
|
63
|
+
* failed: the restore keeps its file beside the restored one instead.
|
|
64
|
+
*/
|
|
65
|
+
safetyBackup?: boolean;
|
|
60
66
|
}
|
|
61
67
|
/** What BackupManager hands each manager's `restore()` (#1723): who asked, and the directory the backup document was read from. */
|
|
62
68
|
export interface ManagerRestoreOptions {
|
|
@@ -60,10 +60,12 @@ declare class ConfigurationManager extends BaseManager {
|
|
|
60
60
|
private addonLayers;
|
|
61
61
|
/** #1559: add-on system-categories refused, and operator values an owner's declaration overrode; #1860: database declarations refused or overridden. */
|
|
62
62
|
private systemCategoryProblems;
|
|
63
|
-
/** #1559: add-ons that must not load because an entry they declare was refused, by slug, with why; #1860: or their database declaration. */
|
|
63
|
+
/** #1559: add-ons that must not load because an entry they declare was refused, by slug, with why; #1860: or their database declaration; #1883: or a site-critical value that is not a boolean. */
|
|
64
64
|
private refusedAddons;
|
|
65
65
|
/** #1860: every installed add-on's sound database declaration, on or off. */
|
|
66
66
|
private addonDatabases;
|
|
67
|
+
/** #1883: which add-ons put the site in maintenance mode when they fail to load, by slug. */
|
|
68
|
+
private siteCriticalAddons;
|
|
67
69
|
private environment;
|
|
68
70
|
private defaultConfigPath;
|
|
69
71
|
private customConfigPath;
|
|
@@ -246,6 +248,14 @@ declare class ConfigurationManager extends BaseManager {
|
|
|
246
248
|
* const appName = configManager.getProperty('ngdpbase.application-name', 'MyWiki');
|
|
247
249
|
*/
|
|
248
250
|
getProperty(key: string, defaultValue?: unknown): unknown;
|
|
251
|
+
/**
|
|
252
|
+
* #1889: a value as configured, every `$NAME` reference in it unresolved,
|
|
253
|
+
* for the one kind of reader that must see that a secret IS a reference:
|
|
254
|
+
* AuthManager refuses an outside provider whose client secret is written
|
|
255
|
+
* into configuration rather than named in the environment. Never for
|
|
256
|
+
* display: a literal secret would come back as itself.
|
|
257
|
+
*/
|
|
258
|
+
getUnresolvedProperty(key: string, defaultValue?: unknown): unknown;
|
|
249
259
|
/**
|
|
250
260
|
* #896: read a property from the SHIPPED defaults only, ignoring instance
|
|
251
261
|
* custom-config overrides. Used by vocabulary providers whose seed must not
|
|
@@ -282,6 +292,14 @@ declare class ConfigurationManager extends BaseManager {
|
|
|
282
292
|
* ({@link getAddonRefusal}). DatabaseManager is the reader.
|
|
283
293
|
*/
|
|
284
294
|
getAddonDatabaseDeclarations(): readonly AddonDatabaseDeclaration[];
|
|
295
|
+
/**
|
|
296
|
+
* #1883: whether this add-on's failure to load puts the whole site in
|
|
297
|
+
* maintenance mode — `ngdpbase.addons.<slug>.site-critical`, from the
|
|
298
|
+
* operator's file or else the add-on's own, read even when the add-on was
|
|
299
|
+
* refused. A value that is not exactly true or false counts as true (and
|
|
300
|
+
* refuses the add-on, {@link getAddonRefusal}).
|
|
301
|
+
*/
|
|
302
|
+
isAddonSiteCritical(slug: string): boolean;
|
|
285
303
|
/**
|
|
286
304
|
* #1813: what one enabled addon's own `config/default-config.json` declares,
|
|
287
305
|
* exactly as it was folded into the merge — not the merged view, so the
|
|
@@ -310,7 +328,10 @@ declare class ConfigurationManager extends BaseManager {
|
|
|
310
328
|
* **`$$literal` escape** — for the rare config value that genuinely
|
|
311
329
|
* starts with `$`. `"$$abc"` resolves to `"$abc"`.
|
|
312
330
|
*
|
|
313
|
-
*
|
|
331
|
+
* Inside a map or list (#1889), the bare `$VAR` form and the `$$` escape
|
|
332
|
+
* apply to every string, strictly, the error naming the full path
|
|
333
|
+
* (`key.map-key.field`); the brace form does not. Other non-string values
|
|
334
|
+
* pass through unchanged. Resolution happens at lookup
|
|
314
335
|
* time (per-`getProperty()` call), not at config load — so tests that
|
|
315
336
|
* mutate `process.env` mid-run see the new value on the next read.
|
|
316
337
|
*
|
|
@@ -322,6 +343,10 @@ declare class ConfigurationManager extends BaseManager {
|
|
|
322
343
|
* @throws When the bare `$VAR` form references an unset env var.
|
|
323
344
|
*/
|
|
324
345
|
private resolveEnvRef;
|
|
346
|
+
/** A string inside a map or list (#1889): `$$` escapes, a bare `$NAME` resolves strictly, anything else is itself. */
|
|
347
|
+
private resolveNestedRef;
|
|
348
|
+
/** The variable a bare `$NAME` reference names; throws, naming the key or path, when it is unset (#775). */
|
|
349
|
+
private readEnvRef;
|
|
325
350
|
/** Audit counters for the boot-time env-ref summary log (#775). */
|
|
326
351
|
private _envRefHitCount;
|
|
327
352
|
private _envRefMissCount;
|
|
@@ -46,6 +46,15 @@ export interface DatabaseBackupData {
|
|
|
46
46
|
name: string;
|
|
47
47
|
error: string;
|
|
48
48
|
}>;
|
|
49
|
+
/**
|
|
50
|
+
* #1892 D2: in a restore's safety backup taken while the application
|
|
51
|
+
* database can't be opened, the databases left out and why. The restore
|
|
52
|
+
* keeps their files beside the restored ones, as `*.pre-restore`.
|
|
53
|
+
*/
|
|
54
|
+
leftOut?: {
|
|
55
|
+
databases: string[];
|
|
56
|
+
reason: string;
|
|
57
|
+
};
|
|
49
58
|
}
|
|
50
59
|
/** What an add-on hands over to have its database opened and migrated. */
|
|
51
60
|
export interface AddonDatabaseOptions {
|
|
@@ -84,10 +93,31 @@ declare class DatabaseManager extends BaseManager {
|
|
|
84
93
|
private restoreAtStart;
|
|
85
94
|
/** Why this manager is degraded, one entry per cause, so one cause clearing leaves the others. */
|
|
86
95
|
private readonly faults;
|
|
96
|
+
/** Why the configured application database could not be opened safely (#1892); null otherwise. */
|
|
97
|
+
private unavailable;
|
|
87
98
|
constructor(engine: WikiEngine);
|
|
88
99
|
initialize(config?: Record<string, unknown>): Promise<void>;
|
|
89
100
|
/** Whether an application database is open. */
|
|
90
101
|
isEnabled(): boolean;
|
|
102
|
+
/**
|
|
103
|
+
* Why the configured application database is not open (#1892): it could not
|
|
104
|
+
* be opened safely, and the site is in maintenance mode. Null when it is
|
|
105
|
+
* open, or when none is configured (`ngdpbase.database.provider` `none`).
|
|
106
|
+
* A manager that needs the database asks this and holds its work.
|
|
107
|
+
*/
|
|
108
|
+
getUnavailableReason(): string | null;
|
|
109
|
+
/**
|
|
110
|
+
* #1892 (operator D3 rule, 2026-10-18): the application database can't be
|
|
111
|
+
* opened safely. The process stays up in maintenance mode through core's
|
|
112
|
+
* survivable path (#1152): visitors see only the public message (#1883),
|
|
113
|
+
* administrators can sign in and reach /admin, and `reportAtBoot()` sends
|
|
114
|
+
* them the reason as a critical notification once notifications exist.
|
|
115
|
+
*/
|
|
116
|
+
private markUnavailable;
|
|
117
|
+
/** The reason for a database that did not open, with the fix that fits the key (#1892). */
|
|
118
|
+
private static openFailure;
|
|
119
|
+
/** The refusal for anything that needs the application database while it is unavailable (#1892). */
|
|
120
|
+
private unavailableRefusal;
|
|
91
121
|
/**
|
|
92
122
|
* The open connection, for a row-storing provider's constructor. Throws when
|
|
93
123
|
* no database is configured, so a provider that needs one fails at boot with
|
|
@@ -180,7 +210,7 @@ declare class DatabaseManager extends BaseManager {
|
|
|
180
210
|
private fault;
|
|
181
211
|
private clearFault;
|
|
182
212
|
private notify;
|
|
183
|
-
/** Whether every open database passes SQLite's quick check; true when none is configured. */
|
|
213
|
+
/** Whether every open database passes SQLite's quick check; true when none is configured, false when the configured one could not be opened (#1892). */
|
|
184
214
|
integrityOk(): boolean;
|
|
185
215
|
/** Closes the add-on databases, then the application database. */
|
|
186
216
|
shutdown(): Promise<void>;
|
|
@@ -14,9 +14,11 @@
|
|
|
14
14
|
* when `NGDPBASE_DATABASE_KEY` is set (in the environment or the instance
|
|
15
15
|
* `.env`), never from config. Without a key it opens unencrypted and says so.
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
* filesystem, when the schema is from a newer build, when a migration
|
|
19
|
-
* or when the integrity check does
|
|
17
|
+
* The database is refused, naming the cause, when the file would sit on a
|
|
18
|
+
* network filesystem, when the schema is from a newer build, when a migration
|
|
19
|
+
* fails, when the key is missing or wrong, or when the integrity check does
|
|
20
|
+
* not pass. The process stays up (#1892): the site starts in maintenance mode
|
|
21
|
+
* and administrators are told the fix (`getUnavailableReason`).
|
|
20
22
|
*
|
|
21
23
|
* An add-on that keeps rows gets a database of its own the same way
|
|
22
24
|
* (`openAddonDatabase`, ngdp-accounting-addons#13): its own file beside the
|
|
@@ -45,6 +47,7 @@ import { recordAuditEvent } from '../utils/auditEvents.js';
|
|
|
45
47
|
import { AUDIT_EVENT } from '../utils/auditEventNames.js';
|
|
46
48
|
import logger from '../utils/logger.js';
|
|
47
49
|
import { ADDON_SLUG, ledgerPrefix } from '../utils/addonDatabaseRules.js';
|
|
50
|
+
import { sitePublicMessage } from '../utils/startupState.js';
|
|
48
51
|
import { applyStagedRestore, manifestPath, runOffThread, stagedPath, writeManifest } from '../providers/sqliteBackup.js';
|
|
49
52
|
/** The environment variable holding the SQLCipher key. Never a config key: config is readable from the admin screens. */
|
|
50
53
|
export const DATABASE_KEY_ENV = 'NGDPBASE_DATABASE_KEY';
|
|
@@ -111,6 +114,8 @@ class DatabaseManager extends BaseManager {
|
|
|
111
114
|
restoreAtStart = null;
|
|
112
115
|
/** Why this manager is degraded, one entry per cause, so one cause clearing leaves the others. */
|
|
113
116
|
faults = new Map();
|
|
117
|
+
/** Why the configured application database could not be opened safely (#1892); null otherwise. */
|
|
118
|
+
unavailable = null;
|
|
114
119
|
constructor(engine) {
|
|
115
120
|
super(engine);
|
|
116
121
|
}
|
|
@@ -133,15 +138,32 @@ class DatabaseManager extends BaseManager {
|
|
|
133
138
|
this.busyTimeoutMs = busy;
|
|
134
139
|
const file = configManager.getResolvedDataPath('ngdpbase.database.file', './data/ngdpbase.db');
|
|
135
140
|
const key = process.env[DATABASE_KEY_ENV] ?? '';
|
|
141
|
+
// Known even when it does not open (#1892): a staged restore stays visible and cancellable.
|
|
142
|
+
this.databaseFile = file;
|
|
136
143
|
// #1723: a staged restore goes in now, before any database opens.
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
144
|
+
try {
|
|
145
|
+
this.applyRestoreAtStart(file);
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
this.markUnavailable(`A staged database restore could not be put in place: ${err.message}. ` +
|
|
149
|
+
'No database was opened. Some databases may already be restored: each one moved is kept beside it as *.pre-restore, and the rest are still *.staged beside ' +
|
|
150
|
+
`${path.basename(manifestPath(file))}. Stop the server, put the files back as they should be, and restart (#1892)`);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
// #1892: a database that cannot be opened safely (no key or the wrong key
|
|
154
|
+
// on an encrypted file, a damaged file, a failed migration, a newer
|
|
155
|
+
// schema) does not stop the process: the site starts in maintenance mode.
|
|
156
|
+
// The provider checks integrity before it writes anything, and a failed
|
|
157
|
+
// migration is rolled back.
|
|
158
|
+
let provider;
|
|
159
|
+
try {
|
|
160
|
+
provider = new SqliteDatabaseProvider(file, key, DATABASE_MIGRATIONS, 'schema_migrations', { busyTimeoutMs: this.busyTimeoutMs });
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
this.markUnavailable(DatabaseManager.openFailure(file, err.message, key));
|
|
164
|
+
return;
|
|
142
165
|
}
|
|
143
166
|
this.provider = provider;
|
|
144
|
-
this.databaseFile = file;
|
|
145
167
|
const { applied, skipped } = provider.migrations;
|
|
146
168
|
logger.info(`[DatabaseManager] Opened ${file} (${key ? 'encrypted' : 'NOT encrypted'}, lock wait ${this.busyTimeoutMs} ms) — migrations applied ${applied.length}, already present ${skipped}`);
|
|
147
169
|
if (!key) {
|
|
@@ -157,6 +179,47 @@ class DatabaseManager extends BaseManager {
|
|
|
157
179
|
isEnabled() {
|
|
158
180
|
return this.provider !== null;
|
|
159
181
|
}
|
|
182
|
+
/**
|
|
183
|
+
* Why the configured application database is not open (#1892): it could not
|
|
184
|
+
* be opened safely, and the site is in maintenance mode. Null when it is
|
|
185
|
+
* open, or when none is configured (`ngdpbase.database.provider` `none`).
|
|
186
|
+
* A manager that needs the database asks this and holds its work.
|
|
187
|
+
*/
|
|
188
|
+
getUnavailableReason() {
|
|
189
|
+
return this.unavailable;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* #1892 (operator D3 rule, 2026-10-18): the application database can't be
|
|
193
|
+
* opened safely. The process stays up in maintenance mode through core's
|
|
194
|
+
* survivable path (#1152): visitors see only the public message (#1883),
|
|
195
|
+
* administrators can sign in and reach /admin, and `reportAtBoot()` sends
|
|
196
|
+
* them the reason as a critical notification once notifications exist.
|
|
197
|
+
*/
|
|
198
|
+
markUnavailable(reason) {
|
|
199
|
+
this.unavailable = reason;
|
|
200
|
+
this.provider = null;
|
|
201
|
+
logger.error(`[DatabaseManager] ${reason}`);
|
|
202
|
+
this.markFailed(reason, 'ngdpbase.database.file');
|
|
203
|
+
const configManager = this.engine.getManager('ConfigurationManager');
|
|
204
|
+
this.engine.blockConfiguration(reason, {
|
|
205
|
+
publicMessage: sitePublicMessage(configManager ? (k, d) => configManager.getProperty(k, d) : undefined)
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
/** The reason for a database that did not open, with the fix that fits the key (#1892). */
|
|
209
|
+
static openFailure(file, cause, key) {
|
|
210
|
+
const keyFix = key
|
|
211
|
+
? `If ${DATABASE_KEY_ENV} is not the key this database was made with, correct ${DATABASE_KEY_ENV} in <FAST_STORAGE>/.env (or the environment) and restart.`
|
|
212
|
+
: `${DATABASE_KEY_ENV} is not set: if the database is encrypted, set ${DATABASE_KEY_ENV} in <FAST_STORAGE>/.env (or the environment) and restart.`;
|
|
213
|
+
return `The application database ${file} could not be opened safely: ${cause.replace(/\.$/, '')}. ` +
|
|
214
|
+
'It was not opened unencrypted, and nothing further was migrated or written. ' +
|
|
215
|
+
`${keyFix} If the file itself is damaged or a migration failed, restore the databases from a backup on Admin → Backup ` +
|
|
216
|
+
'(applied at the next restart; the current file is kept beside it as *.pre-restore), or stop the server, put back a good copy ' +
|
|
217
|
+
'of the database file, and restart (#1892)';
|
|
218
|
+
}
|
|
219
|
+
/** The refusal for anything that needs the application database while it is unavailable (#1892). */
|
|
220
|
+
unavailableRefusal(what) {
|
|
221
|
+
return `The application database is not available, so ${what} — the critical notification about it says why and how to fix it (#1892)`;
|
|
222
|
+
}
|
|
160
223
|
/**
|
|
161
224
|
* The open connection, for a row-storing provider's constructor. Throws when
|
|
162
225
|
* no database is configured, so a provider that needs one fails at boot with
|
|
@@ -185,6 +248,8 @@ class DatabaseManager extends BaseManager {
|
|
|
185
248
|
* set; nothing is opened in the clear.
|
|
186
249
|
*/
|
|
187
250
|
openAddonDatabase(owner, options) {
|
|
251
|
+
if (this.unavailable)
|
|
252
|
+
throw new Error(this.unavailableRefusal(`add-on database '${owner}' is not opened`));
|
|
188
253
|
if (!this.provider || !this.databaseFile) {
|
|
189
254
|
throw new Error(`Add-on '${owner}' needs a database, but none is open — set ngdpbase.database.provider to 'sqlite' (ngdp-accounting-addons#13)`);
|
|
190
255
|
}
|
|
@@ -208,11 +273,8 @@ class DatabaseManager extends BaseManager {
|
|
|
208
273
|
if (path.resolve(file) === path.resolve(this.databaseFile)) {
|
|
209
274
|
throw new Error(`Add-on '${owner}' would open the application database itself (${file}) (ngdp-accounting-addons#13)`);
|
|
210
275
|
}
|
|
276
|
+
// Integrity is checked by the provider before anything is written (#1892).
|
|
211
277
|
const provider = new SqliteDatabaseProvider(file, key, options.migrations, options.ledgerTable, { busyTimeoutMs: this.busyTimeoutMs });
|
|
212
|
-
if (!provider.integrityOk()) {
|
|
213
|
-
provider.close();
|
|
214
|
-
throw new Error(`Add-on database ${file} failed its integrity check (ngdp-accounting-addons#13)`);
|
|
215
|
-
}
|
|
216
278
|
this.addonDatabases.set(owner, { provider, ledgers: new Set([options.ledgerTable]) });
|
|
217
279
|
const { applied, skipped } = provider.migrations;
|
|
218
280
|
logger.info(`[DatabaseManager] Opened ${file} for ${owner} (${key ? 'encrypted' : 'NOT encrypted'}) — ledger ${options.ledgerTable}, migrations applied ${applied.length}, already present ${skipped}`);
|
|
@@ -242,6 +304,9 @@ class DatabaseManager extends BaseManager {
|
|
|
242
304
|
for (const declaration of this.declarations()) {
|
|
243
305
|
if (declaration.declaredBy !== slug || !declaration.enabled)
|
|
244
306
|
continue;
|
|
307
|
+
// #1892: its database sits beside the application database, under the same key.
|
|
308
|
+
if (this.unavailable)
|
|
309
|
+
return this.unavailableRefusal(`add-on '${slug}', which declares database '${declaration.owner}', is not loaded`);
|
|
245
310
|
const refusal = DatabaseManager.keyRefusal(declaration, key);
|
|
246
311
|
if (refusal)
|
|
247
312
|
return refusal;
|
|
@@ -255,7 +320,7 @@ class DatabaseManager extends BaseManager {
|
|
|
255
320
|
static keyRefusal(declaration, key) {
|
|
256
321
|
if (declaration.encrypt !== 'required' || key)
|
|
257
322
|
return null;
|
|
258
|
-
return `Add-on '${declaration.declaredBy}' declares database '${declaration.owner}' with encrypt "required", but ${DATABASE_KEY_ENV} is not set — it is never opened unencrypted (#1860)`;
|
|
323
|
+
return `Add-on '${declaration.declaredBy}' declares database '${declaration.owner}' with encrypt "required", but ${DATABASE_KEY_ENV} is not set — it is never opened unencrypted. Set ${DATABASE_KEY_ENV} in the environment or the instance .env (<FAST_STORAGE>/.env) and restart (#1860, #1883)`;
|
|
259
324
|
}
|
|
260
325
|
// ---- Backup and restore (#1723) ------------------------------------------
|
|
261
326
|
/**
|
|
@@ -270,6 +335,18 @@ class DatabaseManager extends BaseManager {
|
|
|
270
335
|
*/
|
|
271
336
|
async backup(options) {
|
|
272
337
|
const base = { managerName: 'DatabaseManager', timestamp: new Date().toISOString() };
|
|
338
|
+
// #1892: never a backup that silently leaves the databases out. A
|
|
339
|
+
// restore's safety backup (D2) leaves them out and says so: they can't be
|
|
340
|
+
// read, and the restore keeps their files as *.pre-restore.
|
|
341
|
+
if (this.unavailable && this.databaseFile) {
|
|
342
|
+
if (!options?.safetyBackup)
|
|
343
|
+
throw new Error(this.unavailableRefusal('no database was backed up'));
|
|
344
|
+
const leftOut = this.backupSources(this.databaseFile).map((s) => s.name);
|
|
345
|
+
const note = `The databases (${leftOut.join(', ')}) are left out of this safety backup: the application database could not be opened. A restore keeps their files beside the restored ones as *.pre-restore (#1892)`;
|
|
346
|
+
logger.warn(`[DatabaseManager] ${note}`);
|
|
347
|
+
const data = { format: 1, databases: [], absent: [], failed: [], leftOut: { databases: leftOut, reason: this.unavailable } };
|
|
348
|
+
return { ...base, data, note };
|
|
349
|
+
}
|
|
273
350
|
if (!this.provider || !this.databaseFile) {
|
|
274
351
|
return { ...base, data: null, note: 'No application database (ngdpbase.database.provider is none)' };
|
|
275
352
|
}
|
|
@@ -342,7 +419,13 @@ class DatabaseManager extends BaseManager {
|
|
|
342
419
|
logger.info('[DatabaseManager] The backup holds no database; every database is left as it is (#1723)');
|
|
343
420
|
return;
|
|
344
421
|
}
|
|
345
|
-
|
|
422
|
+
// #1892 D2: a restore is how a damaged database is put right, so it is
|
|
423
|
+
// allowed while the application database can't be opened, but never
|
|
424
|
+
// without the key: what it stages would then be unencrypted.
|
|
425
|
+
if (this.unavailable && !(process.env[DATABASE_KEY_ENV] ?? '')) {
|
|
426
|
+
throw new Error(`The application database is not available and ${DATABASE_KEY_ENV} is not set, so nothing was staged: a restore now would put the databases back unencrypted. Set ${DATABASE_KEY_ENV} in <FAST_STORAGE>/.env (or the environment) and restart first; if that opens the database, no restore is needed (#1892)`);
|
|
427
|
+
}
|
|
428
|
+
if (!this.databaseFile || (!this.provider && !this.unavailable)) {
|
|
346
429
|
throw new Error("The backup holds databases, but this site has none open — set ngdpbase.database.provider to 'sqlite' first (#1723)");
|
|
347
430
|
}
|
|
348
431
|
if (!options?.directory) {
|
|
@@ -421,7 +504,10 @@ class DatabaseManager extends BaseManager {
|
|
|
421
504
|
writeManifest(manifestFile, manifest);
|
|
422
505
|
const names = stages.map((s) => s.name).join(', ');
|
|
423
506
|
logger.warn(`[DatabaseManager] Restore staged for ${names} — restart to apply it (#1723)`);
|
|
424
|
-
|
|
507
|
+
const unopened = this.unavailable
|
|
508
|
+
? ' The application database could not be opened, so that safety backup leaves the databases out; the current database files are kept as *.pre-restore instead.'
|
|
509
|
+
: '';
|
|
510
|
+
await this.notify('warning', 'A database restore is staged', `The databases ${names} from the backup taken ${manifest.backup} are staged. Restart the site to put them in place; the current files are kept as *.pre-restore.${manifest.safetyBackup ? ` The current data was backed up first, as ${manifest.safetyBackup}.` : ''}${unopened}`);
|
|
425
511
|
if (skipped.length > 0) {
|
|
426
512
|
const message = `The backup also holds ${skipped.join(', ')}, which no add-on on this site declares, so they were not restored. Install the add-on and restore again to bring them back.`;
|
|
427
513
|
logger.error(`[DatabaseManager] ${message}`);
|
|
@@ -476,6 +562,9 @@ class DatabaseManager extends BaseManager {
|
|
|
476
562
|
* notifications do.
|
|
477
563
|
*/
|
|
478
564
|
async reportAtBoot() {
|
|
565
|
+
if (this.unavailable) {
|
|
566
|
+
await this.notify('error', 'Critical: the application database is not available', `${this.unavailable}. Until then the site is in maintenance mode: visitors see a short notice with no details, and administrators can still sign in and use /admin.`);
|
|
567
|
+
}
|
|
479
568
|
const keyProblem = this.provider ? this.backupKeyProblem() : null;
|
|
480
569
|
if (keyProblem)
|
|
481
570
|
await this.notify('error', 'Databases are not safely backed up', keyProblem);
|
|
@@ -566,8 +655,10 @@ class DatabaseManager extends BaseManager {
|
|
|
566
655
|
logger.error(`[DatabaseManager] The notification '${title}' could not be sent (#1723):`, err);
|
|
567
656
|
}
|
|
568
657
|
}
|
|
569
|
-
/** Whether every open database passes SQLite's quick check; true when none is configured. */
|
|
658
|
+
/** Whether every open database passes SQLite's quick check; true when none is configured, false when the configured one could not be opened (#1892). */
|
|
570
659
|
integrityOk() {
|
|
660
|
+
if (this.unavailable)
|
|
661
|
+
return false;
|
|
571
662
|
if (this.provider && !this.provider.integrityOk())
|
|
572
663
|
return false;
|
|
573
664
|
for (const { provider } of this.addonDatabases.values()) {
|
|
@@ -35,6 +35,9 @@ declare class SqliteDatabaseProvider extends BaseDatabaseProvider<SqliteHandle>
|
|
|
35
35
|
constructor(file: string, key: string, ledger: Migration[], ledgerTable?: string, options?: {
|
|
36
36
|
busyTimeoutMs?: number;
|
|
37
37
|
});
|
|
38
|
+
private static unlock;
|
|
39
|
+
/** Read an existing file read-only: the key opens it and it passes the quick check, or this throws. */
|
|
40
|
+
private static probe;
|
|
38
41
|
/**
|
|
39
42
|
* Run a further migration ledger on this connection, recorded in its own
|
|
40
43
|
* table: an add-on that depends on the add-on owning this database brings
|
|
@@ -49,6 +52,8 @@ declare class SqliteDatabaseProvider extends BaseDatabaseProvider<SqliteHandle>
|
|
|
49
52
|
encrypted: boolean;
|
|
50
53
|
};
|
|
51
54
|
integrityOk(): boolean;
|
|
55
|
+
/** SQLite's quick check; throws what the driver throws (a wrong key: "file is not a database"). */
|
|
56
|
+
private quickCheckOk;
|
|
52
57
|
getDurability(): ProviderDurability;
|
|
53
58
|
close(): void;
|
|
54
59
|
}
|
|
@@ -36,14 +36,17 @@ class SqliteDatabaseProvider extends BaseDatabaseProvider {
|
|
|
36
36
|
super();
|
|
37
37
|
this.file = file;
|
|
38
38
|
refuseNetworkFilesystem(file);
|
|
39
|
+
// #1892: an existing file is first read through a read-only connection.
|
|
40
|
+
// A wrong or missing key, or a damaged file, fails there, and nothing is
|
|
41
|
+
// written: a read-write connection that fails to read still checkpoints
|
|
42
|
+
// the WAL into the file on close, or deletes a WAL it can't parse. Only
|
|
43
|
+
// the -shm index, which SQLite rebuilds from the WAL, may be rewritten.
|
|
44
|
+
if (existsSync(file))
|
|
45
|
+
SqliteDatabaseProvider.probe(file, key);
|
|
39
46
|
this.db = new Database(file, { timeout: options.busyTimeoutMs ?? DEFAULT_BUSY_TIMEOUT_MS });
|
|
40
47
|
this.encrypted = key !== '';
|
|
41
48
|
try {
|
|
42
|
-
|
|
43
|
-
this.db.pragma("cipher='sqlcipher'");
|
|
44
|
-
this.db.pragma(`key='${key.replace(/'/g, "''")}'`);
|
|
45
|
-
}
|
|
46
|
-
// The first read: a wrong key fails here, before anything is written.
|
|
49
|
+
SqliteDatabaseProvider.unlock(this.db, key);
|
|
47
50
|
this.db.pragma('journal_mode = WAL');
|
|
48
51
|
this.db.pragma('synchronous = FULL');
|
|
49
52
|
this.migrations = runMigrations(this.db, ledger, ledgerTable);
|
|
@@ -53,6 +56,26 @@ class SqliteDatabaseProvider extends BaseDatabaseProvider {
|
|
|
53
56
|
throw err;
|
|
54
57
|
}
|
|
55
58
|
}
|
|
59
|
+
static unlock(db, key) {
|
|
60
|
+
if (!key)
|
|
61
|
+
return;
|
|
62
|
+
db.pragma("cipher='sqlcipher'");
|
|
63
|
+
db.pragma(`key='${key.replace(/'/g, "''")}'`);
|
|
64
|
+
}
|
|
65
|
+
/** Read an existing file read-only: the key opens it and it passes the quick check, or this throws. */
|
|
66
|
+
static probe(file, key) {
|
|
67
|
+
const db = new Database(file, { readonly: true, fileMustExist: true });
|
|
68
|
+
try {
|
|
69
|
+
SqliteDatabaseProvider.unlock(db, key);
|
|
70
|
+
const rows = db.pragma('quick_check');
|
|
71
|
+
if (String(rows[0]?.quick_check ?? '').toLowerCase() !== 'ok') {
|
|
72
|
+
throw new Error(`The database ${file} failed its integrity check — nothing was migrated or written`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
db.close();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
56
79
|
/**
|
|
57
80
|
* Run a further migration ledger on this connection, recorded in its own
|
|
58
81
|
* table: an add-on that depends on the add-on owning this database brings
|
|
@@ -70,13 +93,17 @@ class SqliteDatabaseProvider extends BaseDatabaseProvider {
|
|
|
70
93
|
}
|
|
71
94
|
integrityOk() {
|
|
72
95
|
try {
|
|
73
|
-
|
|
74
|
-
return String(rows[0]?.quick_check ?? '').toLowerCase() === 'ok';
|
|
96
|
+
return this.quickCheckOk();
|
|
75
97
|
}
|
|
76
98
|
catch {
|
|
77
99
|
return false;
|
|
78
100
|
}
|
|
79
101
|
}
|
|
102
|
+
/** SQLite's quick check; throws what the driver throws (a wrong key: "file is not a database"). */
|
|
103
|
+
quickCheckOk() {
|
|
104
|
+
const rows = this.db.pragma('quick_check');
|
|
105
|
+
return String(rows[0]?.quick_check ?? '').toLowerCase() === 'ok';
|
|
106
|
+
}
|
|
80
107
|
getDurability() {
|
|
81
108
|
return { bufferedForMs: 0, bufferedRecords: 0, fsync: true };
|
|
82
109
|
}
|
|
@@ -102,10 +102,21 @@ export interface WikiEngine {
|
|
|
102
102
|
* the repair, instead of exiting and leaving the filesystem as the only way
|
|
103
103
|
* back. Only for values an administrator can fix through the admin UI; a
|
|
104
104
|
* failure of the machinery needed to serve that UI is fatal and still throws.
|
|
105
|
+
* Also a site-critical add-on that did not load (#1883, operator
|
|
106
|
+
* 2026-10-18): its fix may lie outside the UI (a key in the instance .env),
|
|
107
|
+
* and the admin notification says so.
|
|
105
108
|
*/
|
|
106
|
-
blockConfiguration(reason: string
|
|
109
|
+
blockConfiguration(reason: string, options?: {
|
|
110
|
+
publicMessage?: string;
|
|
111
|
+
}): void;
|
|
107
112
|
/** Configuration values that could not be used, in the order found (#1152). */
|
|
108
113
|
getBlockingConditions(): readonly string[];
|
|
114
|
+
/**
|
|
115
|
+
* What visitors see in place of the reasons, when a reason was recorded with
|
|
116
|
+
* a `publicMessage` (#1883: a site-critical add-on that did not load); null
|
|
117
|
+
* when every reason may be shown.
|
|
118
|
+
*/
|
|
119
|
+
getPublicBlockingMessage(): string | null;
|
|
109
120
|
/**
|
|
110
121
|
* Get all registered manager names
|
|
111
122
|
* @returns Array of manager names
|
|
@@ -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 { type AddonDatabaseDeclaration } from './addonDatabaseRules.js';
|
|
45
56
|
export declare const ADDONS_PATH_KEY = "ngdpbase.managers.addons-manager.addons-path";
|
|
@@ -78,6 +89,8 @@ export declare function discoverAddonDirs(base: Record<string, unknown>, cwd?: s
|
|
|
78
89
|
* rather than silently skip it.
|
|
79
90
|
*/
|
|
80
91
|
export declare function discoverAddonDefaults(base: Record<string, unknown>, cwd?: string, dataFolder?: string): AddonDefaults[];
|
|
92
|
+
/** How a problem line names an addon; AddonsManager matches it to fold a refusal into its load-failure notice (#1883). */
|
|
93
|
+
export declare function addonProblemSubject(slug: string, enabled: boolean): string;
|
|
81
94
|
export interface AddonDatabaseLayer {
|
|
82
95
|
/** Every sound declaration, of every installed addon, `enabled` as its addon is. */
|
|
83
96
|
declarations: AddonDatabaseDeclaration[];
|
|
@@ -92,6 +105,39 @@ export interface AddonDatabaseLayer {
|
|
|
92
105
|
* file: a value there for a declaration's key is ignored, and said so.
|
|
93
106
|
*/
|
|
94
107
|
export declare function addonDatabaseLayer(addons: readonly AddonDefaults[], custom: Record<string, unknown>): AddonDatabaseLayer;
|
|
108
|
+
export interface AddonSensitiveValuesLayer {
|
|
109
|
+
/** Addons with an entry for a key that is not their own, by slug, with why: never loaded. */
|
|
110
|
+
refused: Map<string, string>;
|
|
111
|
+
problems: string[];
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Which values an addon may classify (#1889): its own, `ngdpbase.addons.<slug>`
|
|
115
|
+
* and anything under it, top-level keys and paths alike. An entry for core's
|
|
116
|
+
* keys or another addon's could unhide them (`false`, or `sensitive` for a
|
|
117
|
+
* `secret`), so it refuses the addon, as a database declaration under another
|
|
118
|
+
* slug does (#1860): it is not loaded and none of its keys apply. Only an
|
|
119
|
+
* enabled addon's entries reach the merge, so only an enabled addon is checked.
|
|
120
|
+
*/
|
|
121
|
+
export declare function addonSensitiveValuesLayer(addons: readonly AddonDefaults[]): AddonSensitiveValuesLayer;
|
|
122
|
+
/** The configuration key that makes an addon site-critical (#1883). */
|
|
123
|
+
export declare function addonSiteCriticalKey(slug: string): string;
|
|
124
|
+
export interface AddonSiteCriticalLayer {
|
|
125
|
+
/** Each addon with a value, by slug: true when its failure to load puts the site in maintenance mode. */
|
|
126
|
+
siteCritical: Map<string, boolean>;
|
|
127
|
+
/** Addons whose value is not exactly `true` or `false`, by slug, with why: never loaded. */
|
|
128
|
+
refused: Map<string, string>;
|
|
129
|
+
problems: string[];
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Which addons are site-critical (#1883): the operator's value if the
|
|
133
|
+
* operator's file sets one, else the addon's own (only under its own slug),
|
|
134
|
+
* else not. The operator's file wins in both directions, as for every addon
|
|
135
|
+
* key. The value is exactly `true` or `false`; anything else (the string
|
|
136
|
+
* `"true"` included) refuses the addon and counts as site-critical, so a
|
|
137
|
+
* mistyped value fails closed. `custom` keys for an addon with no file, or
|
|
138
|
+
* one that is not installed, count too.
|
|
139
|
+
*/
|
|
140
|
+
export declare function addonSiteCriticalLayer(addons: readonly AddonDefaults[], custom: Record<string, unknown>): AddonSiteCriticalLayer;
|
|
95
141
|
/**
|
|
96
142
|
* shipped ⊕ addons ⊕ custom, in that order of precedence (custom wins), with
|
|
97
143
|
* the problems found in the addons' system-categories (#1559).
|
|
@@ -108,6 +154,7 @@ export declare function mergeWithAddonLayer<T extends Record<string, unknown>>(s
|
|
|
108
154
|
problems: string[];
|
|
109
155
|
refused: Map<string, string>;
|
|
110
156
|
databases: AddonDatabaseDeclaration[];
|
|
157
|
+
siteCritical: Map<string, boolean>;
|
|
111
158
|
};
|
|
112
159
|
/**
|
|
113
160
|
* The full merge for a caller with the two files already read (the manager,
|
|
@@ -119,7 +166,9 @@ export declare function mergeWithAddonLayer<T extends Record<string, unknown>>(s
|
|
|
119
166
|
* could not be read; `refused` names, by slug, each addon that must not load
|
|
120
167
|
* because an entry it declares was refused, and why (#1559), or because its
|
|
121
168
|
* database declaration was (#1860); `databases` is every sound database
|
|
122
|
-
* declaration of every installed addon, on or off (#1860)
|
|
169
|
+
* declaration of every installed addon, on or off (#1860); `siteCritical`
|
|
170
|
+
* says, by slug, which addons put the site in maintenance mode when they fail
|
|
171
|
+
* to load (#1883).
|
|
123
172
|
*/
|
|
124
173
|
export declare function mergeConfigWithAddons<T extends Record<string, unknown>>(shipped: T, custom: Partial<T>, cwd?: string, dataFolder?: string): {
|
|
125
174
|
merged: T;
|
|
@@ -128,6 +177,7 @@ export declare function mergeConfigWithAddons<T extends Record<string, unknown>>
|
|
|
128
177
|
problems: string[];
|
|
129
178
|
refused: Map<string, string>;
|
|
130
179
|
databases: AddonDatabaseDeclaration[];
|
|
180
|
+
siteCritical: Map<string, boolean>;
|
|
131
181
|
};
|
|
132
182
|
export interface MergedConfigFiles {
|
|
133
183
|
merged: Record<string, unknown>;
|