@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.
- package/lib/src/managers/AddonsManager.d.ts +31 -0
- package/lib/src/managers/AuthManager.d.ts +10 -1
- package/lib/src/managers/BaseManager.d.ts +38 -2
- package/lib/src/managers/BaseManager.js +2 -2
- package/lib/src/managers/ConfigurationManager.d.ts +11 -1
- package/lib/src/managers/DatabaseManager.d.ts +99 -0
- package/lib/src/managers/DatabaseManager.js +355 -1
- package/lib/src/providers/sqliteBackup.d.ts +128 -0
- package/lib/src/providers/sqliteBackup.js +365 -0
- package/lib/src/types/WikiEngine.d.ts +12 -1
- package/lib/src/utils/addonConfigLayer.d.ts +32 -1
- package/lib/src/utils/addonConfigLayer.js +62 -8
- package/lib/src/utils/auditEventNames.generated.d.ts +6 -0
- package/lib/src/utils/auditEventNames.generated.js +6 -0
- package/package.json +1 -1
|
@@ -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
|
|
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 =
|
|
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 =
|
|
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.
|
|
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",
|