@jwilleke/ngdpbase-types 5.42.0 → 5.44.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/context/ApiContext.d.ts +10 -3
- package/lib/src/context/ApiContext.js +37 -4
- package/lib/src/context/BaseContext.d.ts +20 -20
- package/lib/src/context/BaseContext.js +41 -24
- package/lib/src/context/JobContext.d.ts +31 -0
- package/lib/src/context/JobContext.js +34 -1
- package/lib/src/ice/runExport.d.ts +8 -3
- package/lib/src/ice/searchExportSource.d.ts +44 -0
- package/lib/src/ice/types.d.ts +6 -0
- package/lib/src/managers/AddonsManager.d.ts +0 -28
- package/lib/src/managers/AgentTokenManager.d.ts +8 -0
- package/lib/src/managers/AttachmentManager.d.ts +11 -0
- package/lib/src/managers/AuditManager.d.ts +0 -1
- package/lib/src/managers/AuthManager.d.ts +38 -0
- package/lib/src/managers/AuthManager.js +84 -12
- package/lib/src/managers/BackupManager.d.ts +9 -2
- package/lib/src/managers/BackupManager.js +42 -19
- package/lib/src/managers/BaseManager.d.ts +17 -3
- package/lib/src/managers/BaseManager.js +12 -8
- package/lib/src/managers/ConfigurationManager.d.ts +28 -0
- package/lib/src/managers/ConsentManager.d.ts +1 -1
- package/lib/src/managers/DatabaseManager.js +17 -8
- package/lib/src/managers/EmailManager.d.ts +1 -1
- package/lib/src/managers/ExportManager.d.ts +1 -44
- package/lib/src/managers/ExportManager.js +3 -115
- package/lib/src/managers/ImportManager.d.ts +7 -0
- package/lib/src/managers/PageManager.d.ts +194 -23
- package/lib/src/managers/PolicyEvaluator.d.ts +182 -24
- package/lib/src/managers/ShareManager.d.ts +3 -2
- package/lib/src/managers/ThemeManager.d.ts +20 -1
- package/lib/src/managers/UserManager.d.ts +19 -5
- package/lib/src/managers/ValidationManager.d.ts +3 -1
- package/lib/src/providers/AgentTokenAuthProvider.js +1 -1
- package/lib/src/providers/BaseAuthProvider.d.ts +2 -0
- package/lib/src/providers/FileLoggingProvider.d.ts +12 -1
- package/lib/src/providers/FileLoggingProvider.js +37 -4
- package/lib/src/security/PolicyDecisionPoint.d.ts +107 -4
- package/lib/src/security/PolicyInformationPoint.d.ts +113 -7
- package/lib/src/security/ownedRecords.d.ts +69 -0
- package/lib/src/security/permissions.generated.d.ts +8 -0
- package/lib/src/security/signedOutWrites.d.ts +69 -0
- package/lib/src/security/signedOutWrites.js +146 -0
- package/lib/src/types/Page.d.ts +0 -7
- package/lib/src/types/Policy.d.ts +65 -8
- package/lib/src/types/Policy.js +6 -0
- package/lib/src/types/Provider.d.ts +12 -0
- package/lib/src/types/User.d.ts +6 -0
- package/lib/src/utils/addonConfigLayer.d.ts +6 -3
- package/lib/src/utils/addonConfigLayer.js +61 -3
- package/lib/src/utils/addonHost.d.ts +25 -0
- package/lib/src/utils/addonHost.js +29 -1
- package/lib/src/utils/addonPageSync.d.ts +0 -12
- package/lib/src/utils/auditEventNames.generated.d.ts +2 -0
- package/lib/src/utils/auditEventNames.generated.js +2 -0
- package/lib/src/utils/auditEvents.d.ts +2 -0
- package/lib/src/utils/auditEvents.js +3 -1
- package/lib/src/utils/availability.d.ts +15 -0
- package/lib/src/utils/availability.js +88 -0
- package/lib/src/utils/frontmatterAccess.d.ts +15 -24
- package/lib/src/utils/legacyStoreKeys.d.ts +16 -0
- package/lib/src/utils/pageTitleConflict.d.ts +32 -0
- package/lib/src/utils/pageWritePolicies.d.ts +74 -0
- package/lib/src/utils/permissionCatalog.d.ts +2 -0
- package/lib/src/utils/permissionCatalog.js +19 -0
- package/lib/src/utils/plainAttention.d.ts +19 -0
- package/lib/src/utils/plainAttention.js +18 -0
- package/lib/src/utils/policyAttributes.d.ts +232 -0
- package/lib/src/utils/policyAttributes.js +404 -0
- package/lib/src/utils/privateStoreAccess.d.ts +24 -4
- package/lib/src/utils/privateStoreAccess.js +19 -4
- package/lib/src/utils/roleGrants.d.ts +1 -1
- package/lib/src/utils/username.d.ts +13 -2
- package/lib/src/utils/username.js +18 -3
- package/package.json +7 -7
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
*/
|
|
34
34
|
import { CONSENT_PATH, REMEMBER_BROWSER_CONSENT_SCOPE } from '../types/Consent.js';
|
|
35
35
|
import BaseManager from './BaseManager.js';
|
|
36
|
+
import { IN_SERVER_SETTINGS } from '../utils/plainAttention.js';
|
|
36
37
|
import { PasswordAuthProvider } from '../providers/PasswordAuthProvider.js';
|
|
37
38
|
import { MagicLinkAuthProvider, EMAIL_FACTOR } from '../providers/MagicLinkAuthProvider.js';
|
|
38
39
|
import { dropKeyCopiesOfGrant, dropKeyCopy, hasUnlockedKey, hasUserKeys, heldKeyCopy, lockPrivateStores, openKeyCopy, personKeyOf, sealKekCopyFor } from '../utils/privateStoreUnlock.js';
|
|
@@ -104,6 +105,15 @@ const CORE_PROVIDER_IDS = [
|
|
|
104
105
|
function isReservedProviderId(id) {
|
|
105
106
|
return CORE_PROVIDER_IDS.includes(id) || id.startsWith('oidc:');
|
|
106
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* The host passkeys are tied to, or null when the site's address cannot support
|
|
110
|
+
* them: base-url must be set explicitly, and be https (or localhost).
|
|
111
|
+
*/
|
|
112
|
+
function passkeyRelyingPartyOf(configManager) {
|
|
113
|
+
if (!configManager.isBaseUrlExplicit())
|
|
114
|
+
return null;
|
|
115
|
+
return relyingPartyFrom(configManager.getBaseURL(), String(configManager.getProperty('ngdpbase.application-name', 'ngdpbase')));
|
|
116
|
+
}
|
|
107
117
|
/** A factor as offered: its entry lowered to what the provider's code gives. */
|
|
108
118
|
/**
|
|
109
119
|
* Providers that count toward a sign-in level only once the account has
|
|
@@ -1155,7 +1165,8 @@ class AuthManager extends BaseManager {
|
|
|
1155
1165
|
return;
|
|
1156
1166
|
this.credentialsFile = file;
|
|
1157
1167
|
if (!key) {
|
|
1158
|
-
|
|
1168
|
+
logger.error(`[AuthManager] ${CREDENTIALS_KEY_ENV} is not set, so the credentials store is closed`);
|
|
1169
|
+
this.markDegraded("Nobody can sign in with a password, because the key that protects the saved passwords is not set. This is set in the server's settings, outside this page.", CREDENTIALS_KEY_ENV);
|
|
1159
1170
|
return;
|
|
1160
1171
|
}
|
|
1161
1172
|
const store = new FileCredentialsProvider(file, key);
|
|
@@ -1163,7 +1174,8 @@ class AuthManager extends BaseManager {
|
|
|
1163
1174
|
await store.initialize((rejected) => this.raiseRejectedCredentials(store.location(), rejected));
|
|
1164
1175
|
}
|
|
1165
1176
|
catch (err) {
|
|
1166
|
-
|
|
1177
|
+
logger.error(`[AuthManager] The credentials store ${file} could not be read: ${err.message}`);
|
|
1178
|
+
this.markDegraded('Nobody can sign in with a password, because the saved passwords could not be read. Ask whoever runs the server to check them; this is outside this page.', 'ngdpbase.auth.credentials.file');
|
|
1167
1179
|
return;
|
|
1168
1180
|
}
|
|
1169
1181
|
this.credentials = store;
|
|
@@ -1181,13 +1193,14 @@ class AuthManager extends BaseManager {
|
|
|
1181
1193
|
logger.warn('[AuthManager] Passkeys NOT registered: the credentials store is not available (#448)');
|
|
1182
1194
|
return;
|
|
1183
1195
|
}
|
|
1184
|
-
|
|
1185
|
-
logger.info('[AuthManager] Passkeys off: ngdpbase.application.base-url is not set explicitly, and a passkey is tied to that host (#448)');
|
|
1186
|
-
return;
|
|
1187
|
-
}
|
|
1188
|
-
const rp = relyingPartyFrom(configManager.getBaseURL(), String(configManager.getProperty('ngdpbase.application-name', 'ngdpbase')));
|
|
1196
|
+
const rp = passkeyRelyingPartyOf(configManager);
|
|
1189
1197
|
if (!rp) {
|
|
1190
|
-
|
|
1198
|
+
if (!configManager.isBaseUrlExplicit()) {
|
|
1199
|
+
logger.info('[AuthManager] Passkeys off: ngdpbase.application.base-url is not set explicitly, and a passkey is tied to that host (#448)');
|
|
1200
|
+
}
|
|
1201
|
+
else {
|
|
1202
|
+
logger.warn(`[AuthManager] Passkeys off: base-url ${configManager.getBaseURL()} is not https (or localhost), where browsers allow WebAuthn (#448)`);
|
|
1203
|
+
}
|
|
1191
1204
|
return;
|
|
1192
1205
|
}
|
|
1193
1206
|
const store = this.credentials;
|
|
@@ -1197,6 +1210,18 @@ class AuthManager extends BaseManager {
|
|
|
1197
1210
|
}));
|
|
1198
1211
|
logger.info(`[AuthManager] passkeys tied to ${rp.rpID}`);
|
|
1199
1212
|
}
|
|
1213
|
+
/**
|
|
1214
|
+
* #2317: the setting says passkeys are on, but the site's address cannot
|
|
1215
|
+
* support them (base-url not set explicitly, or neither https nor localhost),
|
|
1216
|
+
* so none are offered. The same rule registerPasskeys applies. Admin →
|
|
1217
|
+
* Configuration says so on that setting's row and offers to turn it off.
|
|
1218
|
+
*/
|
|
1219
|
+
passkeysCannotWork() {
|
|
1220
|
+
const configManager = this.engine.getManager('ConfigurationManager');
|
|
1221
|
+
if (!configManager || configManager.getProperty('ngdpbase.auth.passkey.enabled', true) === false)
|
|
1222
|
+
return false;
|
|
1223
|
+
return passkeyRelyingPartyOf(configManager) === null;
|
|
1224
|
+
}
|
|
1200
1225
|
/** TOTP enrolment (#421), available whenever the credentials store is open. */
|
|
1201
1226
|
openTotp(configManager) {
|
|
1202
1227
|
this.totp = null;
|
|
@@ -1271,6 +1296,10 @@ class AuthManager extends BaseManager {
|
|
|
1271
1296
|
passkeyRelyingParty() {
|
|
1272
1297
|
return this.passkeyProvider()?.relyingParty() ?? null;
|
|
1273
1298
|
}
|
|
1299
|
+
/** Can a passkey be added or used here at all (#2345)? False when passkeys are off or the address can't host them. */
|
|
1300
|
+
passkeysAvailable() {
|
|
1301
|
+
return this.passkeyRelyingParty() !== null;
|
|
1302
|
+
}
|
|
1274
1303
|
/** Options for `username` to enrol a passkey (#448); the caller keeps `challenge` for verify. */
|
|
1275
1304
|
/**
|
|
1276
1305
|
* May this account sign in with `providerId`? Its `allowedAuthMethods`, when
|
|
@@ -1348,7 +1377,8 @@ class AuthManager extends BaseManager {
|
|
|
1348
1377
|
title: 'Security alert: credential rows rejected',
|
|
1349
1378
|
message
|
|
1350
1379
|
}).catch(() => { });
|
|
1351
|
-
|
|
1380
|
+
// #2354: the dashboard says it in plain words; the rows and the way to re-sign them are in the log line above.
|
|
1381
|
+
this.markDegraded(`${rejected.length === 1 ? 'A saved sign-in record' : `${rejected.length} saved sign-in records`} failed a security check and ${rejected.length === 1 ? 'was' : 'were'} ignored. Treat this as a possible attempt to get into an account, and ask whoever runs the server to look into it.`, 'ngdpbase.auth.credentials.file');
|
|
1352
1382
|
}
|
|
1353
1383
|
/**
|
|
1354
1384
|
* Whether `ctx` may act on `username`'s credentials: their own under
|
|
@@ -2348,15 +2378,53 @@ class AuthManager extends BaseManager {
|
|
|
2348
2378
|
* is told, on every page, to close.
|
|
2349
2379
|
*/
|
|
2350
2380
|
async aalReachFor(username) {
|
|
2381
|
+
const { offered, usable } = await this.accountFactors(username);
|
|
2382
|
+
const reach = (factors) => Math.max(factors.some(f => f.primary) ? this.assess(factors).aal : 0, 1);
|
|
2383
|
+
return { now: reach(usable), ifEnrolled: reach(offered) };
|
|
2384
|
+
}
|
|
2385
|
+
/**
|
|
2386
|
+
* The site's factors this account may use (`offered`: narrowed to its
|
|
2387
|
+
* `allowedAuthMethods`), and of those the ones it can use now (`usable`: a
|
|
2388
|
+
* factor that must be enrolled counts only once it is). One reading for
|
|
2389
|
+
* the level the account can reach and for what it is told to add (#2356).
|
|
2390
|
+
*/
|
|
2391
|
+
async accountFactors(username) {
|
|
2351
2392
|
const user = await this.engine.getManager('UserManager')?.getUser(username);
|
|
2352
2393
|
const allowed = user?.allowedAuthMethods;
|
|
2353
2394
|
const offered = this.getFactors().filter(f => allowsProvider(allowed, f.provider));
|
|
2354
|
-
const reach = (factors) => Math.max(factors.some(f => f.primary) ? this.assess(factors).aal : 0, 1);
|
|
2355
2395
|
const usable = offered.filter(f => {
|
|
2356
2396
|
const kind = ENROLLED_FACTORS[f.provider];
|
|
2357
2397
|
return kind === undefined || this.hasCredential(username, kind);
|
|
2358
2398
|
});
|
|
2359
|
-
return {
|
|
2399
|
+
return { offered, usable };
|
|
2400
|
+
}
|
|
2401
|
+
/**
|
|
2402
|
+
* What this account can add or use here toward a stronger sign-in (#2356),
|
|
2403
|
+
* for the sign-in banners and the confirm page's wording. Never a decision:
|
|
2404
|
+
* who steps down and what step-up needs stay with `rolesAtSignIn` and
|
|
2405
|
+
* `stepUpNeeded`. "Add" is a passkey or an authenticator app the site
|
|
2406
|
+
* offers, the account is allowed and has not set up; "use" is one it has,
|
|
2407
|
+
* is allowed, and the site offers (the app's code as step-up offers it).
|
|
2408
|
+
* `password` is a password it has and is allowed, which the app's code
|
|
2409
|
+
* needs to reach AAL2. `outside` names any other allowed sign-in method
|
|
2410
|
+
* that reaches AAL2 by itself, such as an outside provider's.
|
|
2411
|
+
* Computed per call; never cached across accounts.
|
|
2412
|
+
*/
|
|
2413
|
+
async strongerSignInWays(username) {
|
|
2414
|
+
const { offered, usable } = await this.accountFactors(username);
|
|
2415
|
+
const has = (list, id) => list.some(f => f.provider === id);
|
|
2416
|
+
const password = has(usable, 'password')
|
|
2417
|
+
&& (await this.engine.getManager('UserManager')?.hasPassword(username).catch(() => false) ?? false);
|
|
2418
|
+
return {
|
|
2419
|
+
addPasskey: has(offered, 'passkey') && !has(usable, 'passkey'),
|
|
2420
|
+
addTotp: has(offered, 'totp') && !has(usable, 'totp'),
|
|
2421
|
+
usePasskey: has(usable, 'passkey'),
|
|
2422
|
+
useTotp: has(usable, 'totp') && this.stepUpTotpOffered(username),
|
|
2423
|
+
password,
|
|
2424
|
+
outside: usable
|
|
2425
|
+
.filter(f => f.primary && f.aal >= 2 && f.provider !== 'passkey')
|
|
2426
|
+
.map(f => this.providers.get(f.provider)?.displayName ?? f.provider)
|
|
2427
|
+
};
|
|
2360
2428
|
}
|
|
2361
2429
|
/**
|
|
2362
2430
|
* The roles an account holds at a lower level than they require only
|
|
@@ -2468,7 +2536,11 @@ class AuthManager extends BaseManager {
|
|
|
2468
2536
|
const shipped = unreachable.filter(u => !u.operatorSet);
|
|
2469
2537
|
if (shipped.length > 0) {
|
|
2470
2538
|
const roles = shipped.map(u => `${u.role} (AAL${u.aal})`).join(', ');
|
|
2471
|
-
|
|
2539
|
+
logger.warn(`[AuthManager] ${roles} require a stronger sign-in than this site offers, so they act at AAL${Math.max(reachable, 1)} for now. Set ngdpbase.application.base-url (https) so passkeys can be enabled.`);
|
|
2540
|
+
// From the server's settings the address row is locked: say where to change it instead of linking to it.
|
|
2541
|
+
const fromServer = !!this.engine.getManager('ConfigurationManager')?.getEnvControlledKeys?.()['ngdpbase.application.base-url'];
|
|
2542
|
+
const needs = `${shipped.length === 1 ? 'One role needs' : 'Some roles need'} a stronger sign-in than this site offers, so ${shipped.length === 1 ? 'it uses' : 'they use'} a simpler one for now. Set the site's web address to a secure (https) one so passkeys can be turned on.`;
|
|
2543
|
+
this.markNeedsAttention(fromServer ? `${needs} ${IN_SERVER_SETTINGS}` : needs, fromServer ? undefined : 'ngdpbase.application.base-url');
|
|
2472
2544
|
}
|
|
2473
2545
|
const descriptions = this.unreachableRequiredAal();
|
|
2474
2546
|
return unreachable.flatMap((u, i) => (u.operatorSet ? [descriptions[i]] : []));
|
|
@@ -201,6 +201,11 @@ export interface RestoreReport {
|
|
|
201
201
|
}
|
|
202
202
|
/** BackupManager's fault, and the dashboard banner, while there is no backup key (#1561, #1912). */
|
|
203
203
|
export declare const BACKUP_KEY_MISSING = "NGDPBASE_BACKUP_KEY is not set, so backups are NOT encrypted: the document (password hashes, settings with their secrets) and the file archives beside it are in the clear, and the credentials store, the databases and the instance .env are left out";
|
|
204
|
+
/**
|
|
205
|
+
* What the dashboard says while there is no backup key (#2354): plain words, one thing to do.
|
|
206
|
+
* The key is set outside the app, so there is nothing to link to.
|
|
207
|
+
*/
|
|
208
|
+
export declare const BACKUP_KEY_MISSING_PLAIN = "Backups are not encrypted, and some data (the databases and saved secrets) is left out of them, because no backup key is set. This is set in the server's settings, outside this page.";
|
|
204
209
|
/** The title of the one notification about it. */
|
|
205
210
|
export declare const BACKUP_KEY_MISSING_TITLE = "Backups are not encrypted";
|
|
206
211
|
/**
|
|
@@ -210,7 +215,7 @@ export declare const BACKUP_KEY_MISSING_TITLE = "Backups are not encrypted";
|
|
|
210
215
|
*/
|
|
211
216
|
export declare const BACKUP_KEY_SAVED_KEY = "ngdpbase.backup.key-saved-fingerprint";
|
|
212
217
|
/** The banner while no admin has confirmed the backup key is saved off the server (#1876). */
|
|
213
|
-
export declare function backupKeyNotSaved(
|
|
218
|
+
export declare function backupKeyNotSaved(): string;
|
|
214
219
|
/**
|
|
215
220
|
* The config key that records the names (never the values) the instance
|
|
216
221
|
* `.env` set when an admin last confirmed it is saved off the server (#1976),
|
|
@@ -229,7 +234,7 @@ export declare const ENV_KEYS_CHANGED_TITLE = "Save a copy of .env";
|
|
|
229
234
|
*/
|
|
230
235
|
export declare const SHARED_FOLDERS_KEY_SAVED_KEY = "ngdpbase.backup.shared-folders-key-saved-fingerprint";
|
|
231
236
|
/** The banner while no admin has confirmed a saved `.env` holding the current shared-folders key (#2124). */
|
|
232
|
-
export declare function sharedFoldersKeyNotSaved(
|
|
237
|
+
export declare function sharedFoldersKeyNotSaved(): string;
|
|
233
238
|
/** The banner while the instance `.env` sets other names than the ones an admin last confirmed saved (#1976): names only, never a value. */
|
|
234
239
|
export declare function envKeysChanged(change: {
|
|
235
240
|
added: readonly string[];
|
|
@@ -274,6 +279,8 @@ export interface BackupFailure {
|
|
|
274
279
|
/** The document that was written anyway (a backup with a part missing), so it is not counted as good. */
|
|
275
280
|
filename?: string;
|
|
276
281
|
}
|
|
282
|
+
/** What the dashboard says for a health fault (#2354): plain words; the developer reason stays in the log and on the Backup page. */
|
|
283
|
+
export declare function backupHealthPlain(health: Pick<BackupHealth, 'failure' | 'lastGood'>): string;
|
|
277
284
|
/** Whether backups are working, as Admin → Backup and the /admin dashboard show it (#1563). */
|
|
278
285
|
export interface BackupHealth {
|
|
279
286
|
/** The newest backup in the backup directory that is not a failed one: when, how big (with the files beside it), where. */
|
|
@@ -174,6 +174,11 @@ export function restoreLabel(manager) {
|
|
|
174
174
|
}
|
|
175
175
|
/** BackupManager's fault, and the dashboard banner, while there is no backup key (#1561, #1912). */
|
|
176
176
|
export const BACKUP_KEY_MISSING = `${BACKUP_KEY_ENV} is not set, so backups are NOT encrypted: the document (password hashes, settings with their secrets) and the file archives beside it are in the clear, and the credentials store, the databases and the instance .env are left out`;
|
|
177
|
+
/**
|
|
178
|
+
* What the dashboard says while there is no backup key (#2354): plain words, one thing to do.
|
|
179
|
+
* The key is set outside the app, so there is nothing to link to.
|
|
180
|
+
*/
|
|
181
|
+
export const BACKUP_KEY_MISSING_PLAIN = "Backups are not encrypted, and some data (the databases and saved secrets) is left out of them, because no backup key is set. This is set in the server's settings, outside this page.";
|
|
177
182
|
/** The title of the one notification about it. */
|
|
178
183
|
export const BACKUP_KEY_MISSING_TITLE = 'Backups are not encrypted';
|
|
179
184
|
/**
|
|
@@ -183,8 +188,8 @@ export const BACKUP_KEY_MISSING_TITLE = 'Backups are not encrypted';
|
|
|
183
188
|
*/
|
|
184
189
|
export const BACKUP_KEY_SAVED_KEY = 'ngdpbase.backup.key-saved-fingerprint';
|
|
185
190
|
/** The banner while no admin has confirmed the backup key is saved off the server (#1876). */
|
|
186
|
-
export function backupKeyNotSaved(
|
|
187
|
-
return
|
|
191
|
+
export function backupKeyNotSaved() {
|
|
192
|
+
return 'Save the backup key somewhere other than this server. Every backup is encrypted with it, and restoring on a new server needs it. This stays until you confirm it is saved.';
|
|
188
193
|
}
|
|
189
194
|
/**
|
|
190
195
|
* The config key that records the names (never the values) the instance
|
|
@@ -194,6 +199,8 @@ export function backupKeyNotSaved(fingerprint) {
|
|
|
194
199
|
* removed.
|
|
195
200
|
*/
|
|
196
201
|
export const ENV_KEYS_SAVED_KEY = 'ngdpbase.backup.env-keys-saved';
|
|
202
|
+
/** Where a backup-key reminder is cleared: the Backup page, not the Configuration page (#2348). */
|
|
203
|
+
const BACKUP_PAGE_FIX = { fixUrl: '/admin/backup', fixLabel: 'Open Admin → Backup' };
|
|
197
204
|
/** The title of the one notification per change of the `.env` key set (#1976). */
|
|
198
205
|
export const ENV_KEYS_CHANGED_TITLE = 'Save a copy of .env';
|
|
199
206
|
/**
|
|
@@ -203,20 +210,20 @@ export const ENV_KEYS_CHANGED_TITLE = 'Save a copy of .env';
|
|
|
203
210
|
* key asks again.
|
|
204
211
|
*/
|
|
205
212
|
export const SHARED_FOLDERS_KEY_SAVED_KEY = 'ngdpbase.backup.shared-folders-key-saved-fingerprint';
|
|
206
|
-
const ENV_KEYS_CHANGED_PREFIX = 'The
|
|
207
|
-
const KEY_NOT_SAVED_PREFIX = 'Save the backup key
|
|
208
|
-
const SHARED_FOLDERS_KEY_NOT_SAVED_PREFIX = 'Save
|
|
213
|
+
const ENV_KEYS_CHANGED_PREFIX = 'The server\'s key file has changed since you last saved a copy';
|
|
214
|
+
const KEY_NOT_SAVED_PREFIX = 'Save the backup key somewhere other than this server';
|
|
215
|
+
const SHARED_FOLDERS_KEY_NOT_SAVED_PREFIX = 'Save a copy of the server\'s key file somewhere other than this server';
|
|
209
216
|
/** The banner while no admin has confirmed a saved `.env` holding the current shared-folders key (#2124). */
|
|
210
|
-
export function sharedFoldersKeyNotSaved(
|
|
211
|
-
return `${SHARED_FOLDERS_KEY_NOT_SAVED_PREFIX}
|
|
217
|
+
export function sharedFoldersKeyNotSaved() {
|
|
218
|
+
return `${SHARED_FOLDERS_KEY_NOT_SAVED_PREFIX}: it holds the key that opens every shared folder. Download it on the Backup page. This stays until you confirm it is saved.`;
|
|
212
219
|
}
|
|
213
220
|
/** The banner while the instance `.env` sets other names than the ones an admin last confirmed saved (#1976): names only, never a value. */
|
|
214
221
|
export function envKeysChanged(change) {
|
|
215
222
|
const parts = [
|
|
216
|
-
...(change.added.length > 0 ? [
|
|
217
|
-
...(change.removed.length > 0 ? [
|
|
223
|
+
...(change.added.length > 0 ? [`${change.added.length} added`] : []),
|
|
224
|
+
...(change.removed.length > 0 ? [`${change.removed.length} removed`] : [])
|
|
218
225
|
];
|
|
219
|
-
return `${ENV_KEYS_CHANGED_PREFIX} (${parts.join('
|
|
226
|
+
return `${ENV_KEYS_CHANGED_PREFIX} (${parts.join(', ')}). Download it on the Backup page and keep the copy somewhere other than this server. This stays until you confirm it is saved.`;
|
|
220
227
|
}
|
|
221
228
|
/** The scheduled job automatic backups run as (#1720). */
|
|
222
229
|
export const AUTO_BACKUP_JOB_ID = 'backup.auto';
|
|
@@ -225,6 +232,14 @@ export const BACKUP_HEALTH_JOB_ID = 'backup.health';
|
|
|
225
232
|
/** The title of the notification sent when backups stop working (#1563): a backup failed, or none is recent enough. */
|
|
226
233
|
export const BACKUPS_NOT_WORKING_TITLE = 'Backups are not working';
|
|
227
234
|
const DAY_MS = 86_400_000;
|
|
235
|
+
/** What the dashboard says for a health fault (#2354): plain words; the developer reason stays in the log and on the Backup page. */
|
|
236
|
+
export function backupHealthPlain(health) {
|
|
237
|
+
const since = health.lastGood ? localTime(health.lastGood.at) : null;
|
|
238
|
+
const last = since ? `The last good backup was ${since}.` : 'There is no good backup yet.';
|
|
239
|
+
return health.failure
|
|
240
|
+
? `The last backup failed (${localTime(health.failure.at)}), so backups are not working. ${last} Open the Backup page to see what went wrong and make a new one.`
|
|
241
|
+
: `${since ? `There has been no good backup since ${since}` : 'This site has no backup yet'}. Open the Backup page and make one.`;
|
|
242
|
+
}
|
|
228
243
|
/** A number of bytes as admins read it. */
|
|
229
244
|
export function formatBytes(bytes) {
|
|
230
245
|
if (!Number.isFinite(bytes) || bytes < 1024)
|
|
@@ -806,14 +821,14 @@ class BackupManager extends BaseManager {
|
|
|
806
821
|
const envReminder = await this.checkInstanceEnvKeys();
|
|
807
822
|
const key = currentBackupKey();
|
|
808
823
|
if (key) {
|
|
809
|
-
this.markReadyIfDegradedBy(
|
|
824
|
+
this.markReadyIfDegradedBy(BACKUP_KEY_MISSING_PLAIN);
|
|
810
825
|
// #1876: the banner asking an admin to save the key off the server, until one confirms;
|
|
811
826
|
// then #1976's, while the .env key set differs from the one last confirmed. One at a time,
|
|
812
827
|
// and one confirmation ("I have saved .env", which holds the key) can clear both.
|
|
813
828
|
// #2124: then the shared-folders key's, while no saved .env holding the current one is confirmed.
|
|
814
829
|
const shared = this.sharedFoldersKeyInfo();
|
|
815
|
-
const sharedReminder = shared.set && !shared.saved && shared.fingerprint ? sharedFoldersKeyNotSaved(
|
|
816
|
-
const reminder = this.backupKeyInfo().saved ? (envReminder ?? sharedReminder) : backupKeyNotSaved(
|
|
830
|
+
const sharedReminder = shared.set && !shared.saved && shared.fingerprint ? sharedFoldersKeyNotSaved() : null;
|
|
831
|
+
const reminder = this.backupKeyInfo().saved ? (envReminder ?? sharedReminder) : backupKeyNotSaved();
|
|
817
832
|
const status = this.getManagerStatus();
|
|
818
833
|
const ours = status.state === 'degraded' && [KEY_NOT_SAVED_PREFIX, ENV_KEYS_CHANGED_PREFIX, SHARED_FOLDERS_KEY_NOT_SAVED_PREFIX].some((p) => status.reason?.startsWith(p));
|
|
819
834
|
if (!reminder) {
|
|
@@ -821,14 +836,15 @@ class BackupManager extends BaseManager {
|
|
|
821
836
|
this.markReady();
|
|
822
837
|
}
|
|
823
838
|
else if (status.state !== 'degraded' || ours) {
|
|
824
|
-
|
|
839
|
+
// The saved-key settings are records the app writes when an admin confirms, not values to edit: the fix is the Backup page, with no link to them (#2348, #2354).
|
|
840
|
+
this.markNeedsAttention(reminder, undefined, BACKUP_PAGE_FIX);
|
|
825
841
|
}
|
|
826
842
|
return;
|
|
827
843
|
}
|
|
828
844
|
// A degraded backup directory (preflight) is the worse fault, and keeps the banner.
|
|
829
845
|
const status = this.getManagerStatus();
|
|
830
|
-
if (status.state !== 'degraded' || status.reason ===
|
|
831
|
-
this.markNeedsAttention(
|
|
846
|
+
if (status.state !== 'degraded' || status.reason === BACKUP_KEY_MISSING_PLAIN)
|
|
847
|
+
this.markNeedsAttention(BACKUP_KEY_MISSING_PLAIN, BACKUP_KEY_ENV);
|
|
832
848
|
if (this.alreadyNotified(BACKUP_KEY_MISSING_TITLE))
|
|
833
849
|
return;
|
|
834
850
|
await this.notifyAdmins('error', BACKUP_KEY_MISSING_TITLE, `${BACKUP_KEY_MISSING}. Set ${BACKUP_KEY_ENV} in the environment or the instance .env, and restart. Until then the dashboard lists Backup under "Working, but needs your attention"; this is the only notification about it.`);
|
|
@@ -881,7 +897,13 @@ class BackupManager extends BaseManager {
|
|
|
881
897
|
if (info.added.length === 0 && info.removed.length === 0)
|
|
882
898
|
return null;
|
|
883
899
|
const reminder = envKeysChanged(info);
|
|
884
|
-
|
|
900
|
+
// The notification keeps the names (never a value); the dashboard item says only how many.
|
|
901
|
+
const names = [
|
|
902
|
+
...(info.added.length > 0 ? [`added: ${info.added.join(', ')}`] : []),
|
|
903
|
+
...(info.removed.length > 0 ? [`removed: ${info.removed.join(', ')}`] : [])
|
|
904
|
+
];
|
|
905
|
+
logger.info(`[BackupManager] The instance .env key set changed since an admin last saved it (${names.join('; ')})`);
|
|
906
|
+
const message = `${reminder} Changes: ${names.join('; ')}. The /admin dashboard lists Backup under "Working, but needs your attention" until then.`;
|
|
885
907
|
if (!this.alreadyNotified(ENV_KEYS_CHANGED_TITLE, message))
|
|
886
908
|
await this.notifyAdmins('warning', ENV_KEYS_CHANGED_TITLE, message);
|
|
887
909
|
return reminder;
|
|
@@ -1182,8 +1204,9 @@ class BackupManager extends BaseManager {
|
|
|
1182
1204
|
if (this.provider) {
|
|
1183
1205
|
health = await this.getBackupHealth();
|
|
1184
1206
|
if (health.fault) {
|
|
1185
|
-
|
|
1186
|
-
this.
|
|
1207
|
+
const plain = backupHealthPlain(health);
|
|
1208
|
+
this.markDegraded(plain, health.failure ? undefined : 'ngdpbase.backup.auto-backup', BACKUP_PAGE_FIX);
|
|
1209
|
+
this.healthFault = plain;
|
|
1187
1210
|
if (!health.failure)
|
|
1188
1211
|
await this.notifyStaleOnce(health);
|
|
1189
1212
|
}
|
|
@@ -282,7 +282,21 @@ export interface ManagerStatus {
|
|
|
282
282
|
configKey?: string;
|
|
283
283
|
/** Whether the feature is down or works but needs an admin (#1946). Present when degraded or failed. */
|
|
284
284
|
kind?: DegradedKind;
|
|
285
|
+
/** Where the fix is, when it is not the setting `configKey` names (a record the app writes, not a value to edit). */
|
|
286
|
+
fixUrl?: string;
|
|
287
|
+
/** What the link to `fixUrl` says, e.g. "Open Admin → Backup". */
|
|
288
|
+
fixLabel?: string;
|
|
289
|
+
/** One line per problem, drawn as a list under `reason` (#2348). `reason` stays the headline and the audit text. */
|
|
290
|
+
details?: string[];
|
|
291
|
+
/** Buttons under the item (#2355): each posts a form to `action` with `fields`; the page adds the CSRF token. Labels and values are text. */
|
|
292
|
+
buttons?: Array<{
|
|
293
|
+
label: string;
|
|
294
|
+
action: string;
|
|
295
|
+
fields: Record<string, string>;
|
|
296
|
+
}>;
|
|
285
297
|
}
|
|
298
|
+
/** What a caller may add to a needs-attention status (#2348). */
|
|
299
|
+
export type AttentionOptions = Pick<ManagerStatus, 'fixUrl' | 'fixLabel' | 'details' | 'buttons'>;
|
|
286
300
|
declare abstract class BaseManager {
|
|
287
301
|
/** Reference to the wiki engine */
|
|
288
302
|
protected engine: WikiEngine;
|
|
@@ -425,18 +439,18 @@ declare abstract class BaseManager {
|
|
|
425
439
|
* starts degraded and stays degraded must not re-emit on every boot, or
|
|
426
440
|
* the signal is buried in its own noise.
|
|
427
441
|
*/
|
|
428
|
-
protected markDegraded(reason: string, configKey?: string): boolean;
|
|
442
|
+
protected markDegraded(reason: string, configKey?: string, opts?: AttentionOptions): boolean;
|
|
429
443
|
/**
|
|
430
444
|
* Record that this manager works, but an administrator has something to do
|
|
431
445
|
* (#1946). Still `degraded` — it stays on the dashboard, is audited, and
|
|
432
446
|
* clears with `markReadyIfDegradedBy` — but shown as needing attention, not
|
|
433
447
|
* as down.
|
|
434
448
|
*/
|
|
435
|
-
protected markNeedsAttention(reason: string, configKey?: string): boolean;
|
|
449
|
+
protected markNeedsAttention(reason: string, configKey?: string, opts?: AttentionOptions): boolean;
|
|
436
450
|
/** Record that this manager is deliberately off. Not a problem (#1155). */
|
|
437
451
|
protected markDisabled(reason: string): boolean;
|
|
438
452
|
/** Record that this manager could not initialise at all (#1155). */
|
|
439
|
-
protected markFailed(reason: string, configKey?: string): boolean;
|
|
453
|
+
protected markFailed(reason: string, configKey?: string, opts?: AttentionOptions): boolean;
|
|
440
454
|
/**
|
|
441
455
|
* Record that this manager is working.
|
|
442
456
|
*
|
|
@@ -18,6 +18,7 @@ import logger from '../utils/logger.js';
|
|
|
18
18
|
import { checkConfiguredPath } from '../utils/PathPreflight.js';
|
|
19
19
|
import { recordAuditEvent } from '../utils/auditEvents.js';
|
|
20
20
|
import { AUDIT_EVENT } from '../utils/auditEventNames.js';
|
|
21
|
+
import { STAGED_RESTORE_FIX, stagedRestoreNotApplied, UNUSABLE_FOLDER_REASON } from '../utils/plainAttention.js';
|
|
21
22
|
class BaseManager {
|
|
22
23
|
/** Reference to the wiki engine */
|
|
23
24
|
engine;
|
|
@@ -121,7 +122,9 @@ class BaseManager {
|
|
|
121
122
|
const { applyStagedPartsAtStart } = await import('./BackupManager.js');
|
|
122
123
|
this.stagedRestore = applyStagedPartsAtStart(this.engine, this.registeredName(), declared);
|
|
123
124
|
if (this.stagedRestore?.refused) {
|
|
124
|
-
|
|
125
|
+
// #2354: the dashboard says it in plain words; the parts and the cause stay in the log.
|
|
126
|
+
logger.warn(`[${this.constructor.name}] A staged restore of ${declared.map((d) => d.part).join(', ')} was not applied: ${this.stagedRestore.refused}`);
|
|
127
|
+
this.markNeedsAttention(stagedRestoreNotApplied('some of the site\'s data'), undefined, STAGED_RESTORE_FIX);
|
|
125
128
|
}
|
|
126
129
|
}
|
|
127
130
|
/**
|
|
@@ -193,7 +196,8 @@ class BaseManager {
|
|
|
193
196
|
// degrading manager already goes through, so setting the state here is
|
|
194
197
|
// what makes twelve invisible degradations visible without each caller
|
|
195
198
|
// remembering to say so.
|
|
196
|
-
|
|
199
|
+
// #2354: the dashboard says it in plain words; the path and key are in the line above.
|
|
200
|
+
this.markDegraded(UNUSABLE_FOLDER_REASON, configKey);
|
|
197
201
|
}
|
|
198
202
|
return result;
|
|
199
203
|
}
|
|
@@ -216,8 +220,8 @@ class BaseManager {
|
|
|
216
220
|
* starts degraded and stays degraded must not re-emit on every boot, or
|
|
217
221
|
* the signal is buried in its own noise.
|
|
218
222
|
*/
|
|
219
|
-
markDegraded(reason, configKey) {
|
|
220
|
-
return this.transition({ state: 'degraded', reason, configKey, kind: 'outage' });
|
|
223
|
+
markDegraded(reason, configKey, opts = {}) {
|
|
224
|
+
return this.transition({ state: 'degraded', reason, configKey, kind: 'outage', ...opts });
|
|
221
225
|
}
|
|
222
226
|
/**
|
|
223
227
|
* Record that this manager works, but an administrator has something to do
|
|
@@ -225,16 +229,16 @@ class BaseManager {
|
|
|
225
229
|
* clears with `markReadyIfDegradedBy` — but shown as needing attention, not
|
|
226
230
|
* as down.
|
|
227
231
|
*/
|
|
228
|
-
markNeedsAttention(reason, configKey) {
|
|
229
|
-
return this.transition({ state: 'degraded', reason, configKey, kind: 'attention' });
|
|
232
|
+
markNeedsAttention(reason, configKey, opts = {}) {
|
|
233
|
+
return this.transition({ state: 'degraded', reason, configKey, kind: 'attention', ...opts });
|
|
230
234
|
}
|
|
231
235
|
/** Record that this manager is deliberately off. Not a problem (#1155). */
|
|
232
236
|
markDisabled(reason) {
|
|
233
237
|
return this.transition({ state: 'disabled', reason });
|
|
234
238
|
}
|
|
235
239
|
/** Record that this manager could not initialise at all (#1155). */
|
|
236
|
-
markFailed(reason, configKey) {
|
|
237
|
-
return this.transition({ state: 'failed', reason, configKey, kind: 'outage' });
|
|
240
|
+
markFailed(reason, configKey, opts = {}) {
|
|
241
|
+
return this.transition({ state: 'failed', reason, configKey, kind: 'outage', ...opts });
|
|
238
242
|
}
|
|
239
243
|
/**
|
|
240
244
|
* Record that this manager is working.
|
|
@@ -54,6 +54,8 @@ declare class ConfigurationManager extends BaseManager {
|
|
|
54
54
|
private defaultConfig;
|
|
55
55
|
private customConfig;
|
|
56
56
|
private mergedConfig;
|
|
57
|
+
/** #2279: counts every change to `mergedConfig`, so a reader may keep what it derived from a value until the next one. */
|
|
58
|
+
private revision;
|
|
57
59
|
/** #1638: shipped ⊕ enabled addons, before the operator's file — what the build declares. */
|
|
58
60
|
private declaredConfig;
|
|
59
61
|
/** #1813: each enabled addon's own default-config.json, as folded into the merge. */
|
|
@@ -200,6 +202,32 @@ declare class ConfigurationManager extends BaseManager {
|
|
|
200
202
|
* @throws {Error} If default configuration file cannot be loaded
|
|
201
203
|
*/
|
|
202
204
|
private loadConfigurations;
|
|
205
|
+
/**
|
|
206
|
+
* A number that changes whenever the configuration in force changes (#2279):
|
|
207
|
+
* a load or reload, a save, a reset, a restore, a runtime value an add-on
|
|
208
|
+
* sets. Equal numbers mean nothing was changed through this manager, so
|
|
209
|
+
* what a reader derived from `getProperty` then still holds. A value
|
|
210
|
+
* returned by `getProperty` must not be changed in place; that is the one
|
|
211
|
+
* change this cannot see.
|
|
212
|
+
*/
|
|
213
|
+
getRevision(): number;
|
|
214
|
+
/** What the #2255 page-write migration rewrote in memory, one sentence each; PolicyDecisionPoint reports it. */
|
|
215
|
+
pageWritePolicyMigration: string[];
|
|
216
|
+
/** Each `pageWritePolicyMigration` line in plain words, for the needs-attention entry. */
|
|
217
|
+
pageWritePolicyMigrationShown: Record<string, string>;
|
|
218
|
+
/**
|
|
219
|
+
* #2255: before the merge, rewrite the custom layer's stored copies of the
|
|
220
|
+
* shipped role policies whose page writes moved to the category policies
|
|
221
|
+
* (`migrateStoredPageWritePolicies`). In memory only, like the other
|
|
222
|
+
* migrations here; one warning per policy changed.
|
|
223
|
+
*/
|
|
224
|
+
private migrateStoredPageWritePolicies;
|
|
225
|
+
/**
|
|
226
|
+
* #2246: `ngdpbase.stores.<kind>.owner|encrypt` in the custom config have
|
|
227
|
+
* been ignored since #1505. One warning per key, and one needs-attention
|
|
228
|
+
* entry naming them all. Nothing is changed, carried over or removed.
|
|
229
|
+
*/
|
|
230
|
+
private reportLegacyStoreKeys;
|
|
203
231
|
/**
|
|
204
232
|
* #642: One-shot migration shim. If the custom config sets either of the
|
|
205
233
|
* legacy keys `ngdpbase.base-url` or `ngdpbase.baseURL`, copy the value
|
|
@@ -56,7 +56,7 @@ declare class ConsentManager extends BaseManager {
|
|
|
56
56
|
private readonly addons;
|
|
57
57
|
/** Username → their records, read once and dropped on every write. */
|
|
58
58
|
private readonly cache;
|
|
59
|
-
/** Document id → why it cannot be asked for
|
|
59
|
+
/** Document id → why it cannot be asked for (the needs-attention reason is made from these), and whether it comes with an add-on (core's Required Pages Sync does not restore those). */
|
|
60
60
|
private readonly problems;
|
|
61
61
|
private attentionReason;
|
|
62
62
|
constructor(engine: WikiEngine);
|
|
@@ -50,6 +50,7 @@ import { applyStagedRestore, manifestPath, runOffThread, setAsideStagedRestore,
|
|
|
50
50
|
import { BACKUP_KEY_ENV, backupKeyFingerprint, currentBackupKey, LEFT_OUT_FOR_NO_KEY } from '../utils/backupKey.js';
|
|
51
51
|
// The environment variable holding the SQLCipher key: one declaration, `src/utils/databaseKey.ts`.
|
|
52
52
|
import { DATABASE_KEY_ENV } from '../utils/databaseKey.js';
|
|
53
|
+
import { STAGED_RESTORE_FIX } from '../utils/plainAttention.js';
|
|
53
54
|
export { DATABASE_KEY_ENV };
|
|
54
55
|
// #1723, #1876: the backup key has one declaration (`src/utils/backupKey.ts`); re-exported for the callers that import it from here.
|
|
55
56
|
export { BACKUP_KEY_ENV, backupKeyFingerprint } from '../utils/backupKey.js';
|
|
@@ -138,7 +139,7 @@ class DatabaseManager extends BaseManager {
|
|
|
138
139
|
catch (err) {
|
|
139
140
|
this.markUnavailable(`A staged database restore could not be put in place: ${err.message}. ` +
|
|
140
141
|
'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 ' +
|
|
141
|
-
`${path.basename(manifestPath(file))}. Stop the server, put the files back as they should be, and restart (#1892)
|
|
142
|
+
`${path.basename(manifestPath(file))}. Stop the server, put the files back as they should be, and restart (#1892)`, 'A saved copy of the databases was being put back and it did not finish, so the site is in maintenance mode and no database is open. Some may already have been replaced. Ask whoever runs the server to check the database files and restart the site; this is done on the server, outside this page.');
|
|
142
143
|
return;
|
|
143
144
|
}
|
|
144
145
|
// #1892: a database that cannot be opened safely (no key or the wrong key
|
|
@@ -151,7 +152,7 @@ class DatabaseManager extends BaseManager {
|
|
|
151
152
|
provider = new SqliteDatabaseProvider(file, key, DATABASE_MIGRATIONS, 'schema_migrations', { busyTimeoutMs: this.busyTimeoutMs });
|
|
152
153
|
}
|
|
153
154
|
catch (err) {
|
|
154
|
-
this.markUnavailable(DatabaseManager.openFailure(file, err.message, key));
|
|
155
|
+
this.markUnavailable(DatabaseManager.openFailure(file, err.message, key), 'The database could not be opened safely, so the site is in maintenance mode. If its key is missing or wrong, fix it in the server\'s settings (outside this page) and restart the site. If the file is damaged, restore the databases from a backup.', STAGED_RESTORE_FIX);
|
|
155
156
|
return;
|
|
156
157
|
}
|
|
157
158
|
this.provider = provider;
|
|
@@ -195,11 +196,12 @@ class DatabaseManager extends BaseManager {
|
|
|
195
196
|
* administrators can sign in and reach /admin, and `reportAtBoot()` sends
|
|
196
197
|
* them the reason as a critical notification once notifications exist.
|
|
197
198
|
*/
|
|
198
|
-
markUnavailable(reason) {
|
|
199
|
+
markUnavailable(reason, plain, fix) {
|
|
199
200
|
this.unavailable = reason;
|
|
200
201
|
this.provider = null;
|
|
201
202
|
logger.error(`[DatabaseManager] ${reason}`);
|
|
202
|
-
|
|
203
|
+
// #2354: the dashboard says it in plain words; the developer text is the log line above.
|
|
204
|
+
this.markFailed(plain, 'ngdpbase.database.file', fix);
|
|
203
205
|
// #1906: no message of its own, so visitors see the site's maintenance message.
|
|
204
206
|
this.engine.maintenance.enter(reason);
|
|
205
207
|
}
|
|
@@ -690,7 +692,13 @@ class DatabaseManager extends BaseManager {
|
|
|
690
692
|
}
|
|
691
693
|
/** Degrade for one cause; the dashboard shows every cause still standing. */
|
|
692
694
|
fault(cause, reason, kind) {
|
|
693
|
-
|
|
695
|
+
// #2354: the dashboard shows the plain words; `reason` is the developer text, already logged by the caller.
|
|
696
|
+
const plain = cause === 'restore'
|
|
697
|
+
? 'A saved copy of the databases was not put back when the site started, so the databases that were there before are still in use. Open the Backup page to see what to do next.'
|
|
698
|
+
: this.backupKey()
|
|
699
|
+
? "The backup key is the same as the database key, so one stolen secret would open both. Give the backups a key of their own, in the server's settings, outside this page."
|
|
700
|
+
: "Databases are not being backed up, because the backup key is not set. This is set in the server's settings, outside this page.";
|
|
701
|
+
this.faults.set(cause, { reason, plain, kind });
|
|
694
702
|
this.markFaults();
|
|
695
703
|
}
|
|
696
704
|
clearFault(cause) {
|
|
@@ -704,11 +712,12 @@ class DatabaseManager extends BaseManager {
|
|
|
704
712
|
/** Every cause still standing; down when any one of them is (#1946). */
|
|
705
713
|
markFaults() {
|
|
706
714
|
const faults = [...this.faults.values()];
|
|
707
|
-
const reason = faults.map((f) => f.
|
|
715
|
+
const reason = faults.map((f) => f.plain).join(' ');
|
|
716
|
+
const fix = this.faults.has('restore') ? STAGED_RESTORE_FIX : {};
|
|
708
717
|
if (faults.some((f) => f.kind === 'outage'))
|
|
709
|
-
this.markDegraded(reason);
|
|
718
|
+
this.markDegraded(reason, undefined, fix);
|
|
710
719
|
else
|
|
711
|
-
this.markNeedsAttention(reason);
|
|
720
|
+
this.markNeedsAttention(reason, undefined, fix);
|
|
712
721
|
}
|
|
713
722
|
async notify(level, title, message) {
|
|
714
723
|
try {
|
|
@@ -35,7 +35,7 @@ import type { MailAttachment, MailMessage, MailProvider } from '../mail/MailProv
|
|
|
35
35
|
import type { ActorContext } from '../context/ActorContext.js';
|
|
36
36
|
export type { MailAttachment } from '../mail/MailProvider.js';
|
|
37
37
|
/** The degraded reason while sends fail; cleared by the next success. */
|
|
38
|
-
export declare const SEND_FAILING_REASON = "
|
|
38
|
+
export declare const SEND_FAILING_REASON = "The site can't send email right now. Check the email settings.";
|
|
39
39
|
/** Title of the administrator notice. */
|
|
40
40
|
export declare const SEND_FAILING_TITLE = "Email could not be sent";
|
|
41
41
|
/** An attachment the message cannot carry (#1974): refused before sending, so it is not a delivery failure. */
|