@jwilleke/ngdpbase-types 5.18.0 → 5.20.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.
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Encrypted, consistent copies of SQLite databases, and the staged restore
3
+ * that puts them back (#1723). Ported from yourPHR (`sqlite-backup.ts`,
4
+ * yourphr#461, #602, #608, #787, #866, #914), with these changes:
5
+ *
6
+ * - __One encrypted file per database__, not one file holding every
7
+ * database's tables. A restore needs no table-name split, and two databases
8
+ * may hold tables of the same name.
9
+ * - __Every database's snapshot is taken before any copying starts.__ Each
10
+ * source gets its own connection with its backup file attached; a read
11
+ * transaction is opened on every one of them, back to back, and only then
12
+ * are they copied. SQLite allows ten attached databases per connection, so
13
+ * one connection for all of them would cap a site at five databases; this
14
+ * has no cap. The live files are in WAL mode, so these readers never block
15
+ * the site's writers, and a write committed after the snapshot is in none of
16
+ * the copies.
17
+ * - `sqlite_sequence` (AUTOINCREMENT counters) and `user_version` are copied,
18
+ * so a restored table never hands out an id it handed out before.
19
+ * - Staging writes `<live>.staged` beside each live file, the path applying
20
+ * reads (yourphr#866: they were different directories), and is all or
21
+ * nothing: on any failure every staged file is removed.
22
+ * - Applying moves the live file's `-wal` and `-shm` aside with it
23
+ * (yourphr#866), so a leftover WAL can never replay onto the restored file.
24
+ *
25
+ * Every function here is synchronous SQLite and can take minutes on a large
26
+ * database: the managers call them through `runOffThread`, never on the thread
27
+ * that answers requests (yourphr#787, #914). Keys travel in `workerData`
28
+ * (memory), never argv or the environment.
29
+ */
30
+ import { chmodSync, copyFileSync, existsSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, readFileSync } from 'node:fs';
31
+ import { createRequire } from 'node:module';
32
+ import path from 'node:path';
33
+ import { Worker } from 'node:worker_threads';
34
+ /** The staged file for a live database: beside it, so staging and applying name the same path. */
35
+ export const stagedPath = (live) => `${live}.staged`;
36
+ /** SQLCipher key pragma escaping, as SqliteDatabaseProvider does it. */
37
+ function quoteKey(key) {
38
+ return `'${key.replace(/'/g, "''")}'`;
39
+ }
40
+ /** Open a file the way SqliteDatabaseProvider does: SQLCipher, keyed when a key is given. */
41
+ function openKeyed(Database, file, key) {
42
+ // Checked here, not with `fileMustExist`: SQLite opens an ATTACHed file with
43
+ // the main file's flags, and without CREATE the backup or staged file could
44
+ // not be made.
45
+ if (!existsSync(file))
46
+ throw new Error(`${file} does not exist`);
47
+ const db = new Database(file);
48
+ try {
49
+ db.pragma("cipher='sqlcipher'");
50
+ if (key)
51
+ db.pragma(`key=${quoteKey(key)}`);
52
+ // The first read: a wrong key fails here.
53
+ db.prepare('SELECT COUNT(*) AS n FROM sqlite_master').get();
54
+ return db;
55
+ }
56
+ catch (err) {
57
+ db.close();
58
+ throw err;
59
+ }
60
+ }
61
+ /**
62
+ * Copies every table (with its rows), index, trigger and view from one schema
63
+ * of the connection into another, inside the caller's transaction. This is
64
+ * sqlcipher_export(), spelled out: SQLite3MultipleCiphers does not ship it.
65
+ * Tables are created and filled before any index or trigger exists, so the
66
+ * copy fires no trigger.
67
+ */
68
+ function copyObjects(db, from, to) {
69
+ const all = db
70
+ .prepare(`SELECT type, name, sql FROM ${from}.sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' ORDER BY CASE type WHEN 'table' THEN 0 WHEN 'index' THEN 1 WHEN 'trigger' THEN 2 ELSE 3 END`)
71
+ .all();
72
+ // A virtual table (FTS5) owns shadow tables, <table>_data, _idx, _content,
73
+ // _docsize, _config: recreating it recreates them, and creating them by hand
74
+ // is refused. Their rows come back through the INSERT into the virtual table.
75
+ const virtual = all.filter((o) => /^CREATE VIRTUAL TABLE/i.test(o.sql)).map((o) => o.name);
76
+ const shadow = (name) => virtual.some((v) => name.startsWith(`${v}_`));
77
+ let tables = 0;
78
+ for (const object of all) {
79
+ if (shadow(object.name))
80
+ continue;
81
+ // CREATE TABLE x -> CREATE TABLE to.x: the one rewrite sqlcipher_export
82
+ // makes. sqlite_master SQL never carries a schema prefix.
83
+ const ddl = object.sql.replace(/^(CREATE (?:TABLE|INDEX|UNIQUE INDEX|TRIGGER|VIEW|VIRTUAL TABLE))\s+(?:IF NOT EXISTS\s+)?("[^"]+"|\[[^\]]+\]|`[^`]+`|\S+)/i, (_m, head, name) => `${head} ${to}.${name}`);
84
+ db.exec(ddl);
85
+ if (object.type !== 'table')
86
+ continue;
87
+ tables++;
88
+ const quoted = `"${object.name.replace(/"/g, '""')}"`;
89
+ if (virtual.includes(object.name)) {
90
+ // A virtual table's rowids are carried explicitly: SELECT * leaves them
91
+ // out, and the copy would be renumbered.
92
+ const cols = db.prepare(`SELECT name FROM ${from}.pragma_table_info(?)`).all(object.name)
93
+ .map((c) => `"${c.name.replace(/"/g, '""')}"`).join(', ');
94
+ db.exec(`INSERT INTO ${to}.${quoted} (rowid, ${cols}) SELECT rowid, ${cols} FROM ${from}.${quoted}`);
95
+ }
96
+ else {
97
+ db.exec(`INSERT INTO ${to}.${quoted} SELECT * FROM ${from}.${quoted}`);
98
+ }
99
+ }
100
+ // AUTOINCREMENT counters: without them a restored table could hand out an
101
+ // id it had already used for a deleted row.
102
+ const hasSequence = db.prepare(`SELECT 1 FROM ${from}.sqlite_master WHERE name = 'sqlite_sequence'`).get();
103
+ if (hasSequence && db.prepare(`SELECT 1 FROM ${to}.sqlite_master WHERE name = 'sqlite_sequence'`).get()) {
104
+ db.exec(`DELETE FROM ${to}.sqlite_sequence`);
105
+ db.exec(`INSERT INTO ${to}.sqlite_sequence (name, seq) SELECT name, seq FROM ${from}.sqlite_sequence`);
106
+ }
107
+ const userVersion = db.pragma(`${from}.user_version`, { simple: true });
108
+ if (userVersion)
109
+ db.pragma(`${to}.user_version = ${Number(userVersion)}`);
110
+ return tables;
111
+ }
112
+ /** SQLite's full integrity check of an open database; throws naming what it found. */
113
+ function requireIntegrity(db, what) {
114
+ const result = String(db.pragma('integrity_check')[0]?.integrity_check ?? 'no result');
115
+ if (result !== 'ok')
116
+ throw new Error(`${what} failed its integrity check: ${result}`);
117
+ }
118
+ /**
119
+ * Write an encrypted copy of each database under `backupKey`. A database that
120
+ * cannot be opened or copied is named in `failed`, its partial file removed;
121
+ * the others are still written. A target that already exists is never
122
+ * overwritten.
123
+ */
124
+ export function exportDatabasesSync(Database, exports, backupKey) {
125
+ if (!backupKey.trim()) {
126
+ throw new Error('a backup key is required: database backups are always encrypted');
127
+ }
128
+ const outcome = { written: [], failed: [] };
129
+ const open = [];
130
+ const fail = (job, err, db) => {
131
+ try {
132
+ db?.close();
133
+ }
134
+ catch { /* already failing */ }
135
+ rmSync(job.target, { force: true });
136
+ outcome.failed.push({ name: job.name, error: err.message });
137
+ };
138
+ // 1. A connection per database, its backup file attached under the backup key.
139
+ for (const job of exports) {
140
+ if (existsSync(job.target)) {
141
+ outcome.failed.push({ name: job.name, error: `${path.basename(job.target)} already exists; a backup never overwrites one` });
142
+ continue;
143
+ }
144
+ let db;
145
+ try {
146
+ db = openKeyed(Database, job.source, job.sourceKey);
147
+ db.prepare(`ATTACH DATABASE ? AS backup KEY ${quoteKey(backupKey)}`).run(job.target);
148
+ open.push({ job, db });
149
+ }
150
+ catch (err) {
151
+ fail(job, err, db);
152
+ }
153
+ }
154
+ // 2. Every snapshot, back to back, before any copying: one point in time.
155
+ const reading = [];
156
+ for (const entry of open) {
157
+ try {
158
+ entry.db.exec('BEGIN');
159
+ entry.db.prepare('SELECT COUNT(*) AS n FROM main.sqlite_master').get();
160
+ reading.push(entry);
161
+ }
162
+ catch (err) {
163
+ fail(entry.job, err, entry.db);
164
+ }
165
+ }
166
+ // 3. Copy each, then close and prove the copy opens under the backup key.
167
+ for (const { job, db } of reading) {
168
+ try {
169
+ const tables = copyObjects(db, 'main', 'backup');
170
+ db.exec('COMMIT');
171
+ db.close();
172
+ const check = openKeyed(Database, job.target, backupKey);
173
+ try {
174
+ requireIntegrity(check, `the backup of ${job.name}`);
175
+ }
176
+ finally {
177
+ check.close();
178
+ }
179
+ chmodSync(job.target, 0o600);
180
+ outcome.written.push({ name: job.name, target: job.target, sizeBytes: statSync(job.target).size, tables });
181
+ }
182
+ catch (err) {
183
+ try {
184
+ if (db.inTransaction)
185
+ db.exec('ROLLBACK');
186
+ }
187
+ catch { /* closing anyway */ }
188
+ fail(job, err, db.open ? db : undefined);
189
+ }
190
+ }
191
+ return outcome;
192
+ }
193
+ /**
194
+ * Stage a restore: each backup file is opened under the backup key,
195
+ * integrity-checked, and copied into `<live>.staged` under the target
196
+ * database's key, which is then opened and checked too. All or nothing: if
197
+ * any database fails, every staged file is removed and the error names it.
198
+ * Live files are never touched.
199
+ */
200
+ export function stageDatabasesSync(Database, stages, backupKey) {
201
+ if (!backupKey.trim()) {
202
+ throw new Error('a backup key is required to read a database backup');
203
+ }
204
+ const outcome = { staged: [] };
205
+ try {
206
+ for (const stage of stages) {
207
+ const staged = stagedPath(stage.live);
208
+ rmSync(staged, { force: true });
209
+ let db;
210
+ try {
211
+ db = openKeyed(Database, stage.backupFile, backupKey);
212
+ }
213
+ catch (err) {
214
+ throw new Error(`the backup of ${stage.name} cannot be read — wrong backup key, or not a backup: ${err.message}`, { cause: err });
215
+ }
216
+ let tables;
217
+ try {
218
+ requireIntegrity(db, `the backup of ${stage.name}`);
219
+ db.prepare(`ATTACH DATABASE ? AS staged KEY ${quoteKey(stage.targetKey)}`).run(staged);
220
+ db.exec('BEGIN');
221
+ try {
222
+ tables = copyObjects(db, 'main', 'staged');
223
+ db.exec('COMMIT');
224
+ }
225
+ catch (err) {
226
+ db.exec('ROLLBACK');
227
+ throw err;
228
+ }
229
+ }
230
+ finally {
231
+ db.close();
232
+ }
233
+ const check = openKeyed(Database, staged, stage.targetKey);
234
+ try {
235
+ requireIntegrity(check, `the staged copy of ${stage.name}`);
236
+ }
237
+ finally {
238
+ check.close();
239
+ }
240
+ chmodSync(staged, 0o600);
241
+ outcome.staged.push({ name: stage.name, staged, live: stage.live, tables });
242
+ }
243
+ return outcome;
244
+ }
245
+ catch (err) {
246
+ for (const stage of stages)
247
+ rmSync(stagedPath(stage.live), { force: true });
248
+ throw err;
249
+ }
250
+ }
251
+ export function runOffThread(job, timeoutMs) {
252
+ // Compiled, the worker is the .js beside this file. From source (vitest,
253
+ // tsx) it is the .ts, which needs tsx's loader.
254
+ const fromSource = import.meta.url.endsWith('.ts');
255
+ const entry = new URL(fromSource ? './sqliteBackupWorker.ts' : './sqliteBackupWorker.js', import.meta.url);
256
+ return new Promise((resolve, reject) => {
257
+ const driver = createRequire(import.meta.url).resolve('better-sqlite3-multiple-ciphers');
258
+ const workerData = { ...job, driver };
259
+ const worker = new Worker(entry, { workerData, ...(fromSource ? { execArgv: ['--import', 'tsx'] } : {}) });
260
+ let settled = false;
261
+ const settle = (fn) => {
262
+ if (settled)
263
+ return;
264
+ settled = true;
265
+ clearTimeout(timer);
266
+ fn();
267
+ };
268
+ const timer = setTimeout(() => {
269
+ settle(() => reject(new Error(`the database ${job.kind === 'export' ? 'backup' : 'restore'} did not finish within ${timeoutMs} ms (ngdpbase.database.backup.worker-timeout-ms) and was stopped`)));
270
+ void worker.terminate();
271
+ }, timeoutMs);
272
+ worker.once('message', (m) => {
273
+ settle(() => (m.ok ? resolve(m.result) : reject(new Error(m.error))));
274
+ });
275
+ worker.once('error', (err) => settle(() => reject(err)));
276
+ worker.once('exit', (code) => settle(() => reject(new Error(`the database backup worker exited (code ${code}) without a result`))));
277
+ });
278
+ }
279
+ /** Where the manifest of a staged restore lives: beside the application database. */
280
+ export const manifestPath = (applicationFile) => `${applicationFile}.restore.json`;
281
+ export function writeManifest(file, manifest) {
282
+ const tmp = `${file}.tmp`;
283
+ writeFileSync(tmp, JSON.stringify(manifest, null, 2) + '\n', { mode: 0o600 });
284
+ renameSync(tmp, file);
285
+ }
286
+ /**
287
+ * Apply a staged restore at start, before any database opens. Each live file,
288
+ * with its `-wal` and `-shm`, steps aside as `*.pre-restore` (`-wal`/`-shm`
289
+ * beside it, so the kept copy still opens as it was), and the staged file
290
+ * takes its name. A rename, never a write into a live database.
291
+ *
292
+ * All or nothing: if any database the manifest names is not staged, nothing
293
+ * is applied; what is staged is renamed `*.incomplete`, so it is neither
294
+ * applied nor retried. A staged file with no manifest (staging was
295
+ * interrupted) is set aside the same way. `allowed` says which live paths may
296
+ * be replaced, so a tampered manifest cannot rename an arbitrary file.
297
+ */
298
+ export function applyStagedRestore(applicationFile, allowed) {
299
+ const manifestFile = manifestPath(applicationFile);
300
+ const dir = path.dirname(applicationFile);
301
+ const stray = () => (existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.db.staged')).map((f) => path.join(dir, f)) : []);
302
+ if (!existsSync(manifestFile)) {
303
+ const leftovers = stray();
304
+ if (leftovers.length === 0)
305
+ return null;
306
+ for (const f of leftovers)
307
+ move(f, `${f}.incomplete`);
308
+ return { applied: [], refused: `staged database files with no restore manifest (an interrupted staging): ${leftovers.map((f) => path.basename(f)).join(', ')} — set aside as *.incomplete` };
309
+ }
310
+ const setAside = (reason) => {
311
+ for (const f of stray())
312
+ move(f, `${f}.incomplete`);
313
+ move(manifestFile, `${manifestFile}.incomplete`);
314
+ return { applied: [], refused: reason };
315
+ };
316
+ let manifest;
317
+ try {
318
+ manifest = JSON.parse(readFileSync(manifestFile, 'utf8'));
319
+ }
320
+ catch (err) {
321
+ return setAside(`the restore manifest cannot be read: ${err.message}`);
322
+ }
323
+ const entries = Array.isArray(manifest?.databases) ? manifest.databases : [];
324
+ const bad = entries.find((e) => !allowed(e.live) || e.staged !== stagedPath(e.live));
325
+ if (entries.length === 0 || bad) {
326
+ return setAside(bad ? `the restore manifest names ${bad.live}, which is not a database of this site` : 'the restore manifest names no database');
327
+ }
328
+ const missing = entries.filter((e) => !existsSync(e.staged));
329
+ if (missing.length > 0) {
330
+ return setAside(`only ${entries.length - missing.length} of ${entries.length} databases were staged (missing: ${missing.map((e) => e.name).join(', ')}), and they belong together`);
331
+ }
332
+ const applied = [];
333
+ for (const entry of entries) {
334
+ const kept = `${entry.live}.pre-restore`;
335
+ // A previous restore's kept WAL must not pair with this restore's kept file.
336
+ for (const sibling of [`${kept}-wal`, `${kept}-shm`])
337
+ rmSync(sibling, { force: true });
338
+ if (existsSync(entry.live)) {
339
+ move(entry.live, kept);
340
+ for (const suffix of ['-wal', '-shm']) {
341
+ if (existsSync(`${entry.live}${suffix}`))
342
+ move(`${entry.live}${suffix}`, `${kept}${suffix}`);
343
+ }
344
+ }
345
+ // Never a WAL beside the restored file that is not its own.
346
+ for (const suffix of ['-wal', '-shm'])
347
+ rmSync(`${entry.live}${suffix}`, { force: true });
348
+ move(entry.staged, entry.live);
349
+ applied.push({ name: entry.name, live: entry.live, kept });
350
+ }
351
+ unlinkSync(manifestFile);
352
+ return { applied };
353
+ }
354
+ /** A rename; a copy and unlink when the two paths are on different devices. */
355
+ function move(from, to) {
356
+ try {
357
+ renameSync(from, to);
358
+ }
359
+ catch (err) {
360
+ if (err.code !== 'EXDEV')
361
+ throw err;
362
+ copyFileSync(from, to);
363
+ unlinkSync(from);
364
+ }
365
+ }
@@ -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): void;
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,12 @@
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`.
43
49
  */
44
50
  import { type AddonDatabaseDeclaration } from './addonDatabaseRules.js';
45
51
  export declare const ADDONS_PATH_KEY = "ngdpbase.managers.addons-manager.addons-path";
@@ -78,6 +84,8 @@ export declare function discoverAddonDirs(base: Record<string, unknown>, cwd?: s
78
84
  * rather than silently skip it.
79
85
  */
80
86
  export declare function discoverAddonDefaults(base: Record<string, unknown>, cwd?: string, dataFolder?: string): AddonDefaults[];
87
+ /** How a problem line names an addon; AddonsManager matches it to fold a refusal into its load-failure notice (#1883). */
88
+ export declare function addonProblemSubject(slug: string, enabled: boolean): string;
81
89
  export interface AddonDatabaseLayer {
82
90
  /** Every sound declaration, of every installed addon, `enabled` as its addon is. */
83
91
  declarations: AddonDatabaseDeclaration[];
@@ -92,6 +100,25 @@ export interface AddonDatabaseLayer {
92
100
  * file: a value there for a declaration's key is ignored, and said so.
93
101
  */
94
102
  export declare function addonDatabaseLayer(addons: readonly AddonDefaults[], custom: Record<string, unknown>): AddonDatabaseLayer;
103
+ /** The configuration key that makes an addon site-critical (#1883). */
104
+ export declare function addonSiteCriticalKey(slug: string): string;
105
+ export interface AddonSiteCriticalLayer {
106
+ /** Each addon with a value, by slug: true when its failure to load puts the site in maintenance mode. */
107
+ siteCritical: Map<string, boolean>;
108
+ /** Addons whose value is not exactly `true` or `false`, by slug, with why: never loaded. */
109
+ refused: Map<string, string>;
110
+ problems: string[];
111
+ }
112
+ /**
113
+ * Which addons are site-critical (#1883): the operator's value if the
114
+ * operator's file sets one, else the addon's own (only under its own slug),
115
+ * else not. The operator's file wins in both directions, as for every addon
116
+ * key. The value is exactly `true` or `false`; anything else (the string
117
+ * `"true"` included) refuses the addon and counts as site-critical, so a
118
+ * mistyped value fails closed. `custom` keys for an addon with no file, or
119
+ * one that is not installed, count too.
120
+ */
121
+ export declare function addonSiteCriticalLayer(addons: readonly AddonDefaults[], custom: Record<string, unknown>): AddonSiteCriticalLayer;
95
122
  /**
96
123
  * shipped ⊕ addons ⊕ custom, in that order of precedence (custom wins), with
97
124
  * the problems found in the addons' system-categories (#1559).
@@ -108,6 +135,7 @@ export declare function mergeWithAddonLayer<T extends Record<string, unknown>>(s
108
135
  problems: string[];
109
136
  refused: Map<string, string>;
110
137
  databases: AddonDatabaseDeclaration[];
138
+ siteCritical: Map<string, boolean>;
111
139
  };
112
140
  /**
113
141
  * The full merge for a caller with the two files already read (the manager,
@@ -119,7 +147,9 @@ export declare function mergeWithAddonLayer<T extends Record<string, unknown>>(s
119
147
  * could not be read; `refused` names, by slug, each addon that must not load
120
148
  * because an entry it declares was refused, and why (#1559), or because its
121
149
  * database declaration was (#1860); `databases` is every sound database
122
- * declaration of every installed addon, on or off (#1860).
150
+ * declaration of every installed addon, on or off (#1860); `siteCritical`
151
+ * says, by slug, which addons put the site in maintenance mode when they fail
152
+ * to load (#1883).
123
153
  */
124
154
  export declare function mergeConfigWithAddons<T extends Record<string, unknown>>(shipped: T, custom: Partial<T>, cwd?: string, dataFolder?: string): {
125
155
  merged: T;
@@ -128,6 +158,7 @@ export declare function mergeConfigWithAddons<T extends Record<string, unknown>>
128
158
  problems: string[];
129
159
  refused: Map<string, string>;
130
160
  databases: AddonDatabaseDeclaration[];
161
+ siteCritical: Map<string, boolean>;
131
162
  };
132
163
  export interface MergedConfigFiles {
133
164
  merged: Record<string, unknown>;
@@ -40,6 +40,12 @@
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`.
43
49
  */
44
50
  import fs from 'fs';
45
51
  import path from 'path';
@@ -127,6 +133,10 @@ export function discoverAddonDefaults(base, cwd = process.cwd(), dataFolder = in
127
133
  }
128
134
  return out;
129
135
  }
136
+ /** How a problem line names an addon; AddonsManager matches it to fold a refusal into its load-failure notice (#1883). */
137
+ export function addonProblemSubject(slug, enabled) {
138
+ return `add-on '${slug}'${enabled ? '' : ' (turned off)'}`;
139
+ }
130
140
  function vaultIdOf(entry) {
131
141
  return parsePrivatestore(entry?.storageLocation?.privatestore)?.vaultId;
132
142
  }
@@ -154,7 +164,7 @@ function addonCategoryLayer(shipped, custom, addons) {
154
164
  const declared = addon.defaults[SYSTEM_CATEGORY_KEY];
155
165
  if (declared === undefined)
156
166
  continue;
157
- const who = `add-on '${addon.slug}'${addon.enabled ? '' : ' (turned off)'}`;
167
+ const who = addonProblemSubject(addon.slug, addon.enabled);
158
168
  if (!isPlainObject(declared)) {
159
169
  const reason = `${SYSTEM_CATEGORY_KEY} must be a map of entries`;
160
170
  layer.problems.push(`${who}: ${reason}; it is refused and the add-on is not loaded`);
@@ -246,7 +256,7 @@ export function addonDatabaseLayer(addons, custom) {
246
256
  }
247
257
  }
248
258
  if (reasons.length > 0) {
249
- const who = `add-on '${addon.slug}'${addon.enabled ? '' : ' (turned off)'}`;
259
+ const who = addonProblemSubject(addon.slug, addon.enabled);
250
260
  layer.problems.push(`${who}: its database declaration is refused (${reasons.join('; ')}); the add-on is not loaded`);
251
261
  layer.refused.set(addon.slug, `its database declaration was refused: ${reasons.join('; ')}`);
252
262
  continue;
@@ -262,6 +272,45 @@ export function addonDatabaseLayer(addons, custom) {
262
272
  }
263
273
  return layer;
264
274
  }
275
+ /** The configuration key that makes an addon site-critical (#1883). */
276
+ export function addonSiteCriticalKey(slug) {
277
+ return `ngdpbase.addons.${slug}.site-critical`;
278
+ }
279
+ /**
280
+ * Which addons are site-critical (#1883): the operator's value if the
281
+ * operator's file sets one, else the addon's own (only under its own slug),
282
+ * else not. The operator's file wins in both directions, as for every addon
283
+ * key. The value is exactly `true` or `false`; anything else (the string
284
+ * `"true"` included) refuses the addon and counts as site-critical, so a
285
+ * mistyped value fails closed. `custom` keys for an addon with no file, or
286
+ * one that is not installed, count too.
287
+ */
288
+ export function addonSiteCriticalLayer(addons, custom) {
289
+ const layer = { siteCritical: new Map(), refused: new Map(), problems: [] };
290
+ const take = (slug, enabled, own) => {
291
+ const key = addonSiteCriticalKey(slug);
292
+ const fromSite = custom[key] !== undefined;
293
+ const value = fromSite ? custom[key] : own;
294
+ if (value === undefined)
295
+ return;
296
+ if (typeof value === 'boolean') {
297
+ layer.siteCritical.set(slug, value);
298
+ return;
299
+ }
300
+ 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`;
301
+ layer.siteCritical.set(slug, true);
302
+ layer.refused.set(slug, reason);
303
+ layer.problems.push(`${addonProblemSubject(slug, enabled)}: ${reason}; the add-on is not loaded`);
304
+ };
305
+ for (const addon of addons)
306
+ take(addon.slug, addon.enabled, addon.defaults[addonSiteCriticalKey(addon.slug)]);
307
+ for (const key of Object.keys(custom)) {
308
+ const slug = /^ngdpbase\.addons\.([^.]+)\.site-critical$/.exec(key)?.[1];
309
+ if (slug && !layer.siteCritical.has(slug))
310
+ take(slug, custom[`ngdpbase.addons.${slug}.enabled`] === true, undefined);
311
+ }
312
+ return layer;
313
+ }
265
314
  /**
266
315
  * The owner's declaration stands (operator, 2026-09-29): for each entry an
267
316
  * addon declared, the fields in OWNER_DECIDED_FIELDS are the addon's, never
@@ -310,8 +359,10 @@ export function mergeWithAddonLayer(shipped, addons, custom) {
310
359
  const customCategories = custom[SYSTEM_CATEGORY_KEY];
311
360
  const layer = addonCategoryLayer(shipped[SYSTEM_CATEGORY_KEY], customCategories, addons);
312
361
  const databaseLayer = addonDatabaseLayer(addons, custom);
362
+ const criticalLayer = addonSiteCriticalLayer(addons, custom);
363
+ const siteCritical = criticalLayer.siteCritical;
313
364
  const refused = new Map(layer.refused);
314
- for (const [slug, reason] of databaseLayer.refused)
365
+ for (const [slug, reason] of [...databaseLayer.refused, ...criticalLayer.refused])
315
366
  refused.set(slug, refused.has(slug) ? `${refused.get(slug)}; ${reason}` : reason);
316
367
  const databases = databaseLayer.declarations.map((d) => ({ ...d, enabled: d.enabled && !refused.has(d.declaredBy) }));
317
368
  const withAddons = addons
@@ -325,14 +376,14 @@ export function mergeWithAddonLayer(shipped, addons, custom) {
325
376
  return deepMergeObjects(acc, rest);
326
377
  }, shipped);
327
378
  if (Object.keys(layer.entries).length === 0) {
328
- return { merged: deepMergeConfigs(withAddons, custom), problems: [...layer.problems, ...databaseLayer.problems], refused, databases };
379
+ return { merged: deepMergeConfigs(withAddons, custom), problems: [...layer.problems, ...databaseLayer.problems, ...criticalLayer.problems], refused, databases, siteCritical };
329
380
  }
330
381
  const shippedCategories = isPlainObject(withAddons[SYSTEM_CATEGORY_KEY]) ? withAddons[SYSTEM_CATEGORY_KEY] : {};
331
382
  const layered = { ...withAddons, [SYSTEM_CATEGORY_KEY]: deepMergeObjects(shippedCategories, layer.entries) };
332
383
  const merged = deepMergeObjects(layered, custom);
333
384
  const stood = ownerDeclarationsStand(merged[SYSTEM_CATEGORY_KEY], layer, customCategories);
334
385
  merged[SYSTEM_CATEGORY_KEY] = stood.categories;
335
- return { merged: merged, problems: [...layer.problems, ...stood.problems, ...databaseLayer.problems], refused, databases };
386
+ return { merged: merged, problems: [...layer.problems, ...stood.problems, ...databaseLayer.problems, ...criticalLayer.problems], refused, databases, siteCritical };
336
387
  }
337
388
  /**
338
389
  * The full merge for a caller with the two files already read (the manager,
@@ -344,12 +395,14 @@ export function mergeWithAddonLayer(shipped, addons, custom) {
344
395
  * could not be read; `refused` names, by slug, each addon that must not load
345
396
  * because an entry it declares was refused, and why (#1559), or because its
346
397
  * database declaration was (#1860); `databases` is every sound database
347
- * declaration of every installed addon, on or off (#1860).
398
+ * declaration of every installed addon, on or off (#1860); `siteCritical`
399
+ * says, by slug, which addons put the site in maintenance mode when they fail
400
+ * to load (#1883).
348
401
  */
349
402
  export function mergeConfigWithAddons(shipped, custom, cwd = process.cwd(), dataFolder = instanceDataFolder()) {
350
403
  const base = deepMergeConfigs(shipped, custom);
351
404
  const all = discoverAddonDefaults(base, cwd, dataFolder);
352
- const { merged, problems, refused, databases } = mergeWithAddonLayer(shipped, all, custom);
405
+ const { merged, problems, refused, databases, siteCritical } = mergeWithAddonLayer(shipped, all, custom);
353
406
  const unreadable = all
354
407
  .filter((a) => !a.enabled && a.error)
355
408
  .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 +412,8 @@ export function mergeConfigWithAddons(shipped, custom, cwd = process.cwd(), data
359
412
  addons: all.filter((a) => a.enabled && !refused.has(a.slug)),
360
413
  problems: [...unreadable, ...problems],
361
414
  refused,
362
- databases
415
+ databases,
416
+ siteCritical
363
417
  };
364
418
  }
365
419
  /**
@@ -34,6 +34,12 @@ export declare const AUDIT_EVENT: {
34
34
  readonly AUTHORIZATION_DENY: "authorization-deny";
35
35
  /** A full backup written; where it went and who asked */
36
36
  readonly BACKUP_CREATE: "backup-create";
37
+ /** A restore of a backup's databases started from Admin → Backup: which backup and who asked; recorded before the fresh backup and the staging, which cannot proceed without it (#1723) */
38
+ readonly BACKUP_RESTORE: "backup-restore";
39
+ /** A staged database restore cancelled before the restart applied it: which backup, who staged it and who cancelled; recorded first, which cannot proceed without it (#1723) */
40
+ readonly BACKUP_RESTORE_CANCEL: "backup-restore-cancel";
41
+ /** A staged database restore replaced by another before the restart applied it: which backup replaced which, and who asked; recorded first, which cannot proceed without it (#1723) */
42
+ readonly BACKUP_RESTORE_REPLACE: "backup-restore-replace";
37
43
  /** Comment added to a page — user content written on someone's behalf, the page-edit class (#1232) */
38
44
  readonly COMMENT_CREATE: "comment-create";
39
45
  /** Comment marked deleted; who removed it and whose it was (#1232) */
@@ -41,6 +41,12 @@ export const AUDIT_EVENT = {
41
41
  AUTHORIZATION_DENY: 'authorization-deny',
42
42
  /** A full backup written; where it went and who asked */
43
43
  BACKUP_CREATE: 'backup-create',
44
+ /** A restore of a backup's databases started from Admin → Backup: which backup and who asked; recorded before the fresh backup and the staging, which cannot proceed without it (#1723) */
45
+ BACKUP_RESTORE: 'backup-restore',
46
+ /** A staged database restore cancelled before the restart applied it: which backup, who staged it and who cancelled; recorded first, which cannot proceed without it (#1723) */
47
+ BACKUP_RESTORE_CANCEL: 'backup-restore-cancel',
48
+ /** A staged database restore replaced by another before the restart applied it: which backup replaced which, and who asked; recorded first, which cannot proceed without it (#1723) */
49
+ BACKUP_RESTORE_REPLACE: 'backup-restore-replace',
44
50
  /** Comment added to a page — user content written on someone's behalf, the page-edit class (#1232) */
45
51
  COMMENT_CREATE: 'comment-create',
46
52
  /** Comment marked deleted; who removed it and whose it was (#1232) */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilleke/ngdpbase-types",
3
- "version": "5.18.0",
3
+ "version": "5.20.0",
4
4
  "description": "TypeScript types for ngdpbase add-ons, and a test kit of core's own values. A dev dependency, versioned with ngdpbase.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Jim Willeke",