@jwilleke/ngdpbase-types 5.20.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.
@@ -17,11 +17,6 @@ import type { WikiEngine } from '../types/WikiEngine.js';
17
17
  import type { User } from '../types/User.js';
18
18
  import { type StoreOwnerState } from '../utils/privateStoreDoor.js';
19
19
  import { type AddonHost } from '../utils/addonHost.js';
20
- /**
21
- * #1883: what visitors see while a site-critical add-on that did not load
22
- * holds the site in maintenance mode. Empty leaves only the page's heading.
23
- */
24
- export declare const SITE_CRITICAL_PUBLIC_MESSAGE_KEY = "ngdpbase.managers.addons-manager.site-critical.public-message";
25
20
  /**
26
21
  * Type passed to `AddonModule.profileSection()` (#534). Matches what
27
22
  * `UserManager.getUser()` returns at runtime — `User` minus the password
@@ -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 {
@@ -248,6 +248,14 @@ declare class ConfigurationManager extends BaseManager {
248
248
  * const appName = configManager.getProperty('ngdpbase.application-name', 'MyWiki');
249
249
  */
250
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;
251
259
  /**
252
260
  * #896: read a property from the SHIPPED defaults only, ignoring instance
253
261
  * custom-config overrides. Used by vocabulary providers whose seed must not
@@ -320,7 +328,10 @@ declare class ConfigurationManager extends BaseManager {
320
328
  * **`$$literal` escape** — for the rare config value that genuinely
321
329
  * starts with `$`. `"$$abc"` resolves to `"$abc"`.
322
330
  *
323
- * Non-string values pass through unchanged. Resolution happens at lookup
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
324
335
  * time (per-`getProperty()` call), not at config load — so tests that
325
336
  * mutate `process.env` mid-run see the new value on the next read.
326
337
  *
@@ -332,6 +343,10 @@ declare class ConfigurationManager extends BaseManager {
332
343
  * @throws When the bare `$VAR` form references an unset env var.
333
344
  */
334
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;
335
350
  /** Audit counters for the boot-time env-ref summary log (#775). */
336
351
  private _envRefHitCount;
337
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
- * Boot refuses, naming the cause, when the file would sit on a network
18
- * filesystem, when the schema is from a newer build, when a migration fails,
19
- * or when the integrity check does not pass.
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
- this.applyRestoreAtStart(file);
138
- const provider = new SqliteDatabaseProvider(file, key, DATABASE_MIGRATIONS, 'schema_migrations', { busyTimeoutMs: this.busyTimeoutMs });
139
- if (!provider.integrityOk()) {
140
- provider.close();
141
- throw new Error(`The application database ${file} failed its integrity check — refusing to start (#1536)`);
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;
@@ -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
- if (!this.provider || !this.databaseFile) {
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
- 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}.` : ''}`);
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
- if (this.encrypted) {
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
- const rows = this.db.pragma('quick_check');
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
  }
@@ -46,6 +46,11 @@
46
46
  * addon's own file and from the operator's, which wins either way, and kept
47
47
  * apart from the merge so a refused addon's value is still known. A value that
48
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.
49
54
  */
50
55
  import { type AddonDatabaseDeclaration } from './addonDatabaseRules.js';
51
56
  export declare const ADDONS_PATH_KEY = "ngdpbase.managers.addons-manager.addons-path";
@@ -100,6 +105,20 @@ export interface AddonDatabaseLayer {
100
105
  * file: a value there for a declaration's key is ignored, and said so.
101
106
  */
102
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;
103
122
  /** The configuration key that makes an addon site-critical (#1883). */
104
123
  export declare function addonSiteCriticalKey(slug: string): string;
105
124
  export interface AddonSiteCriticalLayer {
@@ -46,6 +46,11 @@
46
46
  * addon's own file and from the operator's, which wins either way, and kept
47
47
  * apart from the merge so a refused addon's value is still known. A value that
48
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.
49
54
  */
50
55
  import fs from 'fs';
51
56
  import path from 'path';
@@ -53,6 +58,7 @@ import { isDeepStrictEqual } from 'util';
53
58
  import { configFilePaths, deepMergeConfigs, deepMergeObjects, instanceDataFolder, isPlainObject, readConfigFilesSync } from './configFiles.js';
54
59
  import { configuredInstallSlugs, findNodeModulesDir, installedAddonsDir, matchNpmPackageDirs, resolveAddonSlug, splitAddonsPath } from './addonsPathResolver.js';
55
60
  import { lockedAddonDirsSync } from './addonLock.js';
61
+ import { LEGACY_SECRET_KEYS_KEY, SENSITIVE_VALUES_KEY } from './sensitiveValues.js';
56
62
  import { parsePrivatestore } from './privateStorePath.js';
57
63
  import { OWNER_DECIDED_FIELDS, SYSTEM_CATEGORY_KEY, systemCategoryValueProblems } from './systemCategoryRules.js';
58
64
  import { addonDatabaseKey, addonDatabaseKeySlug, addonDatabaseProblems } from './addonDatabaseRules.js';
@@ -272,6 +278,41 @@ export function addonDatabaseLayer(addons, custom) {
272
278
  }
273
279
  return layer;
274
280
  }
281
+ /** Every entry an addon's file names in a sensitive-values map or the legacy list, whatever its level. */
282
+ function sensitiveEntriesOf(defaults) {
283
+ const names = [];
284
+ for (const listKey of [SENSITIVE_VALUES_KEY, LEGACY_SECRET_KEYS_KEY]) {
285
+ const declared = defaults[listKey];
286
+ if (Array.isArray(declared))
287
+ names.push(...declared.filter((v) => typeof v === 'string'));
288
+ else if (isPlainObject(declared))
289
+ names.push(...Object.keys(declared));
290
+ }
291
+ return names;
292
+ }
293
+ /**
294
+ * Which values an addon may classify (#1889): its own, `ngdpbase.addons.<slug>`
295
+ * and anything under it, top-level keys and paths alike. An entry for core's
296
+ * keys or another addon's could unhide them (`false`, or `sensitive` for a
297
+ * `secret`), so it refuses the addon, as a database declaration under another
298
+ * slug does (#1860): it is not loaded and none of its keys apply. Only an
299
+ * enabled addon's entries reach the merge, so only an enabled addon is checked.
300
+ */
301
+ export function addonSensitiveValuesLayer(addons) {
302
+ const layer = { refused: new Map(), problems: [] };
303
+ for (const addon of addons) {
304
+ if (!addon.enabled)
305
+ continue;
306
+ const own = `ngdpbase.addons.${addon.slug}`;
307
+ const foreign = sensitiveEntriesOf(addon.defaults).filter((entry) => entry.trim() !== own && !entry.trim().startsWith(`${own}.`));
308
+ if (foreign.length === 0)
309
+ continue;
310
+ const reason = `its sensitive-values entries ${foreign.map((e) => `'${e}'`).join(', ')} are not its own; an add-on classifies only ${own} and what is under it`;
311
+ layer.refused.set(addon.slug, reason);
312
+ layer.problems.push(`${addonProblemSubject(addon.slug, addon.enabled)}: ${reason}; the add-on is not loaded`);
313
+ }
314
+ return layer;
315
+ }
275
316
  /** The configuration key that makes an addon site-critical (#1883). */
276
317
  export function addonSiteCriticalKey(slug) {
277
318
  return `ngdpbase.addons.${slug}.site-critical`;
@@ -360,9 +401,10 @@ export function mergeWithAddonLayer(shipped, addons, custom) {
360
401
  const layer = addonCategoryLayer(shipped[SYSTEM_CATEGORY_KEY], customCategories, addons);
361
402
  const databaseLayer = addonDatabaseLayer(addons, custom);
362
403
  const criticalLayer = addonSiteCriticalLayer(addons, custom);
404
+ const sensitiveLayer = addonSensitiveValuesLayer(addons);
363
405
  const siteCritical = criticalLayer.siteCritical;
364
406
  const refused = new Map(layer.refused);
365
- for (const [slug, reason] of [...databaseLayer.refused, ...criticalLayer.refused])
407
+ for (const [slug, reason] of [...databaseLayer.refused, ...criticalLayer.refused, ...sensitiveLayer.refused])
366
408
  refused.set(slug, refused.has(slug) ? `${refused.get(slug)}; ${reason}` : reason);
367
409
  const databases = databaseLayer.declarations.map((d) => ({ ...d, enabled: d.enabled && !refused.has(d.declaredBy) }));
368
410
  const withAddons = addons
@@ -376,14 +418,14 @@ export function mergeWithAddonLayer(shipped, addons, custom) {
376
418
  return deepMergeObjects(acc, rest);
377
419
  }, shipped);
378
420
  if (Object.keys(layer.entries).length === 0) {
379
- return { merged: deepMergeConfigs(withAddons, custom), problems: [...layer.problems, ...databaseLayer.problems, ...criticalLayer.problems], refused, databases, siteCritical };
421
+ return { merged: deepMergeConfigs(withAddons, custom), problems: [...layer.problems, ...databaseLayer.problems, ...criticalLayer.problems, ...sensitiveLayer.problems], refused, databases, siteCritical };
380
422
  }
381
423
  const shippedCategories = isPlainObject(withAddons[SYSTEM_CATEGORY_KEY]) ? withAddons[SYSTEM_CATEGORY_KEY] : {};
382
424
  const layered = { ...withAddons, [SYSTEM_CATEGORY_KEY]: deepMergeObjects(shippedCategories, layer.entries) };
383
425
  const merged = deepMergeObjects(layered, custom);
384
426
  const stood = ownerDeclarationsStand(merged[SYSTEM_CATEGORY_KEY], layer, customCategories);
385
427
  merged[SYSTEM_CATEGORY_KEY] = stood.categories;
386
- return { merged: merged, problems: [...layer.problems, ...stood.problems, ...databaseLayer.problems, ...criticalLayer.problems], refused, databases, siteCritical };
428
+ return { merged: merged, problems: [...layer.problems, ...stood.problems, ...databaseLayer.problems, ...criticalLayer.problems, ...sensitiveLayer.problems], refused, databases, siteCritical };
387
429
  }
388
430
  /**
389
431
  * The full merge for a caller with the two files already read (the manager,
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Paths inside a configuration value (#1889).
3
+ *
4
+ * A configuration key names a value; when the value is a map or a list, each
5
+ * thing inside it has a path below the key: its map keys and list indices, in
6
+ * order. `ngdpbase.addons.accounting.accounts` → `checking` → `account-number`
7
+ * is written `ngdpbase.addons.accounting.accounts.checking.account-number`.
8
+ *
9
+ * Two readers walk these paths, and they walk them here, the same way:
10
+ *
11
+ * - `ConfigurationManager` resolves a bare `$NAME` environment reference
12
+ * wherever it sits in a value, naming the full path when the variable is
13
+ * unset;
14
+ * - `sensitiveValues` hides what `ngdpbase.config.sensitive-values` names by
15
+ * a path, where `*` stands for any one map key (or list index).
16
+ *
17
+ * @module utils/configPaths
18
+ */
19
+ /** A bare, whole-string environment reference: `$NAME`, upper case, digits and `_`. */
20
+ export declare const ENV_REF_PATTERN: RegExp;
21
+ /** The variable a bare `$NAME` reference names, or null when the value is not one. */
22
+ export declare function envRefName(value: unknown): string | null;
23
+ /** One path segment that stands for any map key or list index. */
24
+ export declare const WILDCARD = "*";
25
+ /** A key and the path below it, written as configuration keys are: dot-joined. */
26
+ export declare function pathString(key: string, below: readonly string[]): string;
27
+ /**
28
+ * Visit a value and everything inside it, top first. `visit` returns a
29
+ * replacement for the node, or `undefined` to keep it and go inside. Copy on
30
+ * write: a map or list nothing inside changed is returned as itself, so a read
31
+ * of a value with nothing to replace allocates nothing.
32
+ */
33
+ export declare function mapConfigValue(value: unknown, visit: (node: unknown, below: readonly string[]) => unknown, below?: string[]): unknown;
34
+ /**
35
+ * The segments of a path entry, or null when it is not a well-formed one: no
36
+ * empty segment, and `*` only as a whole segment with at least two written-out
37
+ * segments before the first one (`ngdpbase.<category>`), since a key is never
38
+ * a wildcard.
39
+ */
40
+ export declare function parsePathEntry(entry: string): string[] | null;
41
+ /**
42
+ * Whether `entry` names something inside `key`'s value, and the path pattern
43
+ * below the key if so. The key is matched literally: a `*` stands only for a
44
+ * segment below it.
45
+ */
46
+ export declare function patternBelow(entry: string, key: string): string[] | null;
47
+ /** Whether a path below a key matches a pattern below it: same length, `*` any one segment. */
48
+ export declare function pathMatches(pattern: readonly string[], below: readonly string[]): boolean;
49
+ /** What sits at a path below a value, or undefined. */
50
+ export declare function valueAt(value: unknown, below: readonly string[]): unknown;
51
+ /**
52
+ * The configuration key a path entry lies inside: the longest prefix, ending
53
+ * before the first `*`, for which `has` is true. Null when none is set.
54
+ */
55
+ export declare function keyOfEntry(entry: string, has: (key: string) => boolean): string | null;