@jwilleke/ngdpbase-types 5.43.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 +36 -3
- 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/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 +13 -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 +9 -0
- package/lib/src/managers/PolicyEvaluator.d.ts +113 -30
- package/lib/src/managers/ThemeManager.d.ts +20 -1
- package/lib/src/providers/FileLoggingProvider.d.ts +12 -1
- package/lib/src/providers/FileLoggingProvider.js +37 -4
- package/lib/src/security/PolicyDecisionPoint.d.ts +50 -3
- package/lib/src/security/PolicyInformationPoint.d.ts +27 -1
- package/lib/src/security/ownedRecords.d.ts +69 -0
- package/lib/src/security/signedOutWrites.d.ts +69 -0
- package/lib/src/security/signedOutWrites.js +146 -0
- package/lib/src/types/Policy.d.ts +26 -30
- 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/availability.d.ts +15 -0
- package/lib/src/utils/availability.js +88 -0
- package/lib/src/utils/pageTitleConflict.d.ts +32 -0
- package/lib/src/utils/pageWritePolicies.d.ts +48 -29
- package/lib/src/utils/plainAttention.d.ts +19 -0
- package/lib/src/utils/plainAttention.js +18 -0
- package/lib/src/utils/policyAttributes.d.ts +113 -22
- package/lib/src/utils/policyAttributes.js +228 -43
- package/lib/src/utils/roleGrants.d.ts +1 -1
- package/package.json +7 -7
|
@@ -28,6 +28,7 @@ import type { AgentTokenGrant, PermissionSubject, ProxyClaim } from '../managers
|
|
|
28
28
|
import type { ShareGrant } from '../types/Share.js';
|
|
29
29
|
import { type ReauthMode } from '../managers/AuthManager.js';
|
|
30
30
|
import { type Decision } from '../types/Policy.js';
|
|
31
|
+
import type { OwnedAuditResource } from '../security/ownedRecords.js';
|
|
31
32
|
/**
|
|
32
33
|
* Thrown by ApiContext guard methods (requirePermission, actingUsername).
|
|
33
34
|
* Route handlers should catch this and forward `status` to `res.status()`.
|
|
@@ -201,8 +202,14 @@ export declare class ApiContext extends BaseContext {
|
|
|
201
202
|
* - an allow: a programming error. A refusal never silently passes.
|
|
202
203
|
*
|
|
203
204
|
* @example
|
|
204
|
-
* const d = await pdp.
|
|
205
|
-
* if (!d.permit) await ctx.enforceDeny('
|
|
205
|
+
* const d = await pdp.decideOwnedRecord(ctx.subject, { action: 'phr-read', resource: { type: 'phr-record', id, attributes: { owner } } });
|
|
206
|
+
* if (!d.permit) await ctx.enforceDeny('phr-read', d, { type: 'phr-record', id, owner, ownerUser });
|
|
206
207
|
*/
|
|
207
|
-
enforceDeny(permission: string, decision: Decision): Promise<never>;
|
|
208
|
+
enforceDeny(permission: string, decision: Decision, resource?: OwnedAuditResource): Promise<never>;
|
|
209
|
+
/**
|
|
210
|
+
* The refusal of one add-on record (#1823), recorded before the error is
|
|
211
|
+
* thrown. `authorization-deny` is continue-on-failure, so a lost record does
|
|
212
|
+
* not change the refusal. `owner` is the stored identity @id.
|
|
213
|
+
*/
|
|
214
|
+
private recordOwnedDenial;
|
|
208
215
|
}
|
|
@@ -6,6 +6,9 @@ import { BaseContext } from './BaseContext.js';
|
|
|
6
6
|
import { answeredWithPage } from '../managers/AuthManager.js';
|
|
7
7
|
import { STEP_UP_REASON } from '../types/Policy.js';
|
|
8
8
|
import { CONSENT_PATH } from '../types/Consent.js';
|
|
9
|
+
import { actorOf } from './ActorContext.js';
|
|
10
|
+
import { AUDIT_EVENT } from '../utils/auditEventNames.js';
|
|
11
|
+
import { recordAuditEvent } from '../utils/auditEvents.js';
|
|
9
12
|
// ── ApiError ────────────────────────────────────────────────────────────────
|
|
10
13
|
/**
|
|
11
14
|
* Thrown by ApiContext guard methods (requirePermission, actingUsername).
|
|
@@ -269,13 +272,15 @@ export class ApiContext extends BaseContext {
|
|
|
269
272
|
* - an allow: a programming error. A refusal never silently passes.
|
|
270
273
|
*
|
|
271
274
|
* @example
|
|
272
|
-
* const d = await pdp.
|
|
273
|
-
* if (!d.permit) await ctx.enforceDeny('
|
|
275
|
+
* const d = await pdp.decideOwnedRecord(ctx.subject, { action: 'phr-read', resource: { type: 'phr-record', id, attributes: { owner } } });
|
|
276
|
+
* if (!d.permit) await ctx.enforceDeny('phr-read', d, { type: 'phr-record', id, owner, ownerUser });
|
|
274
277
|
*/
|
|
275
|
-
async enforceDeny(permission, decision) {
|
|
278
|
+
async enforceDeny(permission, decision, resource) {
|
|
276
279
|
if (decision.permit) {
|
|
277
280
|
throw new Error(`ApiContext.enforceDeny('${permission}') was handed an allow decision: it enforces only a deny`);
|
|
278
281
|
}
|
|
282
|
+
if (resource)
|
|
283
|
+
await this.recordOwnedDenial(permission, decision, resource);
|
|
279
284
|
if (decision.reason === STEP_UP_REASON && this.request) {
|
|
280
285
|
// #1758: step-up is AuthManager's — the answer and its reauth-prompt record.
|
|
281
286
|
const authManager = this.engine.getManager('AuthManager');
|
|
@@ -288,4 +293,32 @@ export class ApiContext extends BaseContext {
|
|
|
288
293
|
? new ApiError(403, 'Forbidden')
|
|
289
294
|
: new ApiError(401, 'Authentication required');
|
|
290
295
|
}
|
|
296
|
+
/**
|
|
297
|
+
* The refusal of one add-on record (#1823), recorded before the error is
|
|
298
|
+
* thrown. `authorization-deny` is continue-on-failure, so a lost record does
|
|
299
|
+
* not change the refusal. `owner` is the stored identity @id.
|
|
300
|
+
*/
|
|
301
|
+
async recordOwnedDenial(permission, decision, resource) {
|
|
302
|
+
const who = this.subject
|
|
303
|
+
? actorOf(this.subject)
|
|
304
|
+
: { user: 'anonymous', ipAddress: this.ipAddress, metadata: { origin: 'request' } };
|
|
305
|
+
const sink = this.engine.getManager?.('AuditManager');
|
|
306
|
+
await recordAuditEvent(sink, {
|
|
307
|
+
eventType: AUDIT_EVENT.AUTHORIZATION_DENY,
|
|
308
|
+
...who,
|
|
309
|
+
action: permission,
|
|
310
|
+
resource: resource.id ?? '',
|
|
311
|
+
resourceType: resource.type,
|
|
312
|
+
result: 'deny',
|
|
313
|
+
reason: decision.reason || 'not permitted',
|
|
314
|
+
severity: 'medium',
|
|
315
|
+
metadata: {
|
|
316
|
+
...who.metadata,
|
|
317
|
+
...(resource.owner ? { owner: resource.owner } : {}),
|
|
318
|
+
...(resource.ownerUser ? { ownerUser: resource.ownerUser } : {}),
|
|
319
|
+
...(decision.policyIds?.length ? { policyIds: [...decision.policyIds] } : {}),
|
|
320
|
+
...(decision.conditionNotMet?.length ? { conditionNotMet: [...decision.conditionNotMet] } : {})
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
}
|
|
291
324
|
}
|
|
@@ -31,11 +31,16 @@ export declare function exportSize(items: readonly IceItem[]): number;
|
|
|
31
31
|
export declare function checkExportSize(items: readonly IceItem[], maxBytes: number): void;
|
|
32
32
|
/**
|
|
33
33
|
* Run the source's items through the chain, each shim's output the next
|
|
34
|
-
* one's input, and return the
|
|
34
|
+
* one's input, and return the file the last shim made. What came before
|
|
35
35
|
* travels with each item while a shim keeps the count: earlier metadata
|
|
36
36
|
* under later, as an import's chain does.
|
|
37
37
|
*
|
|
38
|
+
* Items that are already `file`s skip the chain and join what it makes. One
|
|
39
|
+
* file is returned as it is; several, or any when `zipName` is given, are
|
|
40
|
+
* packed into `<zipName>.zip` (`export.zip` by default), each under its own
|
|
41
|
+
* name, a repeated name made unique. The zip is held to `maxBytes` too.
|
|
42
|
+
*
|
|
38
43
|
* @throws IceExportFailed when a step holds more than `maxBytes`, a shim
|
|
39
|
-
* makes something other than what it declares, or the end is
|
|
44
|
+
* makes something other than what it declares, or the end is no file
|
|
40
45
|
*/
|
|
41
|
-
export declare function runExportChain(items: IceItem[], chain: readonly RegisteredShim[], tools: (shim: RegisteredShim) => ExportShimTools, settings: IceExportSettings): Promise<IceItem>;
|
|
46
|
+
export declare function runExportChain(items: IceItem[], chain: readonly RegisteredShim[], tools: (shim: RegisteredShim) => ExportShimTools, settings: IceExportSettings, zipName?: string): Promise<IceItem>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ActorContext } from '../context/ActorContext.js';
|
|
2
|
+
import { type IceExportSource, type IceItem } from './types.js';
|
|
3
|
+
/** Core's search export source. */
|
|
4
|
+
export declare const SEARCH_EXPORT_SOURCE = "core.search";
|
|
5
|
+
/** A search, as the page search takes it: the fields `/search` and `/api/assets/search` read. */
|
|
6
|
+
export interface SearchExportParams {
|
|
7
|
+
q?: string;
|
|
8
|
+
types?: string;
|
|
9
|
+
mimeCategory?: string;
|
|
10
|
+
year?: number;
|
|
11
|
+
category?: string[];
|
|
12
|
+
keywords?: string[];
|
|
13
|
+
systemKeywords?: string[];
|
|
14
|
+
status?: string[];
|
|
15
|
+
searchIn?: string[];
|
|
16
|
+
since?: string;
|
|
17
|
+
until?: string;
|
|
18
|
+
date?: string;
|
|
19
|
+
}
|
|
20
|
+
/** The search's parameters from a query string or a body: known fields only, empty ones left out. */
|
|
21
|
+
export declare function searchExportParams(src: Record<string, unknown> | null | undefined): SearchExportParams;
|
|
22
|
+
/** What `read` and `mayExport` take as `ref`. */
|
|
23
|
+
export declare const searchExportRef: (src: Record<string, unknown> | null | undefined) => string;
|
|
24
|
+
/** A page as an NCM item: its text and what a document made from it shows. */
|
|
25
|
+
export declare function pageItem(name: string, content: string, metadata: Record<string, unknown>): IceItem;
|
|
26
|
+
interface Manager {
|
|
27
|
+
getManager<M>(name: string): M | null | undefined;
|
|
28
|
+
}
|
|
29
|
+
export interface SearchExportDeps {
|
|
30
|
+
engine: Manager;
|
|
31
|
+
/** May this user export this page alone (`page-export` for it, in its private store or shared folder)? */
|
|
32
|
+
mayExportPage(ctx: ActorContext, pageName: string): Promise<boolean>;
|
|
33
|
+
limits(): {
|
|
34
|
+
maxItems: number;
|
|
35
|
+
maxBytes: number;
|
|
36
|
+
};
|
|
37
|
+
/** Is the Export action for a search offered to this user (page-export held)? */
|
|
38
|
+
offered(ctx: ActorContext): Promise<boolean>;
|
|
39
|
+
/** Does this user hold `page-export` now? Throws the step-up answer when it needs a fresh sign-in. */
|
|
40
|
+
allowed(ctx: ActorContext): Promise<boolean>;
|
|
41
|
+
permits(ctx: ActorContext, permission: string): Promise<boolean>;
|
|
42
|
+
}
|
|
43
|
+
export declare function searchExportSource(deps: SearchExportDeps): IceExportSource;
|
|
44
|
+
export {};
|
package/lib/src/ice/types.d.ts
CHANGED
|
@@ -641,6 +641,8 @@ export declare const EXPORT_FILE_KIND = "file";
|
|
|
641
641
|
export interface ExportReadInfo {
|
|
642
642
|
/** The shim that will make the file (`core.pdf`). */
|
|
643
643
|
format: string;
|
|
644
|
+
/** Tell the user something about what was read (what was left out and why); shown with the finished export. */
|
|
645
|
+
note(text: string): void;
|
|
644
646
|
}
|
|
645
647
|
/**
|
|
646
648
|
* The owner's door for reading the thing being exported (#2120, D5): a page
|
|
@@ -654,6 +656,8 @@ export interface IceExportSource {
|
|
|
654
656
|
label?: string;
|
|
655
657
|
/** The kind of the items it yields, e.g. `ncm` for a page. */
|
|
656
658
|
yields: IceKind;
|
|
659
|
+
/** Make the export a zip with this name (`search-results`) even when it holds one file. */
|
|
660
|
+
zipAs?: string;
|
|
657
661
|
/**
|
|
658
662
|
* Would this context be allowed to export `ref`? Asked only to decide
|
|
659
663
|
* whether to offer the Export action: an affordance, which records nothing.
|
|
@@ -724,6 +728,8 @@ export interface IceExportReport {
|
|
|
724
728
|
startedAt?: string;
|
|
725
729
|
finishedAt?: string;
|
|
726
730
|
error?: string;
|
|
731
|
+
/** What the source says about what it read, such as how many items were left out. */
|
|
732
|
+
notes?: string[];
|
|
727
733
|
file?: IceExportFile;
|
|
728
734
|
/** When the file is let go (`ngdpbase.export.engine.keep-minutes`). */
|
|
729
735
|
expiresAt?: string;
|
|
@@ -500,6 +500,17 @@ declare class AttachmentManager extends BaseManager implements CatalogSource {
|
|
|
500
500
|
buffer: Buffer;
|
|
501
501
|
metadata: AttachmentMetadata;
|
|
502
502
|
} | null>;
|
|
503
|
+
/**
|
|
504
|
+
* May `ctx` read this file, by the rules `GET /attachments/:id` serves it
|
|
505
|
+
* under: a file in the requester's own store, else `asset-read`, and a
|
|
506
|
+
* private file only to its owner or their delegate (PIP). No bytes are read.
|
|
507
|
+
*/
|
|
508
|
+
mayReadAttachment(attachmentId: string, ctx: ActorContext): Promise<boolean>;
|
|
509
|
+
/** A file's original bytes and metadata, when {@link mayReadAttachment} lets `ctx` read it; else null. */
|
|
510
|
+
readAttachmentFor(attachmentId: string, ctx: ActorContext): Promise<{
|
|
511
|
+
buffer: Buffer;
|
|
512
|
+
metadata: AttachmentMetadata;
|
|
513
|
+
} | null>;
|
|
503
514
|
/**
|
|
504
515
|
* The requester's own private stores, as provider locations (#1460).
|
|
505
516
|
*
|
|
@@ -60,6 +60,15 @@ export interface FactorEntry {
|
|
|
60
60
|
*/
|
|
61
61
|
preference?: number;
|
|
62
62
|
}
|
|
63
|
+
/** What an account can add or use toward a stronger sign-in (#2356); see `strongerSignInWays`. */
|
|
64
|
+
export interface StrongerSignInWays {
|
|
65
|
+
addPasskey: boolean;
|
|
66
|
+
addTotp: boolean;
|
|
67
|
+
usePasskey: boolean;
|
|
68
|
+
useTotp: boolean;
|
|
69
|
+
password: boolean;
|
|
70
|
+
outside: string[];
|
|
71
|
+
}
|
|
63
72
|
export interface Factor {
|
|
64
73
|
provider: string;
|
|
65
74
|
primary: boolean;
|
|
@@ -797,6 +806,13 @@ declare class AuthManager extends BaseManager {
|
|
|
797
806
|
* the request — so a passkey made here works only on that hostname.
|
|
798
807
|
*/
|
|
799
808
|
private registerPasskeys;
|
|
809
|
+
/**
|
|
810
|
+
* #2317: the setting says passkeys are on, but the site's address cannot
|
|
811
|
+
* support them (base-url not set explicitly, or neither https nor localhost),
|
|
812
|
+
* so none are offered. The same rule registerPasskeys applies. Admin →
|
|
813
|
+
* Configuration says so on that setting's row and offers to turn it off.
|
|
814
|
+
*/
|
|
815
|
+
passkeysCannotWork(): boolean;
|
|
800
816
|
/** TOTP enrolment (#421), available whenever the credentials store is open. */
|
|
801
817
|
private openTotp;
|
|
802
818
|
/** Whether an authenticator app can be enrolled here (#421). */
|
|
@@ -826,6 +842,8 @@ declare class AuthManager extends BaseManager {
|
|
|
826
842
|
private passkeyProvider;
|
|
827
843
|
/** The host passkeys are tied to, or null when passkeys are off (#448). */
|
|
828
844
|
passkeyRelyingParty(): PasskeyRelyingParty | null;
|
|
845
|
+
/** Can a passkey be added or used here at all (#2345)? False when passkeys are off or the address can't host them. */
|
|
846
|
+
passkeysAvailable(): boolean;
|
|
829
847
|
/** Options for `username` to enrol a passkey (#448); the caller keeps `challenge` for verify. */
|
|
830
848
|
/**
|
|
831
849
|
* May this account sign in with `providerId`? Its `allowedAuthMethods`, when
|
|
@@ -1212,6 +1230,26 @@ declare class AuthManager extends BaseManager {
|
|
|
1212
1230
|
now: Aal;
|
|
1213
1231
|
ifEnrolled: Aal;
|
|
1214
1232
|
}>;
|
|
1233
|
+
/**
|
|
1234
|
+
* The site's factors this account may use (`offered`: narrowed to its
|
|
1235
|
+
* `allowedAuthMethods`), and of those the ones it can use now (`usable`: a
|
|
1236
|
+
* factor that must be enrolled counts only once it is). One reading for
|
|
1237
|
+
* the level the account can reach and for what it is told to add (#2356).
|
|
1238
|
+
*/
|
|
1239
|
+
private accountFactors;
|
|
1240
|
+
/**
|
|
1241
|
+
* What this account can add or use here toward a stronger sign-in (#2356),
|
|
1242
|
+
* for the sign-in banners and the confirm page's wording. Never a decision:
|
|
1243
|
+
* who steps down and what step-up needs stay with `rolesAtSignIn` and
|
|
1244
|
+
* `stepUpNeeded`. "Add" is a passkey or an authenticator app the site
|
|
1245
|
+
* offers, the account is allowed and has not set up; "use" is one it has,
|
|
1246
|
+
* is allowed, and the site offers (the app's code as step-up offers it).
|
|
1247
|
+
* `password` is a password it has and is allowed, which the app's code
|
|
1248
|
+
* needs to reach AAL2. `outside` names any other allowed sign-in method
|
|
1249
|
+
* that reaches AAL2 by itself, such as an outside provider's.
|
|
1250
|
+
* Computed per call; never cached across accounts.
|
|
1251
|
+
*/
|
|
1252
|
+
strongerSignInWays(username: string): Promise<StrongerSignInWays>;
|
|
1215
1253
|
/**
|
|
1216
1254
|
* The roles an account holds at a lower level than they require only
|
|
1217
1255
|
* because it has not enrolled a factor it is allowed (#1690): it acts with
|
|
@@ -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
|
}
|