@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.
Files changed (45) hide show
  1. package/lib/src/context/ApiContext.d.ts +10 -3
  2. package/lib/src/context/ApiContext.js +36 -3
  3. package/lib/src/ice/runExport.d.ts +8 -3
  4. package/lib/src/ice/searchExportSource.d.ts +44 -0
  5. package/lib/src/ice/types.d.ts +6 -0
  6. package/lib/src/managers/AttachmentManager.d.ts +11 -0
  7. package/lib/src/managers/AuditManager.d.ts +0 -1
  8. package/lib/src/managers/AuthManager.d.ts +38 -0
  9. package/lib/src/managers/AuthManager.js +84 -12
  10. package/lib/src/managers/BackupManager.d.ts +9 -2
  11. package/lib/src/managers/BackupManager.js +42 -19
  12. package/lib/src/managers/BaseManager.d.ts +17 -3
  13. package/lib/src/managers/BaseManager.js +12 -8
  14. package/lib/src/managers/ConfigurationManager.d.ts +13 -0
  15. package/lib/src/managers/ConsentManager.d.ts +1 -1
  16. package/lib/src/managers/DatabaseManager.js +17 -8
  17. package/lib/src/managers/EmailManager.d.ts +1 -1
  18. package/lib/src/managers/ExportManager.d.ts +1 -44
  19. package/lib/src/managers/ExportManager.js +3 -115
  20. package/lib/src/managers/ImportManager.d.ts +7 -0
  21. package/lib/src/managers/PageManager.d.ts +9 -0
  22. package/lib/src/managers/PolicyEvaluator.d.ts +113 -30
  23. package/lib/src/managers/ThemeManager.d.ts +20 -1
  24. package/lib/src/providers/FileLoggingProvider.d.ts +12 -1
  25. package/lib/src/providers/FileLoggingProvider.js +37 -4
  26. package/lib/src/security/PolicyDecisionPoint.d.ts +50 -3
  27. package/lib/src/security/PolicyInformationPoint.d.ts +27 -1
  28. package/lib/src/security/ownedRecords.d.ts +69 -0
  29. package/lib/src/security/signedOutWrites.d.ts +69 -0
  30. package/lib/src/security/signedOutWrites.js +146 -0
  31. package/lib/src/types/Policy.d.ts +26 -30
  32. package/lib/src/utils/addonConfigLayer.d.ts +6 -3
  33. package/lib/src/utils/addonConfigLayer.js +61 -3
  34. package/lib/src/utils/addonHost.d.ts +25 -0
  35. package/lib/src/utils/addonHost.js +29 -1
  36. package/lib/src/utils/availability.d.ts +15 -0
  37. package/lib/src/utils/availability.js +88 -0
  38. package/lib/src/utils/pageTitleConflict.d.ts +32 -0
  39. package/lib/src/utils/pageWritePolicies.d.ts +48 -29
  40. package/lib/src/utils/plainAttention.d.ts +19 -0
  41. package/lib/src/utils/plainAttention.js +18 -0
  42. package/lib/src/utils/policyAttributes.d.ts +113 -22
  43. package/lib/src/utils/policyAttributes.js +228 -43
  44. package/lib/src/utils/roleGrants.d.ts +1 -1
  45. 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.decide(ctx.getActor(), { action: 'ledger-adjust', resource: { type: 'ledger-entry', id } });
205
- * if (!d.permit) await ctx.enforceDeny('ledger-adjust', d);
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.decide(ctx.getActor(), { action: 'ledger-adjust', resource: { type: 'ledger-entry', id } });
273
- * if (!d.permit) await ctx.enforceDeny('ledger-adjust', d);
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 one file the last shim made. What came before
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 not one file
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 {};
@@ -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
  *
@@ -94,7 +94,6 @@ interface AccessContext {
94
94
  interface PolicyInfo {
95
95
  id: string;
96
96
  name: string;
97
- priority?: number;
98
97
  }
99
98
  /**
100
99
  * Context for authentication events
@@ -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
- this.markDegraded(`${CREDENTIALS_KEY_ENV} is not set, so the credentials store is closed`, CREDENTIALS_KEY_ENV);
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
- this.markDegraded(`The credentials store ${file} could not be read: ${err.message}`, 'ngdpbase.auth.credentials.file');
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
- if (!configManager.isBaseUrlExplicit()) {
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
- logger.warn(`[AuthManager] Passkeys off: base-url ${configManager.getBaseURL()} is not https (or localhost), where browsers allow WebAuthn (#448)`);
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
- this.markDegraded(message, 'ngdpbase.auth.credentials.file');
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 { now: reach(usable), ifEnrolled: reach(offered) };
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
- this.markNeedsAttention(`${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.`, 'ngdpbase.auth.factors');
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(fingerprint: string): string;
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(fingerprint: string): string;
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(fingerprint) {
187
- return `Save the backup key off this server: Admin → Backup shows it (fingerprint ${fingerprint}). Every backup is encrypted with it, and a restore on a new server needs it. This stays until an admin confirms it is saved`;
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 keys in .env changed since an admin last saved it';
207
- const KEY_NOT_SAVED_PREFIX = 'Save the backup key off this server';
208
- const SHARED_FOLDERS_KEY_NOT_SAVED_PREFIX = 'Save .env off this server: it holds the shared-folders key';
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(fingerprint) {
211
- return `${SHARED_FOLDERS_KEY_NOT_SAVED_PREFIX} (${SHARED_FOLDERS_KEY_ENV}, fingerprint ${fingerprint}), which opens every shared folder. Download .env on Admin → Backup and keep the copy off this server. This stays until an admin confirms it is saved`;
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 ? [`added: ${change.added.join(', ')}`] : []),
217
- ...(change.removed.length > 0 ? [`removed: ${change.removed.join(', ')}`] : [])
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('; ')}). Download .env on Admin → Backup and keep the copy off this server. This stays until an admin confirms it is saved`;
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(BACKUP_KEY_MISSING);
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(shared.fingerprint) : null;
816
- const reminder = this.backupKeyInfo().saved ? (envReminder ?? sharedReminder) : backupKeyNotSaved(backupKeyFingerprint(key));
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
- this.markNeedsAttention(reminder, reminder === envReminder ? ENV_KEYS_SAVED_KEY : reminder === sharedReminder ? SHARED_FOLDERS_KEY_SAVED_KEY : BACKUP_KEY_SAVED_KEY);
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 === BACKUP_KEY_MISSING)
831
- this.markNeedsAttention(BACKUP_KEY_MISSING, BACKUP_KEY_ENV);
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
- const message = `${reminder}. The /admin dashboard lists Backup under "Working, but needs your attention" until then.`;
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
- this.markDegraded(health.fault, health.failure ? undefined : 'ngdpbase.backup.auto-backup');
1186
- this.healthFault = health.fault;
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
  }