@jwilleke/ngdpbase-types 5.19.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.
@@ -17,6 +17,11 @@ 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";
20
25
  /**
21
26
  * Type passed to `AddonModule.profileSection()` (#534). Matches what
22
27
  * `UserManager.getUser()` returns at runtime — `User` minus the password
@@ -280,6 +285,8 @@ declare class AddonsManager extends BaseManager {
280
285
  private dashboardCards;
281
286
  /** Name of the first domain addon loaded — only one is permitted */
282
287
  private domainAddonName;
288
+ /** #1883: the enabled add-ons that did not load this start, with why — each reported once. */
289
+ private readonly loadFailures;
283
290
  constructor(engine: WikiEngine);
284
291
  /**
285
292
  * Initialize the AddonsManager
@@ -351,6 +358,30 @@ declare class AddonsManager extends BaseManager {
351
358
  * bundled/drop-in addon wins.
352
359
  */
353
360
  private registerAddonFromDir;
361
+ /**
362
+ * Register an add-on that will not be loaded, so Admin → Add-ons shows it
363
+ * with the reason, and report it when it is enabled (#1883). The first
364
+ * add-on of a slug wins, as for a loadable one.
365
+ */
366
+ private refuseAddon;
367
+ /**
368
+ * #1883 (operator, 2026-10-18): the one rule for an enabled add-on that does
369
+ * not load, whatever the reason. Admins get a notification at the most
370
+ * severe level naming it and the reason, which says how to fix it where the
371
+ * reason does. A site-critical add-on (`ngdpbase.addons.<slug>.site-critical`)
372
+ * also holds the whole site in maintenance mode through core's survivable
373
+ * path (#1152): visitors see only the configured public message, with no
374
+ * details, and administrators can still sign in and reach /admin. Once per
375
+ * add-on per start. Nothing is opened in its place.
376
+ */
377
+ private recordLoadFailure;
378
+ /**
379
+ * #1883: an enabled add-on that discovery never found — its install failed
380
+ * (#1830), or only a stray folder answers it — did not load either.
381
+ */
382
+ private reportUndiscoveredAddons;
383
+ /** A notification to administrators; best-effort, like recordSeedFailure. */
384
+ private notifyAdmins;
354
385
  /**
355
386
  * Why an addon cannot run on this core (#1757): its manifest's `minVersion`
356
387
  * is newer than this release. Null when it can, or when it states none.
@@ -1052,6 +1052,15 @@ declare class AuthManager extends BaseManager {
1052
1052
  unlockVaults(username: string, password: string): Promise<string | undefined>;
1053
1053
  private vaultUnlocker;
1054
1054
  private warnedNoUnlocker;
1055
+ /**
1056
+ * The stored spelling of a username a sign-in brought (#1882), or null when
1057
+ * no account has it. Lookups compare normalized (#1436), so "ADMIN" finds
1058
+ * admin's record; the session, the audit record, the roles and the vault
1059
+ * paths must carry the stored name, never the typed variant, which is a
1060
+ * roleless twin. The one rule, asked by every door a sign-in passes:
1061
+ * `authenticate`, `beginTwoStep` and `completeSignIn`.
1062
+ */
1063
+ private storedUsername;
1055
1064
  /** A fresh session for a sign-in (#1043); the one already on it ends first (#1626, #1670). */
1056
1065
  private regenerate;
1057
1066
  /**
@@ -1109,7 +1118,7 @@ declare class AuthManager extends BaseManager {
1109
1118
  ip?: string;
1110
1119
  userAgent?: string;
1111
1120
  privateStoreHandle?: string;
1112
- }): string;
1121
+ }): Promise<string>;
1113
1122
  /** The pending sign-in, for the browser that started it; null when gone, expired, or another browser. */
1114
1123
  pendingSignIn(handle: string | undefined, binding: string | undefined): PendingSignIn | null;
1115
1124
  /**
@@ -60,10 +60,12 @@ declare class ConfigurationManager extends BaseManager {
60
60
  private addonLayers;
61
61
  /** #1559: add-on system-categories refused, and operator values an owner's declaration overrode; #1860: database declarations refused or overridden. */
62
62
  private systemCategoryProblems;
63
- /** #1559: add-ons that must not load because an entry they declare was refused, by slug, with why; #1860: or their database declaration. */
63
+ /** #1559: add-ons that must not load because an entry they declare was refused, by slug, with why; #1860: or their database declaration; #1883: or a site-critical value that is not a boolean. */
64
64
  private refusedAddons;
65
65
  /** #1860: every installed add-on's sound database declaration, on or off. */
66
66
  private addonDatabases;
67
+ /** #1883: which add-ons put the site in maintenance mode when they fail to load, by slug. */
68
+ private siteCriticalAddons;
67
69
  private environment;
68
70
  private defaultConfigPath;
69
71
  private customConfigPath;
@@ -282,6 +284,14 @@ declare class ConfigurationManager extends BaseManager {
282
284
  * ({@link getAddonRefusal}). DatabaseManager is the reader.
283
285
  */
284
286
  getAddonDatabaseDeclarations(): readonly AddonDatabaseDeclaration[];
287
+ /**
288
+ * #1883: whether this add-on's failure to load puts the whole site in
289
+ * maintenance mode — `ngdpbase.addons.<slug>.site-critical`, from the
290
+ * operator's file or else the add-on's own, read even when the add-on was
291
+ * refused. A value that is not exactly true or false counts as true (and
292
+ * refuses the add-on, {@link getAddonRefusal}).
293
+ */
294
+ isAddonSiteCritical(slug: string): boolean;
285
295
  /**
286
296
  * #1813: what one enabled addon's own `config/default-config.json` declares,
287
297
  * exactly as it was folded into the merge — not the merged view, so the
@@ -255,7 +255,7 @@ class DatabaseManager extends BaseManager {
255
255
  static keyRefusal(declaration, key) {
256
256
  if (declaration.encrypt !== 'required' || key)
257
257
  return null;
258
- return `Add-on '${declaration.declaredBy}' declares database '${declaration.owner}' with encrypt "required", but ${DATABASE_KEY_ENV} is not set — it is never opened unencrypted (#1860)`;
258
+ return `Add-on '${declaration.declaredBy}' declares database '${declaration.owner}' with encrypt "required", but ${DATABASE_KEY_ENV} is not set — it is never opened unencrypted. Set ${DATABASE_KEY_ENV} in the environment or the instance .env (<FAST_STORAGE>/.env) and restart (#1860, #1883)`;
259
259
  }
260
260
  // ---- Backup and restore (#1723) ------------------------------------------
261
261
  /**
@@ -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
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilleke/ngdpbase-types",
3
- "version": "5.19.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",