@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.
- package/lib/packages/ngdpbase-types/index.d.ts +5 -0
- package/lib/src/mail/MailProvider.d.ts +2 -0
- package/lib/src/managers/AddonsManager.d.ts +3 -3
- package/lib/src/managers/AttachmentManager.d.ts +64 -12
- package/lib/src/managers/AuthManager.d.ts +29 -6
- package/lib/src/managers/BackgroundJobManager.d.ts +65 -1
- package/lib/src/managers/BaseManager.d.ts +14 -0
- package/lib/src/managers/BaseManager.js +9 -0
- package/lib/src/managers/CommentManager.d.ts +10 -1
- package/lib/src/managers/DatabaseManager.d.ts +24 -17
- package/lib/src/managers/DatabaseManager.js +72 -29
- package/lib/src/managers/EmailManager.d.ts +42 -0
- package/lib/src/managers/FootnoteManager.d.ts +10 -1
- package/lib/src/managers/NotificationManager.d.ts +13 -0
- package/lib/src/managers/OrganizationManager.d.ts +40 -6
- package/lib/src/managers/PersonManager.d.ts +106 -9
- package/lib/src/managers/RoleManager.d.ts +19 -1
- package/lib/src/managers/SessionStatsManager.d.ts +94 -0
- package/lib/src/managers/UserManager.d.ts +36 -1
- package/lib/src/providers/BaseAttachmentProvider.d.ts +14 -0
- package/lib/src/providers/BasicAttachmentProvider.d.ts +26 -2
- package/lib/src/providers/SqliteDatabaseProvider.d.ts +7 -0
- package/lib/src/providers/SqliteDatabaseProvider.js +16 -0
- package/lib/src/providers/SqliteJobStateProvider.d.ts +10 -0
- package/lib/src/providers/attachmentFilesBackup.d.ts +52 -0
- package/lib/src/providers/sqliteBackup.d.ts +6 -0
- package/lib/src/providers/sqliteBackup.js +16 -6
- package/lib/src/security/permissions.generated.d.ts +4 -0
- package/lib/src/types/Asset.d.ts +10 -0
- package/lib/src/types/Config.d.ts +11 -0
- package/lib/src/types/Organization.d.ts +7 -0
- package/lib/src/types/Person.d.ts +27 -3
- package/lib/src/types/PersonProvider.d.ts +6 -0
- package/lib/src/types/WikiEngine.d.ts +9 -21
- package/lib/src/types/express.d.ts +2 -0
- package/lib/src/utils/addonPageSync.d.ts +24 -0
- package/lib/src/utils/administrators.d.ts +21 -0
- package/lib/src/utils/auditEventNames.generated.d.ts +4 -0
- package/lib/src/utils/auditEventNames.generated.js +4 -0
- package/lib/src/utils/backupCrypto.d.ts +28 -0
- package/lib/src/utils/backupKey.d.ts +23 -0
- package/lib/src/utils/backupKey.js +37 -0
- package/lib/src/utils/contactAccess.d.ts +58 -0
- package/lib/src/utils/databaseKey.d.ts +7 -0
- package/lib/src/utils/databaseKey.js +7 -0
- package/lib/src/utils/directoryBackup.d.ts +78 -0
- package/lib/src/utils/emailForLog.d.ts +20 -0
- package/lib/src/utils/maintenanceState.d.ts +153 -0
- package/lib/src/utils/privateStoreTakeout.d.ts +2 -2
- package/lib/src/utils/sessionAccount.d.ts +26 -0
- package/lib/src/utils/sessionEnd.d.ts +1 -1
- package/lib/src/utils/siteRestore.d.ts +110 -0
- package/lib/src/utils/siteRestore.js +309 -0
- package/lib/src/utils/treeArchive.d.ts +44 -0
- package/lib/src/utils/username.d.ts +7 -1
- package/package.json +2 -2
- package/lib/src/utils/startupState.d.ts +0 -65
- 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 {
|
|
51
|
-
import {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
194
|
-
*
|
|
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
|
-
|
|
204
|
-
this.engine.
|
|
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 = `${
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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;
|