@jwilleke/ngdpbase-types 5.21.0 → 5.23.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 (58) hide show
  1. package/lib/packages/ngdpbase-types/index.d.ts +5 -0
  2. package/lib/src/mail/MailProvider.d.ts +2 -0
  3. package/lib/src/managers/AddonsManager.d.ts +3 -3
  4. package/lib/src/managers/AttachmentManager.d.ts +64 -12
  5. package/lib/src/managers/AuthManager.d.ts +29 -6
  6. package/lib/src/managers/BackgroundJobManager.d.ts +65 -1
  7. package/lib/src/managers/BaseManager.d.ts +14 -0
  8. package/lib/src/managers/BaseManager.js +9 -0
  9. package/lib/src/managers/CommentManager.d.ts +10 -1
  10. package/lib/src/managers/DatabaseManager.d.ts +24 -17
  11. package/lib/src/managers/DatabaseManager.js +72 -29
  12. package/lib/src/managers/EmailManager.d.ts +42 -0
  13. package/lib/src/managers/FootnoteManager.d.ts +10 -1
  14. package/lib/src/managers/NotificationManager.d.ts +13 -0
  15. package/lib/src/managers/OrganizationManager.d.ts +40 -6
  16. package/lib/src/managers/PersonManager.d.ts +106 -9
  17. package/lib/src/managers/RoleManager.d.ts +19 -1
  18. package/lib/src/managers/SessionStatsManager.d.ts +94 -0
  19. package/lib/src/managers/UserManager.d.ts +36 -1
  20. package/lib/src/providers/BaseAttachmentProvider.d.ts +14 -0
  21. package/lib/src/providers/BasicAttachmentProvider.d.ts +26 -2
  22. package/lib/src/providers/SqliteDatabaseProvider.d.ts +7 -0
  23. package/lib/src/providers/SqliteDatabaseProvider.js +16 -0
  24. package/lib/src/providers/SqliteJobStateProvider.d.ts +10 -0
  25. package/lib/src/providers/attachmentFilesBackup.d.ts +52 -0
  26. package/lib/src/providers/sqliteBackup.d.ts +6 -0
  27. package/lib/src/providers/sqliteBackup.js +16 -6
  28. package/lib/src/security/permissions.generated.d.ts +4 -0
  29. package/lib/src/types/Asset.d.ts +10 -0
  30. package/lib/src/types/Config.d.ts +11 -0
  31. package/lib/src/types/Organization.d.ts +7 -0
  32. package/lib/src/types/Person.d.ts +27 -3
  33. package/lib/src/types/PersonProvider.d.ts +6 -0
  34. package/lib/src/types/WikiEngine.d.ts +9 -21
  35. package/lib/src/types/express.d.ts +2 -0
  36. package/lib/src/utils/addonPageSync.d.ts +24 -0
  37. package/lib/src/utils/administrators.d.ts +21 -0
  38. package/lib/src/utils/auditEventNames.generated.d.ts +4 -0
  39. package/lib/src/utils/auditEventNames.generated.js +4 -0
  40. package/lib/src/utils/backupCrypto.d.ts +28 -0
  41. package/lib/src/utils/backupKey.d.ts +23 -0
  42. package/lib/src/utils/backupKey.js +37 -0
  43. package/lib/src/utils/contactAccess.d.ts +58 -0
  44. package/lib/src/utils/databaseKey.d.ts +7 -0
  45. package/lib/src/utils/databaseKey.js +7 -0
  46. package/lib/src/utils/directoryBackup.d.ts +78 -0
  47. package/lib/src/utils/emailForLog.d.ts +20 -0
  48. package/lib/src/utils/maintenanceState.d.ts +153 -0
  49. package/lib/src/utils/privateStoreTakeout.d.ts +2 -2
  50. package/lib/src/utils/sessionAccount.d.ts +26 -0
  51. package/lib/src/utils/sessionEnd.d.ts +1 -1
  52. package/lib/src/utils/siteRestore.d.ts +110 -0
  53. package/lib/src/utils/siteRestore.js +309 -0
  54. package/lib/src/utils/treeArchive.d.ts +44 -0
  55. package/lib/src/utils/username.d.ts +7 -1
  56. package/package.json +2 -2
  57. package/lib/src/utils/startupState.d.ts +0 -65
  58. package/lib/src/utils/startupState.js +0 -115
@@ -40,34 +40,22 @@ import path from 'node:path';
40
40
  import BaseManager from './BaseManager.js';
41
41
  import SqliteDatabaseProvider, { DEFAULT_BUSY_TIMEOUT_MS } from '../providers/SqliteDatabaseProvider.js';
42
42
  import DATABASE_MIGRATIONS from '../providers/databaseMigrations.js';
43
- import { createHmac } from 'node:crypto';
44
43
  import { existsSync, readFileSync, rmSync } from 'node:fs';
45
44
  import { actorOf } from '../context/ActorContext.js';
46
45
  import { recordAuditEvent } from '../utils/auditEvents.js';
47
46
  import { AUDIT_EVENT } from '../utils/auditEventNames.js';
48
47
  import logger from '../utils/logger.js';
49
48
  import { ADDON_SLUG, ledgerPrefix } from '../utils/addonDatabaseRules.js';
50
- import { sitePublicMessage } from '../utils/startupState.js';
51
- import { applyStagedRestore, manifestPath, runOffThread, stagedPath, writeManifest } from '../providers/sqliteBackup.js';
52
- /** The environment variable holding the SQLCipher key. Never a config key: config is readable from the admin screens. */
53
- export const DATABASE_KEY_ENV = 'NGDPBASE_DATABASE_KEY';
54
- /**
55
- * The environment variable holding the key every database backup is encrypted
56
- * under (#1723). Its own secret, not the database key: the copy that travels
57
- * and the copy that stays should not fall to the same compromise. Never a
58
- * config key. Unset, no database is backed up, and admins are told.
59
- */
60
- export const BACKUP_KEY_ENV = 'NGDPBASE_BACKUP_KEY';
49
+ import { applyStagedRestore, manifestPath, runOffThread, setAsideStagedRestore, stagedPath, writeManifest } from '../providers/sqliteBackup.js';
50
+ import { gatePart, markPart, recordStagedPart, siteRestoreFile } from '../utils/siteRestore.js';
51
+ import { BACKUP_KEY_ENV, backupKeyFingerprint, currentBackupKey, LEFT_OUT_FOR_NO_KEY } from '../utils/backupKey.js';
52
+ // The environment variable holding the SQLCipher key: one declaration, `src/utils/databaseKey.ts`.
53
+ import { DATABASE_KEY_ENV } from '../utils/databaseKey.js';
54
+ export { DATABASE_KEY_ENV };
55
+ // #1723, #1876: the backup key has one declaration (`src/utils/backupKey.ts`); re-exported for the callers that import it from here.
56
+ export { BACKUP_KEY_ENV, backupKeyFingerprint } from '../utils/backupKey.js';
61
57
  /** How long a database backup or restore staging may run on its worker before it is stopped, when not configured. */
62
58
  export const DEFAULT_BACKUP_WORKER_TIMEOUT_MS = 3_600_000;
63
- /**
64
- * A short fingerprint of a backup key (#1723, D3): the first 16 hex digits of
65
- * an HMAC of a fixed label under the key. Recorded in each backup so a restore
66
- * under another key says so plainly; it never reveals the key.
67
- */
68
- export function backupKeyFingerprint(key) {
69
- return createHmac('sha256', key).update('ngdpbase backup key fingerprint v1').digest('hex').slice(0, 16);
70
- }
71
59
  /** The backup's name for the application database; an add-on's is `addon-<owner>`. */
72
60
  const APPLICATION = 'application';
73
61
  const addonName = (owner) => `addon-${owner}`;
@@ -112,6 +100,10 @@ class DatabaseManager extends BaseManager {
112
100
  addonDatabases = new Map();
113
101
  /** What the start did with a staged restore (#1723), reported once the notifications exist. */
114
102
  restoreAtStart = null;
103
+ /** The databases a staged restore put back at this start (#1876): kept after the report, for the managers whose data is in them. */
104
+ restoredNow = [];
105
+ /** When the restored backup was taken, and where the replaced application database is kept, when a restore was applied at this start (#1876). */
106
+ replaced = null;
115
107
  /** Why this manager is degraded, one entry per cause, so one cause clearing leaves the others. */
116
108
  faults = new Map();
117
109
  /** Why the configured application database could not be opened safely (#1892); null otherwise. */
@@ -190,8 +182,8 @@ class DatabaseManager extends BaseManager {
190
182
  }
191
183
  /**
192
184
  * #1892 (operator D3 rule, 2026-10-18): the application database can't be
193
- * opened safely. The process stays up in maintenance mode through core's
194
- * survivable path (#1152): visitors see only the public message (#1883),
185
+ * opened safely. The process stays up in maintenance mode through the one
186
+ * door (`engine.maintenance`, #1906): visitors see only the site's maintenance message,
195
187
  * administrators can sign in and reach /admin, and `reportAtBoot()` sends
196
188
  * them the reason as a critical notification once notifications exist.
197
189
  */
@@ -200,10 +192,8 @@ class DatabaseManager extends BaseManager {
200
192
  this.provider = null;
201
193
  logger.error(`[DatabaseManager] ${reason}`);
202
194
  this.markFailed(reason, 'ngdpbase.database.file');
203
- const configManager = this.engine.getManager('ConfigurationManager');
204
- this.engine.blockConfiguration(reason, {
205
- publicMessage: sitePublicMessage(configManager ? (k, d) => configManager.getProperty(k, d) : undefined)
206
- });
195
+ // #1906: no message of its own, so visitors see the site's maintenance message.
196
+ this.engine.maintenance.enter(reason);
207
197
  }
208
198
  /** The reason for a database that did not open, with the fix that fits the key (#1892). */
209
199
  static openFailure(file, cause, key) {
@@ -355,7 +345,7 @@ class DatabaseManager extends BaseManager {
355
345
  }
356
346
  const backupKey = this.backupKey();
357
347
  if (!backupKey) {
358
- const reason = `${BACKUP_KEY_ENV} is not set, so no database was backed up. Database backups are always encrypted and are never written without it (#1723)`;
348
+ const reason = `${LEFT_OUT_FOR_NO_KEY}no database was backed up. Database backups are always encrypted and are never written without it (#1723)`;
359
349
  this.fault('backup-key', reason);
360
350
  throw new Error(reason);
361
351
  }
@@ -502,6 +492,14 @@ class DatabaseManager extends BaseManager {
502
492
  databases: stages.map((s) => ({ name: s.name, live: s.live, staged: stagedPath(s.live) }))
503
493
  };
504
494
  writeManifest(manifestFile, manifest);
495
+ // #1876: part of a site restore — the databases go in with the other parts, or not at all.
496
+ if (options.siteRestore) {
497
+ recordStagedPart(options.siteRestore, {
498
+ manager: 'DatabaseManager',
499
+ swaps: manifest.databases.map((d) => ({ live: d.live, staged: d.staged })),
500
+ discard: [manifestFile]
501
+ });
502
+ }
505
503
  const names = stages.map((s) => s.name).join(', ');
506
504
  logger.warn(`[DatabaseManager] Restore staged for ${names} — restart to apply it (#1723)`);
507
505
  const unopened = this.unavailable
@@ -514,6 +512,31 @@ class DatabaseManager extends BaseManager {
514
512
  await this.notify('error', 'Some databases in the backup were not restored', message);
515
513
  }
516
514
  }
515
+ /** Whether a staged restore put the databases back at this start (#1876): the job state in them is then the backup's. */
516
+ restoredAtStart() {
517
+ return this.restoredNow.length > 0;
518
+ }
519
+ /**
520
+ * Read the application database as it was just before this start's restore
521
+ * replaced it (#1876): the kept `*.pre-restore` file, read-only, under the
522
+ * site's database key, which it was encrypted under. For the owner of data
523
+ * in it (BackgroundJobManager's job runs since the backup) to say what the
524
+ * restore took away. Null when no restore replaced it at this start.
525
+ */
526
+ readReplacedApplicationDatabase(read) {
527
+ const kept = this.replaced?.applicationKept;
528
+ if (!kept || !existsSync(kept))
529
+ return null;
530
+ return SqliteDatabaseProvider.readOnce(kept, process.env[DATABASE_KEY_ENV] ?? '', (db) => read(db, this.replaced.backup));
531
+ }
532
+ /** The databases a staged restore put back at this start, by name (#1876). */
533
+ getRestoredAtStart() {
534
+ return [...this.restoredNow];
535
+ }
536
+ /** Its restore is staged for the next start (#1723), and joins a site restore (#1876). */
537
+ restoreIsStaged() {
538
+ return true;
539
+ }
517
540
  /** The restore staged and waiting for the next start, or null. */
518
541
  getStagedRestore() {
519
542
  if (!this.databaseFile)
@@ -596,7 +619,23 @@ class DatabaseManager extends BaseManager {
596
619
  const name = path.basename(resolved);
597
620
  return path.dirname(resolved) === dir && name.endsWith('.db') && ADDON_SLUG.test(name.slice(0, -3));
598
621
  };
599
- const outcome = applyStagedRestore(file, allowed);
622
+ // #1876: a database restore staged as part of a site restore goes in only
623
+ // when the whole site restore does; otherwise it is set aside with it.
624
+ const siteFile = siteRestoreFile(this.engine.getManager('ConfigurationManager'));
625
+ const stagedBackupAt = this.getStagedRestore()?.backup ?? '';
626
+ const gate = gatePart(siteFile, 'DatabaseManager');
627
+ let outcome;
628
+ if (gate.decision === 'set-aside') {
629
+ outcome = setAsideStagedRestore(file, `the site restore it belongs to was not applied: ${gate.reason}`);
630
+ if (gate.part)
631
+ markPart(siteFile, 'DatabaseManager', 'set-aside', gate.reason);
632
+ }
633
+ else {
634
+ outcome = applyStagedRestore(file, allowed);
635
+ if (gate.decision === 'apply') {
636
+ markPart(siteFile, 'DatabaseManager', outcome && !outcome.refused ? 'applied' : 'set-aside', outcome?.refused ?? (outcome ? undefined : 'no staged database restore was found'));
637
+ }
638
+ }
600
639
  if (!outcome)
601
640
  return;
602
641
  this.restoreAtStart = outcome;
@@ -606,12 +645,16 @@ class DatabaseManager extends BaseManager {
606
645
  this.fault('restore', reason);
607
646
  return;
608
647
  }
648
+ this.restoredNow = outcome.applied.map((a) => a.name);
649
+ if (outcome.applied.length > 0) {
650
+ this.replaced = { backup: stagedBackupAt, applicationKept: outcome.applied.find((a) => path.resolve(a.live) === path.resolve(file))?.kept ?? null };
651
+ }
609
652
  for (const a of outcome.applied) {
610
653
  logger.warn(`[DatabaseManager] Restore applied: ${a.name} -> ${a.live} (previous kept as ${path.basename(a.kept)}) (#1723)`);
611
654
  }
612
655
  }
613
656
  backupKey() {
614
- return (process.env[BACKUP_KEY_ENV] ?? '').trim();
657
+ return currentBackupKey();
615
658
  }
616
659
  /** What is wrong with the backup key: missing, or the database key itself (D3). Null when sound. */
617
660
  backupKeyProblem() {
@@ -12,6 +12,14 @@
12
12
  * Implements the MailProvider interface so it can be passed directly to
13
13
  * MagicLinkAuthProvider without any changes to that class.
14
14
  *
15
+ * A failed send is never silent (#1916). Every sender goes through `send()`,
16
+ * so it is reported here, once for all of them: logged (provider, recipient
17
+ * domain, subject, error — never the body or the full address), the manager
18
+ * shows degraded until the next send succeeds, and administrators get one
19
+ * notification per `ngdpbase.mail.failure-notice.window-minutes` carrying a
20
+ * running count, not one per email. `send()` still rejects, so a caller with
21
+ * a person waiting can tell them.
22
+ *
15
23
  * @see docs/admin/email-setup.md for operator setup instructions
16
24
  * @see {@link https://github.com/jwilleke/ngdpbase/issues/456}
17
25
  */
@@ -19,21 +27,55 @@ import BaseManager from './BaseManager.js';
19
27
  import type { BackupData } from './BaseManager.js';
20
28
  import type { WikiEngine } from '../types/WikiEngine.js';
21
29
  import type { MailMessage, MailProvider } from '../mail/MailProvider.js';
30
+ /** The degraded reason while sends fail; cleared by the next success. */
31
+ export declare const SEND_FAILING_REASON = "outbound email is failing \u2014 see the server log and the administrator notification";
32
+ /** Title of the administrator notice. */
33
+ export declare const SEND_FAILING_TITLE = "Email could not be sent";
22
34
  declare class EmailManager extends BaseManager implements MailProvider {
23
35
  private provider;
24
36
  private providerName;
25
37
  private from;
26
38
  private enabled;
39
+ private noticeWindowMs;
40
+ private notice;
41
+ private failuresSinceSuccess;
27
42
  constructor(engine: WikiEngine);
28
43
  initialize(config?: Record<string, unknown>): Promise<void>;
29
44
  /**
30
45
  * Send an email. Injects the configured from address if the message omits it.
46
+ *
47
+ * A failure is reported here (#1916) and then rethrown, so a caller with a
48
+ * person waiting can tell them it was not sent.
31
49
  */
32
50
  send(message: MailMessage): Promise<void>;
33
51
  /**
34
52
  * Convenience wrapper — build and send a message in one call.
35
53
  */
36
54
  sendTo(to: string, subject: string, text: string, html?: string): Promise<void>;
55
+ /**
56
+ * Send without waiting, for a sender nobody is waiting on (a form's copy, a
57
+ * reservation notice). Never rejects: a failure is already logged and
58
+ * reported to administrators by `send()` (#1916), so there is nothing left
59
+ * for the caller to do with it.
60
+ */
61
+ sendInBackground(to: string, subject: string, text: string, html?: string): void;
62
+ /** Whether sends are failing right now: degraded by a failed send, not yet cleared by a success. */
63
+ isDeliveryFailing(): boolean;
64
+ private reportFailure;
65
+ private reportSuccess;
66
+ /**
67
+ * One notice per window, with a running count (#1916). A failure inside an
68
+ * open window updates that notice; the first failure after it opens a new
69
+ * one. The notice is created with `escalate: false`, so it is never emailed:
70
+ * that send would fail and report another failure.
71
+ *
72
+ * Never throws — it runs off the failed send, and a notice that cannot be
73
+ * written is already covered by the error log above.
74
+ */
75
+ private noticeAdministrators;
76
+ private createNotice;
77
+ private noticeMessage;
78
+ private notifications;
37
79
  /** Name of the active provider: 'console' | 'smtp' */
38
80
  getProviderName(): string;
39
81
  /** Effective from address (smtp.from or mail.from) */
@@ -1,4 +1,4 @@
1
- import BaseManager from './BaseManager.js';
1
+ import BaseManager, { type BackupData, type ManagerBackupOptions, type ManagerRestoreOptions } from './BaseManager.js';
2
2
  import type { WikiEngine } from '../types/WikiEngine.js';
3
3
  import { type ActorContext } from '../context/ActorContext.js';
4
4
  import { type VaultPageNotesOutside, type PageRef } from '../utils/pageNotes.js';
@@ -17,6 +17,15 @@ export default class FootnoteManager extends BaseManager {
17
17
  private readonly outsideCheck;
18
18
  constructor(engine: WikiEngine);
19
19
  initialize(): Promise<void>;
20
+ /**
21
+ * The public folder is in the backup (#1876), as `<stem>.footnotes.tar` beside
22
+ * the document: the public pages' footnotes. A vault page's are in its vault, which
23
+ * PageManager backs up with the vault, sealed as stored.
24
+ */
25
+ backup(options?: ManagerBackupOptions): Promise<BackupData>;
26
+ /** Staged for the next start with the rest of a site restore (#1876); the live folder is untouched. */
27
+ restore(backupData: BackupData, options?: ManagerRestoreOptions): Promise<void>;
28
+ restoreIsStaged(): boolean;
20
29
  isEnabled(): boolean;
21
30
  /** The public folder and the vaults, as configured now (#1839). */
22
31
  private roots;
@@ -25,6 +25,12 @@ export interface NotificationInput {
25
25
  level?: 'info' | 'warning' | 'error' | 'success';
26
26
  targetUsers?: string[];
27
27
  expiresAt?: Date | null;
28
+ /**
29
+ * False: never email this notification, whatever the escalation settings
30
+ * (#1916). A notice that email is failing must not itself be emailed — the
31
+ * send would fail and report another failure. Not stored.
32
+ */
33
+ escalate?: boolean;
28
34
  }
29
35
  /**
30
36
  * Notification statistics
@@ -102,6 +108,13 @@ declare class NotificationManager extends BaseManager {
102
108
  * @returns {Promise<string>} Notification ID
103
109
  */
104
110
  createNotification(notification: NotificationInput): Promise<string>;
111
+ /**
112
+ * Replace an active notification's message in place (#1916) — a running
113
+ * count on one notice instead of a new notice per event. Never escalates:
114
+ * the notification was already escalated, or not, when it was created.
115
+ * @returns false when there is no such notification
116
+ */
117
+ updateNotificationMessage(notificationId: string, message: string): Promise<boolean>;
105
118
  /**
106
119
  * Add a notification (alias for createNotification for backward compatibility)
107
120
  * @param {NotificationInput} notification - Notification object
@@ -1,7 +1,9 @@
1
- import BaseManager, { type ManagerStats } from './BaseManager.js';
1
+ import BaseManager, { type BackupData, type ManagerBackupOptions, type ManagerRestoreOptions, type ManagerStats } from './BaseManager.js';
2
2
  import type { WikiEngine } from '../types/WikiEngine.js';
3
- import type { Organization, OrganizationUpdate } from '../types/Organization.js';
3
+ import type { Organization, OrganizationInput, OrganizationUpdate } from '../types/Organization.js';
4
4
  import type { OrganizationProvider } from '../types/OrganizationProvider.js';
5
+ import type { Person } from '../types/Person.js';
6
+ import type { ActorContext } from '../context/ActorContext.js';
5
7
  /**
6
8
  * Subset of install-form data this manager needs to seed the anchor org.
7
9
  * Kept narrow so InstallService and tests can pass plain objects without
@@ -34,6 +36,8 @@ declare class OrganizationManager extends BaseManager {
34
36
  readonly description = "Canonical Organization records (#617)";
35
37
  private provider;
36
38
  private providerClass?;
39
+ /** `ngdpbase.application.organization.storagedir`, resolved at start: what a backup copies (#1876). */
40
+ private storageDir;
37
41
  private installOrgCache;
38
42
  private byIdCache;
39
43
  private byFileCache;
@@ -41,6 +45,11 @@ declare class OrganizationManager extends BaseManager {
41
45
  constructor(engine: WikiEngine);
42
46
  initialize(config?: Record<string, unknown>): Promise<void>;
43
47
  /** Provider accessor, mainly for tests. */
48
+ /** Organization records are in the backup (#1876), as `<stem>.organizations.tar` beside the document. */
49
+ backup(options?: ManagerBackupOptions): Promise<BackupData>;
50
+ /** Staged for the next start with the rest of a site restore (#1876); the live records are untouched. */
51
+ restore(backupData: BackupData, options?: ManagerRestoreOptions): Promise<void>;
52
+ restoreIsStaged(): boolean;
44
53
  getProvider(): OrganizationProvider | null;
45
54
  /**
46
55
  * Create the anchor organization from the instance's own identity (#1027).
@@ -81,10 +90,35 @@ declare class OrganizationManager extends BaseManager {
81
90
  getByFile(filename: string): Promise<Organization | null>;
82
91
  /** #1006: how many organizations this instance holds. Count only. */
83
92
  getManagerStats(): Promise<ManagerStats>;
84
- list(): Promise<Organization[]>;
85
- create(org: Organization, filename?: string): Promise<Organization>;
86
- update(id: string, patch: OrganizationUpdate): Promise<Organization | null>;
87
- delete(id: string): Promise<boolean>;
93
+ /** Every organization, for a caller who may read contacts (`contact-read`). */
94
+ list(ctx: ActorContext): Promise<Organization[]>;
95
+ /** One organization by `@id`, for a caller who may read contacts (`contact-read`). */
96
+ get(ctx: ActorContext, id: string): Promise<Organization | null>;
97
+ /**
98
+ * Organizations whose `name`, `legalName`, `email` or a contact point's
99
+ * email contains `query`, ignoring case; an empty query matches all. At
100
+ * most `limit` (`ngdpbase.application.organization.search.limit` by default).
101
+ */
102
+ search(ctx: ActorContext, query: string, options?: {
103
+ limit?: number;
104
+ }): Promise<Organization[]>;
105
+ /**
106
+ * The people affiliated with an organization, read from each person's
107
+ * `affiliation` (#1930): the link is stored once, on the person. Each comes
108
+ * with the role they hold there, when one is given.
109
+ */
110
+ people(ctx: ActorContext, organizationId: string): Promise<Array<{
111
+ person: Person;
112
+ role?: string;
113
+ }>>;
114
+ /**
115
+ * Create an organization. `name` is required; `@id` defaults to the `url`,
116
+ * else a new `urn:uuid:`. `filename` is for the install anchor (seeding);
117
+ * other records take one from the url or name.
118
+ */
119
+ create(ctx: ActorContext, org: OrganizationInput, filename?: string): Promise<Organization>;
120
+ update(ctx: ActorContext, id: string, patch: OrganizationUpdate): Promise<Organization | null>;
121
+ delete(ctx: ActorContext, id: string): Promise<boolean>;
88
122
  /**
89
123
  * Clear all caches. Called on every write — the simplest correct
90
124
  * invalidation. Per-key invalidation can come later if needed.
@@ -1,12 +1,37 @@
1
- import BaseManager, { type ManagerStats } from './BaseManager.js';
1
+ import BaseManager, { type BackupData, type ManagerBackupOptions, type ManagerRestoreOptions, type ManagerStats } from './BaseManager.js';
2
2
  import type { WikiEngine } from '../types/WikiEngine.js';
3
- import type { Person, PersonUpdate } from '../types/Person.js';
3
+ import type { Person, PersonInput, PersonUpdate } from '../types/Person.js';
4
4
  import type { PersonProvider } from '../types/PersonProvider.js';
5
+ import { type ActorContext } from '../context/ActorContext.js';
6
+ /** What the confirmation page shows: the person the link would join. */
7
+ export interface LinkOfferDetails {
8
+ personName: string | null;
9
+ email: string;
10
+ }
11
+ /** The outcome of offering a sign-up link (#1930). */
12
+ export type LinkOfferOutcome = 'sent' | 'no-match' | 'several' | 'unavailable' | 'failed';
5
13
  /**
6
- * PersonManager — canonical core record for persons (#617).
14
+ * PersonManager — canonical core record for persons (#617), and the door for
15
+ * contacts (#1930).
16
+ *
17
+ * A person may have an account or not. Each account has exactly one person
18
+ * (`identifier` = the username); a person with no account has the identifier
19
+ * `person:<uuid>`, which no username may take. UserManager calls the door on
20
+ * the account lifecycle under a system context.
21
+ *
22
+ * The door: `create`, `update`, `delete`, `get`, `list`, `search` and
23
+ * `linkAccount` take the caller's context first and ask the PDP: `contact-read`
24
+ * to list, search or get; `contact-manage` to change (`linkAccount` also `user-edit`). Each change is recorded as
25
+ * the `contact-manage` audit event. A refusal throws `ContactRefusedError`.
26
+ * `getById` / `getByIdentifier` stay as context-free lookups for core's own
27
+ * hot paths (RoleManager resolves roles through them on every request).
28
+ *
29
+ * A later sign-up joins a person with no account in one of two ways: the new
30
+ * account confirms an emailed link (`offerSignUpLink` → `confirmSignUpLink`),
31
+ * or an administrator links them (`linkAccount`). Either way the existing
32
+ * person keeps its `@id` and takes the account; the person made for the new
33
+ * account is folded into it and removed, so there is never a second person.
7
34
  *
8
- * Each User has at most one paired Person record. UserManager calls
9
- * `create`/`update`/`delete` here on the matching user lifecycle events.
10
35
  * `getByUserIdentifier(username)` remains as a lazy-migration entry point
11
36
  * for legacy users that pre-date the sync wiring.
12
37
  */
@@ -14,20 +39,91 @@ declare class PersonManager extends BaseManager {
14
39
  readonly description = "Canonical Person records (#617)";
15
40
  private provider;
16
41
  private providerClass?;
42
+ /** `ngdpbase.application.persons.storagedir`, resolved at start: what a backup copies (#1876). */
43
+ private storageDir;
17
44
  private byIdentifierCache;
18
45
  private byIdCache;
19
46
  private metricsRef;
47
+ /** Sign-up link offers by token (#1930). In memory: a restart drops them, and an administrator links by hand. */
48
+ private linkOffers;
20
49
  constructor(engine: WikiEngine);
21
50
  initialize(config?: Record<string, unknown>): Promise<void>;
51
+ /** Person records are in the backup (#1876), as `<stem>.persons.tar` beside the document. */
52
+ backup(options?: ManagerBackupOptions): Promise<BackupData>;
53
+ /** Staged for the next start with the rest of a site restore (#1876); the live records are untouched. */
54
+ restore(backupData: BackupData, options?: ManagerRestoreOptions): Promise<void>;
55
+ restoreIsStaged(): boolean;
22
56
  getProvider(): PersonProvider | null;
23
57
  getById(id: string): Promise<Person | null>;
24
58
  getByIdentifier(identifier: string): Promise<Person | null>;
25
59
  /** #1006: how many people this instance holds. Count only, never records. */
26
60
  getManagerStats(): Promise<ManagerStats>;
27
- list(): Promise<Person[]>;
28
- create(person: Person): Promise<Person>;
29
- update(id: string, patch: PersonUpdate): Promise<Person | null>;
30
- delete(id: string): Promise<boolean>;
61
+ /** True when the person has no account: its identifier is `person:<uuid>` (#1930). */
62
+ static isAccountless(person: Pick<Person, 'identifier'>): boolean;
63
+ /** Every person, for a caller who may read contacts (`contact-read`). */
64
+ list(ctx: ActorContext): Promise<Person[]>;
65
+ /** One person by `@id`, for a caller who may read contacts (`contact-read`). */
66
+ get(ctx: ActorContext, id: string): Promise<Person | null>;
67
+ /**
68
+ * Persons whose name (`name`, `givenName`, `familyName`) or `email` contains
69
+ * `query`, ignoring case; an empty query matches everyone. `accountless`
70
+ * narrows to persons with no account. At most `limit` results
71
+ * (`ngdpbase.application.persons.search.limit` by default).
72
+ */
73
+ search(ctx: ActorContext, query: string, options?: {
74
+ limit?: number;
75
+ accountless?: boolean;
76
+ }): Promise<Person[]>;
77
+ /**
78
+ * Create a person. With no `identifier` the person has no account and gets
79
+ * `person:<uuid>`. An `identifier` must be an existing account that has no
80
+ * person yet: that is how UserManager makes the person for a new account.
81
+ */
82
+ create(ctx: ActorContext, input: PersonInput): Promise<Person>;
83
+ /** Change a person's fields, affiliations included. `@id` and `identifier` never change here. */
84
+ update(ctx: ActorContext, id: string, patch: PersonUpdate): Promise<Person | null>;
85
+ delete(ctx: ActorContext, id: string): Promise<boolean>;
86
+ /**
87
+ * An administrator joins a person who has no account to an account (#1930),
88
+ * for when the email differs or none was given. Asks `contact-manage` and
89
+ * `user-edit`. The person keeps its `@id`; the account's own person, if it
90
+ * has one, is folded in and removed.
91
+ */
92
+ linkAccount(ctx: ActorContext, personId: string, username: string): Promise<Person>;
93
+ /**
94
+ * Offer a new account the person with no account who has the same email
95
+ * ("Is this you?"): a single-use link is mailed to that address, and the
96
+ * account joins the person only when it confirms. Called by UserManager
97
+ * after an account is created. Skipped when email links are not available
98
+ * on this site; nothing is offered when no person, or more than one, has
99
+ * that address (an administrator links those by hand). `ctx` is the context
100
+ * the account was created under, named in the log.
101
+ */
102
+ offerSignUpLink(ctx: ActorContext, username: string): Promise<LinkOfferOutcome>;
103
+ /** What the confirmation page shows, for the signed-in account the link was sent to; null for an unknown, used, expired or other account's link. */
104
+ signUpLinkOffer(ctx: ActorContext, token: string): Promise<LinkOfferDetails | null>;
105
+ /**
106
+ * The new account confirms the emailed link: it is joined to the person.
107
+ * Single use. The link is the authority — it proves the address and is
108
+ * bound to the account it was sent for — so no permission is asked; the
109
+ * change is recorded under the account's own context. Null for a link that
110
+ * is unknown, used, expired, or another account's.
111
+ */
112
+ confirmSignUpLink(ctx: ActorContext, token: string): Promise<Person | null>;
113
+ /** The new account says the record is not theirs: the link is dropped. */
114
+ declineSignUpLink(ctx: ActorContext, token: string): boolean;
115
+ private liveOffer;
116
+ private sweepLinkOffers;
117
+ /**
118
+ * Join `username` to the person `personId`, who has no account. The person
119
+ * takes the account's identifier and keeps its `@id`; fields it lacks are
120
+ * filled from the account's own person, that person's role memberships move
121
+ * to it, and that person is removed. Never a second person.
122
+ */
123
+ private join;
124
+ /** Each affiliation names an organization that exists; `role` is trimmed text. Throws on any other shape. */
125
+ private checkedAffiliations;
126
+ private configNumber;
31
127
  /**
32
128
  * Clear all caches. Called on every write — the simplest correct
33
129
  * invalidation. Per-key invalidation can come later if needed.
@@ -45,4 +141,5 @@ declare class PersonManager extends BaseManager {
45
141
  private getMetrics;
46
142
  private normalizeProviderName;
47
143
  }
144
+ export { ContactRefusedError } from '../utils/contactAccess.js';
48
145
  export default PersonManager;
@@ -1,4 +1,4 @@
1
- import BaseManager from './BaseManager.js';
1
+ import BaseManager, { type BackupData, type ManagerBackupOptions, type ManagerRestoreOptions } from './BaseManager.js';
2
2
  import type { WikiEngine } from '../types/WikiEngine.js';
3
3
  import type { Role, RoleUpdate } from '../types/Role.js';
4
4
  import type { RoleProvider } from '../types/RoleProvider.js';
@@ -8,6 +8,8 @@ declare class RoleManager extends BaseManager {
8
8
  readonly description = "Canonical OrganizationRole records (#617 follow-up)";
9
9
  private provider;
10
10
  private providerClass?;
11
+ /** `ngdpbase.application.roles.storagedir`, resolved at start: what a backup copies (#1876). */
12
+ private storageDir;
11
13
  private memberCache;
12
14
  private byOrgPositionCache;
13
15
  private metricsRef;
@@ -31,6 +33,15 @@ declare class RoleManager extends BaseManager {
31
33
  * as is any other `additionalProperty` entry.
32
34
  */
33
35
  private stripCatalogueCopies;
36
+ /**
37
+ * Role membership is in the backup (#1876): the role records folder, as
38
+ * `<stem>.roles.tar` beside the document. Without it restored users would
39
+ * have no roles.
40
+ */
41
+ backup(options?: ManagerBackupOptions): Promise<BackupData>;
42
+ /** Staged for the next start with the rest of a site restore (#1876); the live records are untouched. */
43
+ restore(backupData: BackupData, options?: ManagerRestoreOptions): Promise<void>;
44
+ restoreIsStaged(): boolean;
34
45
  /** Provider accessor, mainly for tests. */
35
46
  getProvider(): RoleProvider | null;
36
47
  getById(id: string): Promise<Role | null>;
@@ -102,6 +113,13 @@ declare class RoleManager extends BaseManager {
102
113
  * @returns how many accounts were given the role (0 once it has been given)
103
114
  */
104
115
  grantToEveryAccountOnce(roleName: string, usernames: readonly string[], ctx: JobContext): Promise<number>;
116
+ /**
117
+ * Move every membership held by one Person `@id` to another (#1930): when
118
+ * PersonManager joins an account to an existing person, the roles the
119
+ * account's person held go with the account. One write per role record;
120
+ * returns how many records changed. PersonManager's join records the change.
121
+ */
122
+ moveMemberships(fromPersonId: string, toPersonId: string): Promise<number>;
105
123
  /** Remove a deleted account's Person from every role it held. */
106
124
  removeAllMemberships(username: string): Promise<void>;
107
125
  /**
@@ -0,0 +1,94 @@
1
+ /**
2
+ * SessionStatsManager — in-process reads of the session store (#1246).
3
+ *
4
+ * Two things want to know how many sessions exist and who holds them: the
5
+ * `/api/session-count` and `/api/session-users` routes, and SessionsPlugin,
6
+ * which renders those numbers into a page. The plugin used to get them by
7
+ * making an HTTP request to this server's own address through a bare global
8
+ * `fetch` — an outbound call outside `src/http/` (#1133) that `guardedFetch`
9
+ * cannot take, because loopback is refused unconditionally (#1186), and one
10
+ * that silently rendered `0` in any container whose configured host was not
11
+ * reachable from inside the process.
12
+ *
13
+ * The store is an in-process object. Nothing about reading it needs a socket.
14
+ * app.ts attaches the store it built for express-session; the routes keep
15
+ * reading `req.sessionStore` (the same object) and both go through the same
16
+ * two helpers, so there is exactly one implementation of "count sessions" and
17
+ * "list session users".
18
+ */
19
+ import BaseManager from './BaseManager.js';
20
+ import type { WikiEngine } from '../types/WikiEngine.js';
21
+ /** The two optional store methods express-session stores may implement. */
22
+ export interface SessionStoreLike {
23
+ length?(callback: (err: unknown, count?: number) => void): void;
24
+ all?(callback: (err: unknown, sessions?: unknown) => void): void;
25
+ /** session-file-store: the session file names, not the sessions. */
26
+ list?(callback: (err: unknown, files?: string[]) => void): void;
27
+ get?(sid: string, callback: (err: unknown, session?: unknown) => void): void;
28
+ destroy?(sid: string, callback: (err: unknown) => void): void;
29
+ /** session-file-store keeps its file extension here (default `.json`). */
30
+ options?: {
31
+ fileExtension?: string;
32
+ };
33
+ }
34
+ export interface SessionCount {
35
+ sessionCount: number;
36
+ distinctUsers: number;
37
+ }
38
+ export interface SessionUsers {
39
+ users: string[];
40
+ anonymous: number;
41
+ total: number;
42
+ }
43
+ /** Thrown when the store implements neither `length` nor `all`. */
44
+ export declare class SessionStoreUnsupportedError extends Error {
45
+ constructor(what: string);
46
+ }
47
+ /**
48
+ * Session count and distinct-user count. Prefers `length` (cheap), falls back
49
+ * to `all`. Under `length` the distinct-user figure equals the session count,
50
+ * which is what the route always reported on that path.
51
+ */
52
+ export declare function countSessions(store: SessionStoreLike): Promise<SessionCount>;
53
+ /**
54
+ * Authenticated usernames (sorted, distinct) and the anonymous session count.
55
+ * Needs `all`; under `length` alone the list is empty and every session is
56
+ * reported as anonymous.
57
+ */
58
+ export declare function listSessionUsers(store: SessionStoreLike): Promise<SessionUsers>;
59
+ /** A session in the store, with its id. */
60
+ export interface StoredSession {
61
+ sid: string;
62
+ session: Record<string, unknown>;
63
+ }
64
+ /**
65
+ * Every session in the store with its id (#1937): `all()` keyed by id where
66
+ * the store has it, else list and read each (the file store). A store whose
67
+ * `all()` returns a bare array gives no ids, so it cannot be listed this way.
68
+ */
69
+ export declare function storedSessions(store: SessionStoreLike): Promise<StoredSession[]>;
70
+ /**
71
+ * The private-store handles held by sessions still in the store (#1626).
72
+ * Uses `all()` where the store has it, else lists and reads each session:
73
+ * the file store has no `all()`, so the sweep failed on every run there and
74
+ * no orphaned key was ever dropped.
75
+ */
76
+ export declare function liveSessionHandles(store: SessionStoreLike): Promise<Set<string>>;
77
+ declare class SessionStatsManager extends BaseManager {
78
+ private store;
79
+ constructor(engine: WikiEngine);
80
+ /** app.ts hands over the store it built for express-session. */
81
+ attachStore(store: SessionStoreLike): void;
82
+ hasStore(): boolean;
83
+ /** @throws Error when no store is attached; SessionStoreUnsupportedError when it cannot count. */
84
+ count(): Promise<SessionCount>;
85
+ /** @throws Error when no store is attached; SessionStoreUnsupportedError when it cannot list. */
86
+ users(): Promise<SessionUsers>;
87
+ /** @throws Error when no store is attached; SessionStoreUnsupportedError when it cannot list. */
88
+ liveHandles(): Promise<Set<string>>;
89
+ /** Every session in the store with its id (#1937). @throws Error when no store is attached; SessionStoreUnsupportedError when it cannot list by id. */
90
+ sessions(): Promise<StoredSession[]>;
91
+ /** Remove one session from the store (#1937). @throws Error when no store is attached, or SessionStoreUnsupportedError when it cannot destroy. */
92
+ destroy(sid: string): Promise<void>;
93
+ }
94
+ export default SessionStatsManager;