@jwilleke/ngdpbase-types 5.40.0 → 5.41.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 (43) hide show
  1. package/lib/packages/ngdpbase-types/index.d.ts +1 -1
  2. package/lib/src/ice/fileHandling.d.ts +6 -1
  3. package/lib/src/ice/runExport.d.ts +41 -0
  4. package/lib/src/ice/runImport.d.ts +16 -7
  5. package/lib/src/ice/shims/csv.d.ts +24 -0
  6. package/lib/src/ice/shims/export.d.ts +23 -0
  7. package/lib/src/ice/shims/index.d.ts +3 -1
  8. package/lib/src/ice/shims/svg.d.ts +27 -0
  9. package/lib/src/ice/shims/takeout.d.ts +83 -0
  10. package/lib/src/ice/types.d.ts +251 -5
  11. package/lib/src/ice/vet.d.ts +19 -0
  12. package/lib/src/managers/AttachmentManager.d.ts +86 -36
  13. package/lib/src/managers/AuthManager.d.ts +117 -7
  14. package/lib/src/managers/AuthManager.js +341 -37
  15. package/lib/src/managers/ExportManager.d.ts +16 -0
  16. package/lib/src/managers/ExportManager.js +25 -0
  17. package/lib/src/managers/FileManager.d.ts +182 -0
  18. package/lib/src/managers/ImportManager.d.ts +264 -16
  19. package/lib/src/managers/MediaManager.d.ts +2 -0
  20. package/lib/src/managers/SharedFolderManager.d.ts +320 -0
  21. package/lib/src/providers/BaseMediaProvider.d.ts +4 -0
  22. package/lib/src/providers/FileSystemMediaProvider.d.ts +6 -0
  23. package/lib/src/security/PolicyDecisionPoint.d.ts +3 -2
  24. package/lib/src/security/permissions.generated.d.ts +3 -3
  25. package/lib/src/types/File.d.ts +90 -0
  26. package/lib/src/types/express.d.ts +3 -1
  27. package/lib/src/types/linkedom.d.ts +2 -0
  28. package/lib/src/utils/auditEventNames.generated.d.ts +2 -0
  29. package/lib/src/utils/auditEventNames.generated.js +2 -0
  30. package/lib/src/utils/auditEvents.d.ts +3 -0
  31. package/lib/src/utils/auditEvents.js +3 -0
  32. package/lib/src/utils/fileMigrationPlan.d.ts +81 -0
  33. package/lib/src/utils/privateStoreAccess.d.ts +21 -0
  34. package/lib/src/utils/privateStoreAccess.js +11 -0
  35. package/lib/src/utils/privateStoreImport.d.ts +0 -10
  36. package/lib/src/utils/privateStorePath.d.ts +12 -0
  37. package/lib/src/utils/privateStorePath.js +18 -0
  38. package/lib/src/utils/recoveryWordsFile.d.ts +54 -0
  39. package/lib/src/utils/sessionEnd.d.ts +1 -0
  40. package/lib/src/utils/sessionEnd.js +4 -0
  41. package/lib/src/utils/sessionIdle.d.ts +4 -0
  42. package/lib/src/utils/sessionIdle.js +6 -0
  43. package/package.json +1 -1
@@ -69,6 +69,24 @@ import { sameUsername } from '../utils/username.js';
69
69
  import { isEmail } from '../types/guards.js';
70
70
  import { jobContextFromRequestWithReason } from '../context/JobContext.js';
71
71
  import { systemContext, systemPrincipalOf } from '../context/bootActions.js';
72
+ /**
73
+ * Old provider ids whose entries still govern the provider (#1743; #1523 Q3,
74
+ * operator 2026-10-31): an entry, or an account's `allowedAuthMethods`, under
75
+ * the old id is read as the current one.
76
+ */
77
+ const FACTOR_ALIASES = { 'google-oidc': 'oidc:google', 'email-link': 'magic-link' };
78
+ /**
79
+ * Do an account's `allowedAuthMethods` let it use `providerId`? Absent or
80
+ * empty means any; an old id names the provider it was renamed to (Q3). The
81
+ * one reading for signing in and for the level the account can reach.
82
+ */
83
+ const allowsProvider = (allowed, providerId) => !allowed || allowed.length === 0 || allowed.some(a => (FACTOR_ALIASES[a] ?? a) === providerId);
84
+ /**
85
+ * The entry that governs the email link as a second factor (#1523 Q3): the
86
+ * magic link's, since starting a sign-in and confirming one are the same
87
+ * provider. The second step runs it itself, so it needs no registered provider.
88
+ */
89
+ const EMAIL_LINK_ENTRY = 'magic-link';
72
90
  /** A factor as offered: its entry lowered to what the provider's code gives. */
73
91
  /**
74
92
  * Providers that count toward a sign-in level only once the account has
@@ -291,6 +309,91 @@ function proxyGrantLive(grant, now) {
291
309
  export function answeredWithPage(mode) {
292
310
  return mode === 'page' || mode === 'download';
293
311
  }
312
+ /** How many prompts one session keeps (#2193), the newest: a few tabs' worth. */
313
+ const STEP_UP_PROMPTS_PER_SESSION = 5;
314
+ /** A prompt id as minted (#2193), or null for anything else. */
315
+ export function stepUpPromptId(value) {
316
+ return typeof value === 'string' && /^[0-9a-f]{32}$/.test(value) ? value : null;
317
+ }
318
+ /** A waiting prompt of this session confirmed or cancelled (#2193); one already settled stays as it is. */
319
+ function settleStepUpPrompt(req, id, state) {
320
+ const prompt = stepUpPromptId(id);
321
+ const kept = req.session?.stepUpPrompts?.find((p) => p.id === prompt);
322
+ if (kept?.state === 'waiting')
323
+ kept.state = state;
324
+ }
325
+ /** Where a fresh sign-in sends the browser to redo a form POST (#2154). */
326
+ export const STEP_UP_CONTINUE_PATH = '/auth/reauth/continue';
327
+ /** How long a kept form POST waits for its fresh sign-in (#2154): reading the prompt may take a while. */
328
+ const STEP_UP_REPLAY_WAIT_MS = 60 * 60_000;
329
+ /** How long a confirmed form POST waits to be redone (#2154), from the confirmation: the browser goes there at once. */
330
+ const STEP_UP_REPLAY_TTL_MS = 2 * 60_000;
331
+ /** How many kept form POSTs one session holds at once (#2176): a few tabs' worth, so memory stays bounded. */
332
+ const STEP_UP_REPLAYS_PER_SESSION = 5;
333
+ /** How many one account holds across its sessions (#2176): signing in many times does not hold more. */
334
+ const STEP_UP_REPLAYS_PER_ACCOUNT = 10;
335
+ /** How many the site holds in all (#2176): each may hold a form body of up to 100 KB, so at most 20 MB. */
336
+ const STEP_UP_REPLAYS_TOTAL = 200;
337
+ /** The kept form POST a prompt's `next` names (#2154), or null when it names none. */
338
+ function stepUpReplayId(next) {
339
+ const url = new URL(next, 'http://site.invalid');
340
+ return url.pathname === STEP_UP_CONTINUE_PATH ? url.searchParams.get('replay') : null;
341
+ }
342
+ /**
343
+ * Field names whose value is a secret (#2154): a password, recovery words, a
344
+ * token. A form holding one is never kept or written back into a page; it
345
+ * returns with the note instead.
346
+ */
347
+ const SECRET_FIELD = /password|secret|token|^words$/i;
348
+ /** Drop kept form POSTs past their time (#2154). */
349
+ function dropExpiredReplays(replays, now) {
350
+ for (const [id, kept] of replays)
351
+ if (kept.expires <= now)
352
+ replays.delete(id);
353
+ }
354
+ /**
355
+ * Drop the oldest of `entries` from `replays` until `room` are left (#2176),
356
+ * the unconfirmed first: a confirmed one is an action about to be carried out.
357
+ * `entries` are in the Map's insertion order, oldest first.
358
+ */
359
+ function dropOldestReplays(replays, entries, room) {
360
+ const order = [...entries.filter(([, kept]) => !kept.ready), ...entries.filter(([, kept]) => kept.ready)];
361
+ for (const [id] of order.slice(0, Math.max(0, order.length - room)))
362
+ replays.delete(id);
363
+ }
364
+ /** A kept form POST's address for the log and the audit (#2176): the path, never the query. */
365
+ function replayPath(action) {
366
+ return new URL(action, 'http://site.invalid').pathname;
367
+ }
368
+ /**
369
+ * A form POST's fields as they can be posted again to give the same body
370
+ * (#2154), or null when they cannot: a file upload, a nested field, a name
371
+ * with brackets. A list goes back as `name[]`, which parses to a list even
372
+ * of one. `_csrf` is left out: the replay carries the session's token. A
373
+ * secret (`SECRET_FIELD`) is never kept, so such a form is null too.
374
+ */
375
+ export function replayableFields(req) {
376
+ if (!req.is('application/x-www-form-urlencoded'))
377
+ return null;
378
+ const body = req.body;
379
+ if (!body || typeof body !== 'object')
380
+ return null;
381
+ const fields = [];
382
+ for (const [name, value] of Object.entries(body)) {
383
+ if (name === '_csrf')
384
+ continue;
385
+ if (/[[\]]/.test(name) || SECRET_FIELD.test(name))
386
+ return null;
387
+ if (typeof value === 'string')
388
+ fields.push([name, value]);
389
+ else if (Array.isArray(value) && value.every((v) => typeof v === 'string'))
390
+ for (const v of value)
391
+ fields.push([`${name}[]`, v]);
392
+ else
393
+ return null;
394
+ }
395
+ return fields;
396
+ }
294
397
  /**
295
398
  * Where to return after re-authenticating: this page for a page GET, and the
296
399
  * page the person was on for anything else — a POST (the form is filled in
@@ -419,6 +522,7 @@ class AuthManager extends BaseManager {
419
522
  await this.openCredentialsStore(configManager);
420
523
  this.registerPasskeys(configManager);
421
524
  this.registerOidcProviders(configManager);
525
+ this.warnIfEmailSecondFactorsDropped();
422
526
  logger.info(`[AuthManager] Initialized — factors: [${this.factorEntries.map(e => e.authproviderid).join(', ')}]`);
423
527
  }
424
528
  /**
@@ -430,24 +534,61 @@ class AuthManager extends BaseManager {
430
534
  return [{ authproviderid: 'password' }];
431
535
  const legacy = configManager.getCustomProperty('ngdpbase.auth.required-factors');
432
536
  const custom = configManager.getCustomProperty('ngdpbase.auth.factors');
433
- if (Array.isArray(legacy)) {
537
+ const legacyIds = Array.isArray(legacy) ? legacy.filter((id) => typeof id === 'string') : null;
538
+ if (legacyIds) {
434
539
  logger.warn('[AuthManager] ngdpbase.auth.required-factors is retired — ' +
435
- (custom === undefined
436
- ? 'converted to ngdpbase.auth.factors entries; rename it in custom config. (#1523)'
437
- : 'ignored, because ngdpbase.auth.factors is also set; remove it from custom config. (#1523)'));
438
- if (custom === undefined) {
439
- return legacy.filter((id) => typeof id === 'string').map(id => ({ authproviderid: id }));
440
- }
441
- }
442
- const entries = configManager.getProperty('ngdpbase.auth.factors', [{ authproviderid: 'password' }]);
540
+ (custom !== undefined
541
+ ? 'ignored, because ngdpbase.auth.factors is also set; remove it from custom config. (#1523)'
542
+ : legacyIds.some(id => (FACTOR_ALIASES[id] ?? id) === EMAIL_LINK_ENTRY)
543
+ ? 'converted to ngdpbase.auth.factors entries; rename it in custom config. (#1523)'
544
+ : `converted to ngdpbase.auth.factors entries; rename it in custom config. It lists no ${EMAIL_LINK_ENTRY}, so the email second factor is off: ` +
545
+ `accounts that enrolled one sign in with one step. List ${EMAIL_LINK_ENTRY} to keep it. (#1523)`));
546
+ }
547
+ const entries = legacyIds && custom === undefined
548
+ ? legacyIds.map(id => ({ authproviderid: id }))
549
+ : configManager.getProperty('ngdpbase.auth.factors', [{ authproviderid: 'password' }]);
443
550
  if (!Array.isArray(entries)) {
444
551
  logger.error('[AuthManager] ngdpbase.auth.factors is not a list — offering password only. (#1523)');
445
552
  return [{ authproviderid: 'password' }];
446
553
  }
447
- return entries
448
- .filter((e) => typeof e === 'object' && e !== null && typeof e.authproviderid === 'string')
449
- // #1743: Google's provider is now `oidc:google`; an entry under its old id still governs it.
450
- .map(e => (e.authproviderid === 'google-oidc' ? { ...e, authproviderid: 'oidc:google' } : e));
554
+ // An entry without a string authproviderid is dropped, and it also made
555
+ // the merge replace the shipped list instead of merging by id.
556
+ const wellFormed = entries.filter((e) => {
557
+ if (typeof e === 'object' && e !== null && typeof e.authproviderid === 'string')
558
+ return true;
559
+ logger.warn(`[AuthManager] ngdpbase.auth.factors: dropped ${JSON.stringify(e)}, which has no string authproviderid. ` +
560
+ 'A custom list holding such an entry replaces the shipped list rather than merging with it, so every shipped entry it does not restate is off. (#1523)');
561
+ return false;
562
+ });
563
+ // #1743, #1523 Q3: an entry under an old id governs the provider. The merge
564
+ // appends a custom entry under the old id after the shipped one, so of two
565
+ // entries for one provider the later wins, in the earlier one's place.
566
+ const byProvider = new Map();
567
+ for (const e of wellFormed) {
568
+ const id = FACTOR_ALIASES[e.authproviderid] ?? e.authproviderid;
569
+ byProvider.set(id, { ...e, authproviderid: id });
570
+ }
571
+ const read = [...byProvider.values()];
572
+ // #1523 Q10: no longer shipped, so only a site's own config still says it.
573
+ const trusting = read.filter(e => Array.isArray(e.amr) && e.amr.includes('trust')).map(e => e.authproviderid);
574
+ if (trusting.length > 0) {
575
+ logger.warn(`[AuthManager] ngdpbase.auth.factors: "amr": ["trust"] on ${trusting.join(', ')} does nothing — trust an outside provider's own second factor with "trust-mfa": true on its entry in ngdpbase.auth.oidc.providers. (#1633)`);
576
+ }
577
+ return read;
578
+ }
579
+ /**
580
+ * #1523: with the `magic-link` entry off or not listed, an enrolled email
581
+ * second factor is no longer asked for. The operator may mean that, but it
582
+ * must not happen silently.
583
+ */
584
+ warnIfEmailSecondFactorsDropped() {
585
+ if (this.factorEntries.some(e => e.authproviderid === EMAIL_LINK_ENTRY && e.enabled !== false))
586
+ return;
587
+ const accounts = new Set((this.credentials?.listKind('email') ?? []).map(r => r.username)).size;
588
+ if (accounts === 0)
589
+ return;
590
+ logger.warn(`[AuthManager] ngdpbase.auth.factors: the ${EMAIL_LINK_ENTRY} entry is off or not listed, so the enrolled email second factor ` +
591
+ `of ${accounts} account(s) is no longer asked for — they sign in with one step, and magic-link sign-in is off too. (#1523)`);
451
592
  }
452
593
  /**
453
594
  * The outside OpenID Connect providers (#1743): one `OidcAuthProvider` per
@@ -1027,9 +1168,7 @@ class AuthManager extends BaseManager {
1027
1168
  */
1028
1169
  async userMayUseProvider(username, providerId) {
1029
1170
  const user = await this.engine.getManager('UserManager')?.getUser(username);
1030
- const allowed = user?.allowedAuthMethods;
1031
- // #1743: Google's provider was `google-oidc`; an account limited to it keeps it as `oidc:google`.
1032
- return !allowed || allowed.length === 0 || allowed.includes(providerId) || (providerId === 'oidc:google' && allowed.includes('google-oidc'));
1171
+ return allowsProvider(user?.allowedAuthMethods, providerId);
1033
1172
  }
1034
1173
  async passkeyRegistrationOptions(ctx, username, displayName) {
1035
1174
  if (!(await this.mayManageCredentials(ctx, username)))
@@ -2085,7 +2224,7 @@ class AuthManager extends BaseManager {
2085
2224
  async aalReachFor(username) {
2086
2225
  const user = await this.engine.getManager('UserManager')?.getUser(username);
2087
2226
  const allowed = user?.allowedAuthMethods;
2088
- const offered = this.getFactors().filter(f => !allowed || allowed.length === 0 || allowed.includes(f.provider));
2227
+ const offered = this.getFactors().filter(f => allowsProvider(allowed, f.provider));
2089
2228
  const reach = (factors) => Math.max(factors.some(f => f.primary) ? this.assess(factors).aal : 0, 1);
2090
2229
  const usable = offered.filter(f => {
2091
2230
  const kind = ENROLLED_FACTORS[f.provider];
@@ -2185,11 +2324,21 @@ class AuthManager extends BaseManager {
2185
2324
  await this.recordReauth(req, AUDIT_EVENT.REAUTH_PROMPT, 'failure', permission, delegated ? 'delegated credential cannot re-authenticate' : 'fresh sign-in asked for');
2186
2325
  if (delegated)
2187
2326
  return { permission, delegated, error: 'This needs a fresh sign-in by the person; a token cannot give one' };
2188
- const next = reauthReturnTo(req, mode);
2327
+ const back = reauthReturnTo(req, mode);
2328
+ // #2154: a form POST is kept, and the confirmation returns to redo it.
2329
+ const replay = req.session ? this.keepStepUpReplay(req, permission, mode, back) : null;
2330
+ const next = replay ? `${STEP_UP_CONTINUE_PATH}?replay=${replay}` : back;
2331
+ // #2193: a script's request names its own prompt, so the page that sent it
2332
+ // waits for that confirmation and no other.
2333
+ const prompt = req.session && mode === 'json' ? randomBytes(16).toString('hex') : null;
2334
+ if (prompt)
2335
+ req.session.stepUpPrompts = [...(req.session.stepUpPrompts ?? []), { id: prompt, state: 'waiting' }].slice(-STEP_UP_PROMPTS_PER_SESSION);
2189
2336
  // #2145: what the prompt was for, so its success is recorded against it and,
2190
2337
  // when returning does not redo the action, the page says it can be done now.
2191
2338
  if (req.session) {
2192
- req.session.pendingStepUp = { permission, next, replays: req.method === 'GET' && mode === 'page' };
2339
+ req.session.pendingStepUp = replay
2340
+ ? { permission, next, replays: true, replay }
2341
+ : { permission, next, replays: req.method === 'GET' && mode === 'page' };
2193
2342
  // Saved before the answer goes out: the browser follows a redirect on its
2194
2343
  // headers, before express-session's own save at the end lands, and the
2195
2344
  // prompt's request would read (and write back) the session without it.
@@ -2202,9 +2351,22 @@ class AuthManager extends BaseManager {
2202
2351
  permission,
2203
2352
  delegated,
2204
2353
  error: 'A fresh sign-in is needed',
2205
- reauth: `/auth/reauth?next=${encodeURIComponent(next)}`
2354
+ reauth: `/auth/reauth?next=${encodeURIComponent(next)}${prompt ? `&prompt=${prompt}` : ''}`
2206
2355
  };
2207
2356
  }
2357
+ /**
2358
+ * Where this session's prompt `id` is (#2193): waiting, confirmed or
2359
+ * cancelled, or unknown (another session's, dropped, or not an id). Reads
2360
+ * only, so asking it often changes nothing in the session.
2361
+ */
2362
+ stepUpPromptState(req, id) {
2363
+ const prompt = stepUpPromptId(id);
2364
+ return req.session?.stepUpPrompts?.find((p) => p.id === prompt)?.state ?? 'unknown';
2365
+ }
2366
+ /** The prompt's Cancel (#2193): a waiting prompt of this session is cancelled, so its page stops waiting. */
2367
+ cancelStepUpPrompt(req, id) {
2368
+ settleStepUpPrompt(req, id, 'cancelled');
2369
+ }
2208
2370
  /** Record a step-up event: a prompt, a success or a failure (#1525). */
2209
2371
  async recordReauth(req, eventType, result, permission, detail) {
2210
2372
  await recordAuditEvent(this.engine.getManager('AuditManager'), {
@@ -2229,17 +2391,36 @@ class AuthManager extends BaseManager {
2229
2391
  * directly, against the method), and when going there does not redo the
2230
2392
  * action (a form POST, a script's request, a download) the page there says
2231
2393
  * it can be done now (`takeStepUpNotice`).
2394
+ *
2395
+ * #2193: `prompt`, the id a script's refusal named, is confirmed when it is
2396
+ * this session's and still waiting; no other prompt is.
2232
2397
  */
2233
- async completeReauth(req, result, method, next) {
2398
+ async completeReauth(req, result, method, next, prompt) {
2234
2399
  const record = this.reauthenticated(req.session.signIn, result);
2235
2400
  if (record)
2236
2401
  req.session.signIn = record;
2402
+ if (record)
2403
+ settleStepUpPrompt(req, prompt, 'confirmed');
2237
2404
  const pending = req.session.pendingStepUp;
2238
2405
  delete req.session.pendingStepUp;
2239
2406
  const answered = pending && pending.next === next ? pending : undefined;
2240
2407
  if (answered && !answered.replays)
2241
2408
  req.session.stepUpConfirmed = new URL(next, 'http://site.invalid').pathname;
2242
- await this.recordReauth(req, AUDIT_EVENT.REAUTH_SUCCESS, 'success', answered?.permission ?? method, `re-authenticated with ${method}${answered ? ` for ${answered.permission}` : ''}; acr ${record?.acr ?? 'unknown'}`);
2409
+ // #2154: the confirmation releases the form POST it was asked for, named by
2410
+ // `next` itself (the session's pending prompt may be another tab's), and
2411
+ // its time to be redone starts now, however long the prompt was open.
2412
+ // Another session's or person's is not released, nor is the success recorded
2413
+ // against its permission: the attempt is a `reauth-failure`, as at the take.
2414
+ const kept = this.stepUpReplays.get(stepUpReplayId(next) ?? '');
2415
+ const mine = kept && kept.sessionId === req.sessionID && kept.username === req.session.username ? kept : undefined;
2416
+ if (kept && !mine) {
2417
+ await this.recordReauth(req, AUDIT_EVENT.REAUTH_FAILURE, 'failure', kept.permission, `kept form POST for ${replayPath(kept.action)} confirmed from another session; not released`);
2418
+ }
2419
+ if (mine && mine.expires > Date.now()) {
2420
+ mine.ready = true;
2421
+ mine.expires = Date.now() + STEP_UP_REPLAY_TTL_MS;
2422
+ }
2423
+ await this.recordReauth(req, AUDIT_EVENT.REAUTH_SUCCESS, 'success', answered?.permission ?? mine?.permission ?? method, `re-authenticated with ${method}${answered ? ` for ${answered.permission}` : ''}; acr ${record?.acr ?? 'unknown'}`);
2243
2424
  return record;
2244
2425
  }
2245
2426
  /**
@@ -2257,6 +2438,79 @@ class AuthManager extends BaseManager {
2257
2438
  delete req.session.stepUpConfirmed;
2258
2439
  return req.path === path;
2259
2440
  }
2441
+ /** #2154: form POSTs kept for their fresh sign-in, by a random id. In memory: a restart drops them. */
2442
+ stepUpReplays = new Map();
2443
+ /**
2444
+ * Keep a form POST answered with the prompt, to be redone after it
2445
+ * (#2154); its id, or null when it cannot be redone exactly. Only a page's
2446
+ * POST by a signed-in person, to an address on this site. Each prompt keeps
2447
+ * its own, so the same button pressed in two tabs loses neither (#2176); a
2448
+ * session keeps at most `STEP_UP_REPLAYS_PER_SESSION`, an account
2449
+ * `STEP_UP_REPLAYS_PER_ACCOUNT` and the site `STEP_UP_REPLAYS_TOTAL`, a new
2450
+ * prompt dropping the oldest past that, unconfirmed first, and any past its
2451
+ * time. It waits `STEP_UP_REPLAY_WAIT_MS` for the confirmation, which
2452
+ * restarts its clock. A session's end drops its own (`dropStepUpReplays`).
2453
+ */
2454
+ keepStepUpReplay(req, permission, mode, back) {
2455
+ const username = req.session.username;
2456
+ if (req.method !== 'POST' || mode !== 'page' || typeof username !== 'string' || !req.sessionID)
2457
+ return null;
2458
+ const action = req.originalUrl;
2459
+ if (safeRedirect(action) !== action)
2460
+ return null;
2461
+ const fields = replayableFields(req);
2462
+ if (!fields)
2463
+ return null;
2464
+ const now = Date.now();
2465
+ dropExpiredReplays(this.stepUpReplays, now);
2466
+ // A Map iterates in insertion order: the oldest come first.
2467
+ const all = () => Array.from(this.stepUpReplays);
2468
+ dropOldestReplays(this.stepUpReplays, all().filter(([, kept]) => kept.sessionId === req.sessionID), STEP_UP_REPLAYS_PER_SESSION - 1);
2469
+ dropOldestReplays(this.stepUpReplays, all().filter(([, kept]) => kept.username === username), STEP_UP_REPLAYS_PER_ACCOUNT - 1);
2470
+ dropOldestReplays(this.stepUpReplays, all(), STEP_UP_REPLAYS_TOTAL - 1);
2471
+ const id = randomBytes(16).toString('hex');
2472
+ this.stepUpReplays.set(id, { sessionId: req.sessionID, username, permission, action, fields, back, expires: now + STEP_UP_REPLAY_WAIT_MS, ready: false });
2473
+ return id;
2474
+ }
2475
+ /** A session ended (#2176): the form POSTs it kept go with it (`endSession`). */
2476
+ dropStepUpReplays(sessionId) {
2477
+ for (const [id, kept] of this.stepUpReplays)
2478
+ if (kept.sessionId === sessionId)
2479
+ this.stepUpReplays.delete(id);
2480
+ }
2481
+ /**
2482
+ * Take the kept form POST `id` for the continue page (#2154). Single-use:
2483
+ * taken, it is gone, whatever the answer. Another session's or person's is
2484
+ * refused (null); one not confirmed (the prompt's Cancel) or past its time
2485
+ * only goes back, a confirmed one with the note (#2145). Otherwise the page
2486
+ * posts the fields to the same address. A refusal or one past its time is
2487
+ * recorded as a `reauth-failure`; any other past its time is dropped.
2488
+ */
2489
+ async takeStepUpReplay(req, id) {
2490
+ const kept = typeof id === 'string' ? this.stepUpReplays.get(id) : undefined;
2491
+ if (kept)
2492
+ this.stepUpReplays.delete(id);
2493
+ const now = Date.now();
2494
+ dropExpiredReplays(this.stepUpReplays, now);
2495
+ if (!kept) {
2496
+ await this.recordReauth(req, AUDIT_EVENT.REAUTH_FAILURE, 'failure', STEP_UP_CONTINUE_PATH, 'kept form POST not found: already used, past its time, or unknown');
2497
+ return null;
2498
+ }
2499
+ if (kept.sessionId !== req.sessionID || kept.username !== req.session?.username) {
2500
+ logger.warn(`[step-up] a kept form POST for ${replayPath(kept.action)} was asked for from another session; refused`);
2501
+ await this.recordReauth(req, AUDIT_EVENT.REAUTH_FAILURE, 'failure', kept.permission, `kept form POST for ${replayPath(kept.action)} asked for from another session`);
2502
+ return null;
2503
+ }
2504
+ if (now > kept.expires) {
2505
+ await this.recordReauth(req, AUDIT_EVENT.REAUTH_FAILURE, 'failure', kept.permission, `kept form POST for ${replayPath(kept.action)} past its time; not redone`);
2506
+ if (kept.ready)
2507
+ req.session.stepUpConfirmed = new URL(kept.back, 'http://site.invalid').pathname;
2508
+ return { back: kept.back };
2509
+ }
2510
+ if (!kept.ready)
2511
+ return { back: kept.back };
2512
+ return { action: kept.action, fields: kept.fields, back: kept.back };
2513
+ }
2260
2514
  /**
2261
2515
  * The one sign-in completion step (#1758): every way a person finishes
2262
2516
  * signing in — a password, a passkey, a second factor, an email link, an
@@ -2475,11 +2729,24 @@ class AuthManager extends BaseManager {
2475
2729
  const policy = this.stepUpPolicy();
2476
2730
  if (!policy.permissions.has(permission))
2477
2731
  return false;
2732
+ return !this.freshUnder(policy.maxAgeMs, signIn, roles, delegated, now, cap);
2733
+ }
2734
+ /**
2735
+ * Whether `signIn` is fresh enough for every step-up permission (#2193): the
2736
+ * window and the level are one for all of them, so a page waiting on its
2737
+ * prompt asks this without naming the permission. With step-up off, every
2738
+ * sign-in is.
2739
+ */
2740
+ signInFresh(signIn, roles, delegated, now = Date.now(), cap) {
2741
+ const policy = this.stepUpPolicy();
2742
+ return policy.permissions.size === 0 || this.freshUnder(policy.maxAgeMs, signIn, roles, delegated, now, cap);
2743
+ }
2744
+ freshUnder(maxAgeMs, signIn, roles, delegated, now, cap) {
2478
2745
  if (delegated)
2479
- return true;
2746
+ return false;
2480
2747
  const need = Math.max(this.requiredAalFor(roles, cap), 1);
2481
- const since = now - policy.maxAgeMs;
2482
- return !(signIn?.factors ?? []).some((f) => f.aal >= need && Date.parse(f.at) >= since);
2748
+ const since = now - maxAgeMs;
2749
+ return (signIn?.factors ?? []).some((f) => f.aal >= need && Date.parse(f.at) >= since);
2483
2750
  }
2484
2751
  /**
2485
2752
  * A session's sign-in after a re-authentication (#1525): the new factors
@@ -2793,16 +3060,48 @@ class AuthManager extends BaseManager {
2793
3060
  return this.mailForLinks() !== null;
2794
3061
  }
2795
3062
  /**
2796
- * The second factors `username` has enrolled and can use now (#1523). Any
2797
- * enrolled factor works; one that is not truly available (no mail transport)
2798
- * is not offered. Empty means the sign-in is one step, as before.
3063
+ * Whether the email link can be a second factor now (#1523): its entry in
3064
+ * `ngdpbase.auth.factors` (`magic-link`, or its alias `email-link`) listed
3065
+ * and enabled, mail, and an explicit base-url.
3066
+ */
3067
+ emailSecondFactorAvailable() {
3068
+ return this.mailForLinks() !== null && this.factorEntries.some(e => e.authproviderid === EMAIL_LINK_ENTRY && e.enabled !== false);
3069
+ }
3070
+ /**
3071
+ * The second factors that may follow `first`, in the order they are offered
3072
+ * (#1523; Q2, operator 2026-10-31): every enabled entry of
3073
+ * `ngdpbase.auth.factors` other than the one the sign-in started with, whose
3074
+ * level is known (its provider registered, or the email link). Those that,
3075
+ * with the first step, reach `requiredAal` come first; then by `preference`,
3076
+ * lower first, an entry with none last. Entry ids.
2799
3077
  */
2800
- secondFactorsFor(username) {
3078
+ secondFactorOrder(first, requiredAal) {
3079
+ const ranked = this.factorEntries.flatMap((entry) => {
3080
+ if (entry.enabled === false || entry.authproviderid === first.provider)
3081
+ return [];
3082
+ const factor = entry.authproviderid === EMAIL_LINK_ENTRY ? EMAIL_FACTOR : this.getFactors().find(f => f.provider === entry.authproviderid);
3083
+ if (!factor)
3084
+ return [];
3085
+ const reaches = this.assess([...(first.factors ?? []), factor]).aal >= requiredAal;
3086
+ return [{ id: entry.authproviderid, reaches, preference: entry.preference ?? Number.MAX_SAFE_INTEGER }];
3087
+ });
3088
+ return ranked.sort((a, b) => Number(b.reaches) - Number(a.reaches) || a.preference - b.preference).map(r => r.id);
3089
+ }
3090
+ /**
3091
+ * The second factors `username` has enrolled and can use now (#1523), in
3092
+ * `secondFactorOrder` after the pending sign-in's first step; without one,
3093
+ * by `preference`. Only what the second step can run is offered — today the
3094
+ * email link — and one that is not truly available (no mail transport) is
3095
+ * not. Empty means the sign-in is one step, as before.
3096
+ */
3097
+ secondFactorsFor(username, signIn) {
2801
3098
  const rows = this.credentials?.list(username) ?? [];
2802
3099
  const out = [];
2803
- if (this.mailForLinks()) {
2804
- for (const r of rows.filter((c) => c.kind === 'email')) {
2805
- out.push({ id: 'email-link', label: 'Email link', target: maskEmail(r.subject) });
3100
+ for (const id of this.secondFactorOrder(signIn?.first ?? {}, signIn?.requiredAal ?? 0)) {
3101
+ if (id === EMAIL_LINK_ENTRY && this.mailForLinks()) {
3102
+ for (const r of rows.filter((c) => c.kind === 'email')) {
3103
+ out.push({ id: 'email-link', label: 'Email link', target: maskEmail(r.subject) });
3104
+ }
2806
3105
  }
2807
3106
  }
2808
3107
  return out;
@@ -2820,7 +3119,7 @@ class AuthManager extends BaseManager {
2820
3119
  const record = result.success && result.username ? this.signInRecord(result) : null;
2821
3120
  if (!record || record.aal >= 2 || record.amr.includes('email'))
2822
3121
  return false;
2823
- return this.secondFactorsFor(result.username).length > 0;
3122
+ return this.secondFactorsFor(result.username, { first: result }).length > 0;
2824
3123
  }
2825
3124
  /**
2826
3125
  * Roughly where `req` came from, for the approval message (#1532):
@@ -2851,6 +3150,9 @@ class AuthManager extends BaseManager {
2851
3150
  if (!username)
2852
3151
  throw new Error('beginTwoStep: no stored account has that username (#1882)');
2853
3152
  first = { ...first, username };
3153
+ // #1523 Q2: what this account's roles require, so the factors that reach it are offered first.
3154
+ const pip = this.engine.getManager('PolicyInformationPoint');
3155
+ const requiredAal = this.requiredAalFor((await pip?.subjectFor(username))?.roles ?? [], await this.aalCapFor(username));
2854
3156
  this.sweepPending();
2855
3157
  const handle = randomBytes(24).toString('base64url');
2856
3158
  const now = Date.now();
@@ -2865,7 +3167,8 @@ class AuthManager extends BaseManager {
2865
3167
  requestIp: ctx.ip,
2866
3168
  requestUserAgent: ctx.userAgent,
2867
3169
  requestLocation: ctx.location ?? null,
2868
- privateStoreHandle: ctx.privateStoreHandle
3170
+ privateStoreHandle: ctx.privateStoreHandle,
3171
+ requiredAal
2869
3172
  });
2870
3173
  // #1532: an expiry is recorded when it happens, not when someone next looks.
2871
3174
  setTimeout(() => this.expirePending(handle), PENDING_SIGN_IN_TTL_MS + 1).unref?.();
@@ -2938,11 +3241,12 @@ class AuthManager extends BaseManager {
2938
3241
  const p = this.pendingSignIn(handle, binding);
2939
3242
  if (!p)
2940
3243
  return 'unavailable';
2941
- const mail = this.mailForLinks();
3244
+ const mail = this.emailSecondFactorAvailable() ? this.mailForLinks() : null;
2942
3245
  const row = (this.credentials?.list(p.username) ?? []).find((c) => c.kind === 'email');
2943
3246
  const from = AuthManager.askedFrom(p);
2944
3247
  if (!mail || !row) {
2945
- await this.recordApproval(AUDIT_EVENT.SIGNINAPPROVAL_SENDFAIL, p, 'error', from, { reason: mail ? 'no email factor enrolled' : 'mail is not configured' });
3248
+ const reason = mail ? 'no email factor enrolled' : this.mailForLinks() ? 'the email link is off in ngdpbase.auth.factors' : 'mail is not configured';
3249
+ await this.recordApproval(AUDIT_EVENT.SIGNINAPPROVAL_SENDFAIL, p, 'error', from, { reason });
2946
3250
  return 'unavailable';
2947
3251
  }
2948
3252
  if (p.lastSentAt && Date.now() - p.lastSentAt < APPROVAL_RESEND_MS) {
@@ -3143,7 +3447,7 @@ class AuthManager extends BaseManager {
3143
3447
  async startEmailFactorEnrolment(ctx, username) {
3144
3448
  if (!(await this.mayManageCredentials(ctx, username)))
3145
3449
  throw new Error('Permission denied');
3146
- const mail = this.mailForLinks();
3450
+ const mail = this.emailSecondFactorAvailable() ? this.mailForLinks() : null;
3147
3451
  if (!mail)
3148
3452
  return 'unavailable';
3149
3453
  const user = await this.engine.getManager('UserManager')?.getUser(username);
@@ -128,6 +128,11 @@ declare class ExportManager extends BaseManager {
128
128
  * with the reason, as `none` is.
129
129
  */
130
130
  private pdfProvider;
131
+ /**
132
+ * Is a PDF provider chosen (#2120)? The Export action offers PDF only then.
133
+ * Whether the chosen one works is known only by rendering, as before.
134
+ */
135
+ isPdfConfigured(): boolean;
131
136
  /** Log why, flag the manager (unless something worse is already shown), and refuse. */
132
137
  private pdfUnavailable;
133
138
  private acquirePdfSlot;
@@ -148,6 +153,17 @@ declare class ExportManager extends BaseManager {
148
153
  * @returns {Promise<string>} HTML content
149
154
  */
150
155
  exportPageToHtml(pageName: string, ctx: ActorContext, user?: ExportUser | null): Promise<string>;
156
+ /**
157
+ * One page as a complete HTML document: its content rendered, with the
158
+ * export's styling and metadata footer. It reads nothing: the caller has
159
+ * read the page through its door (the page export source, #2120).
160
+ *
161
+ * @param pageName - The page's name, its heading
162
+ * @param page - Its content and metadata
163
+ * @param user - User object for locale-aware formatting
164
+ * @returns The HTML document
165
+ */
166
+ renderPageDocument(pageName: string, page: PageForExport, user?: ExportUser | null): Promise<string>;
151
167
  /**
152
168
  * Export multiple pages to a single HTML file
153
169
  * @param {string[]} pageNames - Array of page names
@@ -182,6 +182,14 @@ class ExportManager extends BaseManager {
182
182
  return new NonePdfProvider(`ngdpbase.export.pdf.provider '${name}' is not a PDF provider: use ${PDF_PROVIDERS.join(', ')}`);
183
183
  }
184
184
  }
185
+ /**
186
+ * Is a PDF provider chosen (#2120)? The Export action offers PDF only then.
187
+ * Whether the chosen one works is known only by rendering, as before.
188
+ */
189
+ isPdfConfigured() {
190
+ const name = this.engine.getManager('ConfigurationManager')?.getProperty('ngdpbase.export.pdf.provider', PDF_DEFAULTS.provider);
191
+ return (name ?? PDF_DEFAULTS.provider) !== 'none';
192
+ }
185
193
  /** Log why, flag the manager (unless something worse is already shown), and refuse. */
186
194
  pdfUnavailable(reason) {
187
195
  logger.warn(`[ExportManager] PDF export unavailable: ${reason}`);
@@ -248,6 +256,23 @@ class ExportManager extends BaseManager {
248
256
  if (!page) {
249
257
  throw new Error(`Page '${pageName}' not found`);
250
258
  }
259
+ return this.renderPageDocument(pageName, page, user);
260
+ }
261
+ /**
262
+ * One page as a complete HTML document: its content rendered, with the
263
+ * export's styling and metadata footer. It reads nothing: the caller has
264
+ * read the page through its door (the page export source, #2120).
265
+ *
266
+ * @param pageName - The page's name, its heading
267
+ * @param page - Its content and metadata
268
+ * @param user - User object for locale-aware formatting
269
+ * @returns The HTML document
270
+ */
271
+ async renderPageDocument(pageName, page, user = null) {
272
+ const renderingManager = this.engine.getManager('RenderingManager');
273
+ if (!renderingManager) {
274
+ throw new Error('Required managers not available');
275
+ }
251
276
  // Render the page content (without user context for exports)
252
277
  const renderedContent = await renderingManager.renderMarkdown(page.content, pageName, null);
253
278
  // Create full HTML document