@jwilleke/ngdpbase-types 5.21.0 → 5.22.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 (54) 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 +24 -4
  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/UserManager.d.ts +15 -0
  19. package/lib/src/providers/BaseAttachmentProvider.d.ts +14 -0
  20. package/lib/src/providers/BasicAttachmentProvider.d.ts +26 -2
  21. package/lib/src/providers/SqliteDatabaseProvider.d.ts +7 -0
  22. package/lib/src/providers/SqliteDatabaseProvider.js +16 -0
  23. package/lib/src/providers/SqliteJobStateProvider.d.ts +10 -0
  24. package/lib/src/providers/attachmentFilesBackup.d.ts +52 -0
  25. package/lib/src/providers/sqliteBackup.d.ts +6 -0
  26. package/lib/src/providers/sqliteBackup.js +16 -6
  27. package/lib/src/security/permissions.generated.d.ts +4 -0
  28. package/lib/src/types/Asset.d.ts +10 -0
  29. package/lib/src/types/Config.d.ts +11 -0
  30. package/lib/src/types/Organization.d.ts +7 -0
  31. package/lib/src/types/Person.d.ts +27 -3
  32. package/lib/src/types/PersonProvider.d.ts +6 -0
  33. package/lib/src/types/WikiEngine.d.ts +9 -21
  34. package/lib/src/utils/addonPageSync.d.ts +24 -0
  35. package/lib/src/utils/administrators.d.ts +21 -0
  36. package/lib/src/utils/auditEventNames.generated.d.ts +4 -0
  37. package/lib/src/utils/auditEventNames.generated.js +4 -0
  38. package/lib/src/utils/backupCrypto.d.ts +28 -0
  39. package/lib/src/utils/backupKey.d.ts +23 -0
  40. package/lib/src/utils/backupKey.js +37 -0
  41. package/lib/src/utils/contactAccess.d.ts +58 -0
  42. package/lib/src/utils/databaseKey.d.ts +7 -0
  43. package/lib/src/utils/databaseKey.js +7 -0
  44. package/lib/src/utils/directoryBackup.d.ts +78 -0
  45. package/lib/src/utils/emailForLog.d.ts +20 -0
  46. package/lib/src/utils/maintenanceState.d.ts +153 -0
  47. package/lib/src/utils/privateStoreTakeout.d.ts +2 -2
  48. package/lib/src/utils/siteRestore.d.ts +110 -0
  49. package/lib/src/utils/siteRestore.js +309 -0
  50. package/lib/src/utils/treeArchive.d.ts +44 -0
  51. package/lib/src/utils/username.d.ts +7 -1
  52. package/package.json +1 -1
  53. package/lib/src/utils/startupState.d.ts +0 -65
  54. package/lib/src/utils/startupState.js +0 -115
@@ -30,3 +30,8 @@ export type { Migration } from '../../src/providers/sqliteMigrations.js';
30
30
  export type { AddonDatabaseDeclaration, DatabaseEncrypt } from '../../src/utils/addonDatabaseRules.js';
31
31
  export type { default as ExportManager } from '../../src/managers/ExportManager.js';
32
32
  export type { default as PolicyDecisionPoint } from '../../src/security/PolicyDecisionPoint.js';
33
+ export type { default as PersonManager, LinkOfferOutcome } from '../../src/managers/PersonManager.js';
34
+ export type { default as OrganizationManager } from '../../src/managers/OrganizationManager.js';
35
+ export type { Person, PersonAffiliation, PersonInput, PersonUpdate } from '../../src/types/Person.js';
36
+ export type { Organization, OrganizationInput, OrganizationUpdate } from '../../src/types/Organization.js';
37
+ export type { ContactRefusedError } from '../../src/utils/contactAccess.js';
@@ -30,6 +30,8 @@ export interface MailProvider {
30
30
  /**
31
31
  * Development transport — writes the email to the server log instead of
32
32
  * sending it. Useful for local installs where no SMTP server is available.
33
+ * Like every log line, it names the recipient's domain only, and any address
34
+ * in the body is cut to its domain (#1916).
33
35
  */
34
36
  export declare class ConsoleMailProvider implements MailProvider {
35
37
  send(message: MailMessage): Promise<void>;
@@ -364,9 +364,9 @@ declare class AddonsManager extends BaseManager {
364
364
  * not load, whatever the reason. Admins get a notification at the most
365
365
  * severe level naming it and the reason, which says how to fix it where the
366
366
  * reason does. A site-critical add-on (`ngdpbase.addons.<slug>.site-critical`)
367
- * also holds the whole site in maintenance mode through core's survivable
368
- * path (#1152): visitors see only the configured public message, with no
369
- * details, and administrators can still sign in and reach /admin. Once per
367
+ * also holds the whole site in maintenance mode through the one door
368
+ * (`engine.maintenance`, #1906): visitors see only the site's maintenance
369
+ * message, with no details, and administrators can still sign in and reach /admin. Once per
370
370
  * add-on per start. Nothing is opened in its place.
371
371
  */
372
372
  private recordLoadFailure;
@@ -1,4 +1,6 @@
1
- import BaseManager, { BackupData, type ManagerStats } from './BaseManager.js';
1
+ import BaseManager, { BackupData, type ManagerBackupOptions, type ManagerRestoreOptions, type ManagerStats } from './BaseManager.js';
2
+ import type { AttachmentFilesBackupData, AttachmentFilesBackupOutcome } from '../providers/attachmentFilesBackup.js';
3
+ import type { PartOutcome } from '../utils/siteRestore.js';
2
4
  import { type ActorContext } from '../context/ActorContext.js';
3
5
  /**
4
6
  * Marks an error as "the audit record could not be written", not "the action
@@ -7,13 +9,16 @@ import { type ActorContext } from '../context/ActorContext.js';
7
9
  export declare const AUDIT_WRITE_FAILED = "EAUDITWRITE";
8
10
  import type { WikiEngine } from '../types/WikiEngine.js';
9
11
  import type { CatalogSource, CatalogQuery, CatalogPage, CreativeWork, SchemaType, RebuildOpts } from '../types/Schema.js';
10
- import type { AssetQuery, AssetRecord } from '../types/Asset.js';
12
+ import type { User } from '../providers/BaseAttachmentProvider.js';
13
+ import { type AssetQuery, type AssetRecord } from '../types/Asset.js';
11
14
  import type { StoreFileEntry, StoreFileLocation } from '../types/Provider.js';
12
15
  /**
13
16
  * Base attachment provider interface
14
17
  */
15
18
  interface BaseAttachmentProvider {
16
- initialize(): Promise<void>;
19
+ initialize(options?: {
20
+ readOnly?: boolean;
21
+ }): Promise<void>;
17
22
  storeAttachment(fileBuffer: Buffer, fileInfo: FileInfo, metadata: AttachmentMetadataInput, user: User | null): Promise<AttachmentMetadata>;
18
23
  getAttachment(attachmentId: string): Promise<{
19
24
  buffer: Buffer;
@@ -30,6 +35,12 @@ interface BaseAttachmentProvider {
30
35
  getThumbnail?(id: string, size: string): Promise<Buffer | null>;
31
36
  backup(): Promise<unknown>;
32
37
  restore(backupData: unknown): Promise<void>;
38
+ /** #1876: the files themselves beside the backup document, and their staged restore. */
39
+ backupFiles?(directory: string, stem: string): Promise<AttachmentFilesBackupOutcome>;
40
+ stageFilesRestore?(providerBackup: Record<string, unknown>, files: AttachmentFilesBackupData, directory: string, siteRestore: string): Promise<{
41
+ skippedVaults: string[];
42
+ }>;
43
+ takeRestoreAtStart?(): PartOutcome | null;
33
44
  shutdown(): Promise<void>;
34
45
  getProviderInfo(): {
35
46
  features: string[];
@@ -95,13 +106,6 @@ export interface UploadOptions {
95
106
  /** Store id when private with no private page; default from ConfigurationManager defaultstoreid */
96
107
  store?: string;
97
108
  }
98
- /**
99
- * User object interface
100
- */
101
- export interface User {
102
- name: string;
103
- email?: string;
104
- }
105
109
  /**
106
110
  * Mention object (WebPage reference)
107
111
  */
@@ -148,6 +152,8 @@ export interface AttachmentMetadataInput {
148
152
  export interface AttachmentBackupData extends BackupData {
149
153
  providerClass: string | null;
150
154
  providerBackup?: unknown;
155
+ /** The files themselves, as archives beside the document (#1876). */
156
+ files?: AttachmentFilesBackupData;
151
157
  data?: null;
152
158
  }
153
159
  declare class AttachmentManager extends BaseManager implements CatalogSource {
@@ -302,6 +308,16 @@ declare class AttachmentManager extends BaseManager implements CatalogSource {
302
308
  * @returns {Promise<AttachmentMetadata>} Attachment metadata
303
309
  */
304
310
  uploadAttachment(fileBuffer: Buffer, fileInfo: FileInfo, ctx: ActorContext, options?: UploadOptions): Promise<AttachmentMetadata>;
311
+ /**
312
+ * The username an upload is recorded under (#1926): the person the context
313
+ * names. Asked after policy has allowed the upload and before anything is
314
+ * written; it decides nothing about authority, only whether there is
315
+ * someone to record. No one uploads anonymously (operator, 2026-10-20, on
316
+ * #1852), so the anonymous subject — a share visitor included — and a job
317
+ * with no person behind it are refused. A refusal reads 'Permission denied'
318
+ * so the route answers it as one (401 for an anonymous caller).
319
+ */
320
+ private static uploaderOf;
305
321
  /** A file is added to a private page only by someone who may edit it: the page's own decision (#1398). */
306
322
  private assertMayEditPage;
307
323
  /**
@@ -446,6 +462,40 @@ declare class AttachmentManager extends BaseManager implements CatalogSource {
446
462
  * is findable exactly as a public one is.
447
463
  */
448
464
  searchPrivateStoreAttachments(query: AssetQuery, ctx: ActorContext): Promise<AssetRecord[]>;
465
+ /**
466
+ * {@link searchPrivateStoreAttachments}, kept apart by vault (#1852): the
467
+ * files layer needs to say which vault each file is in. The same stores,
468
+ * the same container rule, the same matching.
469
+ */
470
+ readableVaultAssetRecords(ctx: ActorContext, query?: AssetQuery): Promise<Array<{
471
+ owner: string;
472
+ vault: string;
473
+ files: AssetRecord[];
474
+ }>>;
475
+ /**
476
+ * The shared pool as asset records (#1852), every one of them: the
477
+ * provider's own mapping, so the files layer and asset search show a
478
+ * public file the same way. Whether the requester may read the pool is the
479
+ * caller's question (`asset-read`).
480
+ */
481
+ sharedPoolAssetRecords(): Promise<AssetRecord[]>;
482
+ /**
483
+ * Every vault's files, for an operator inventory with no viewer (#1852,
484
+ * the files migration dry run), the way {@link findVaultPagesUsingPoolFiles}
485
+ * walks them: an unsealed vault's index entries; for any other vault
486
+ * (sealed, or not yet opened) only the files on disk and their size, since
487
+ * its index can't be read without its owner's key. Reads only. The entries
488
+ * are for the files layer to count; they are never shown.
489
+ */
490
+ vaultFileInventory(): Promise<Array<{
491
+ owner: string;
492
+ vault: string;
493
+ /** Whether its index could be read: an unsealed vault. */
494
+ readable: boolean;
495
+ entries: StoreFileEntry[] | null;
496
+ filesOnDisk: number;
497
+ bytesOnDisk: number;
498
+ }>>;
449
499
  /**
450
500
  * Move every unencrypted private file record out of the global
451
501
  * `attachment-metadata.json` and into its store's own index, at start-up.
@@ -1000,7 +1050,7 @@ declare class AttachmentManager extends BaseManager implements CatalogSource {
1000
1050
  *
1001
1051
  * @returns {Promise<AttachmentBackupData>}
1002
1052
  */
1003
- backup(): Promise<AttachmentBackupData>;
1053
+ backup(options?: ManagerBackupOptions): Promise<AttachmentBackupData>;
1004
1054
  /**
1005
1055
  * Restore manager data from backup
1006
1056
  * Delegates to provider's restore method
@@ -1008,7 +1058,9 @@ declare class AttachmentManager extends BaseManager implements CatalogSource {
1008
1058
  * @param {AttachmentBackupData} backupData - Backup data from backup() method
1009
1059
  * @returns {Promise<void>}
1010
1060
  */
1011
- restore(backupData: AttachmentBackupData): Promise<void>;
1061
+ restore(backupData: AttachmentBackupData, options?: ManagerRestoreOptions): Promise<void>;
1062
+ /** Its restore is staged for the next start with the rest of a site restore (#1876). */
1063
+ restoreIsStaged(): boolean;
1012
1064
  /**
1013
1065
  * Shutdown the manager
1014
1066
  * @returns {Promise<void>}
@@ -30,7 +30,7 @@
30
30
  * @see {@link https://github.com/jwilleke/ngdpbase/issues/396}
31
31
  */
32
32
  import BaseManager from './BaseManager.js';
33
- import type { BackupData } from './BaseManager.js';
33
+ import type { BackupData, ManagerBackupOptions, ManagerRestoreOptions } from './BaseManager.js';
34
34
  import type { WikiEngine } from '../types/WikiEngine.js';
35
35
  import type { PermissionSubject } from './UserManager.js';
36
36
  import type { JobContext } from '../context/JobContext.js';
@@ -396,6 +396,8 @@ declare class AuthManager extends BaseManager {
396
396
  private factorCache;
397
397
  /** The credentials store (#1524); null when it could not be opened. */
398
398
  private credentials;
399
+ /** `ngdpbase.auth.credentials.file`, resolved at start: what a backup copies (#1876). */
400
+ private credentialsFile;
399
401
  constructor(engine: WikiEngine);
400
402
  initialize(config?: Record<string, unknown>): Promise<void>;
401
403
  /**
@@ -1126,7 +1128,7 @@ declare class AuthManager extends BaseManager {
1126
1128
  * The link only opens the approval page; the button there approves, so a
1127
1129
  * mail scanner opening links approves nothing. Limited to one per minute.
1128
1130
  */
1129
- sendEmailApproval(handle: string, binding: string): Promise<'sent' | 'too-soon' | 'unavailable'>;
1131
+ sendEmailApproval(handle: string, binding: string): Promise<'sent' | 'too-soon' | 'unavailable' | 'failed'>;
1130
1132
  /** What the approval page shows about the waiting sign-in; null for an unknown or used link. */
1131
1133
  approvalDetails(token: string): {
1132
1134
  username: string;
@@ -1260,7 +1262,25 @@ declare class AuthManager extends BaseManager {
1260
1262
  isEnabled(providerId: string): boolean;
1261
1263
  /** Returns all registered providers (for admin UI). */
1262
1264
  getProviders(): AuthProvider[];
1263
- backup(): Promise<BackupData>;
1264
- restore(_backupData: BackupData): Promise<void>;
1265
+ /**
1266
+ * The provider list, and the credentials store (#1876): passkeys, TOTP,
1267
+ * verified email and phone, devices, linked identities and proxy grants,
1268
+ * as its own file beside the document, __always encrypted under
1269
+ * `NGDPBASE_BACKUP_KEY`__ (`<stem>.credentials.tar.enc`). Without the key the
1270
+ * store is left out and the backup is reported incomplete; it is never in a
1271
+ * backup unencrypted. The rows stay signed with `NGDPBASE_CREDENTIALS_KEY`,
1272
+ * which is not in the backup: a site restored under another credentials key
1273
+ * holds them in quarantine (#1633).
1274
+ */
1275
+ backup(options?: ManagerBackupOptions): Promise<BackupData>;
1276
+ /**
1277
+ * Stage the credentials store from a backup for the next start, with the
1278
+ * rest of a site restore (#1876). A backup that left it out (no backup key
1279
+ * when it was taken) stages nothing: the store on the site stays, and the
1280
+ * restore's notification names it as not restored.
1281
+ */
1282
+ restore(backupData: BackupData, options?: ManagerRestoreOptions): Promise<void>;
1283
+ /** Staged for the next start with the rest of a site restore (#1876). */
1284
+ restoreIsStaged(): boolean;
1265
1285
  }
1266
1286
  export default AuthManager;
@@ -1,9 +1,21 @@
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 JobContext } from '../context/JobContext.js';
4
4
  import { type ScheduleInput } from '../utils/schedule.js';
5
5
  import { type CatchUp, type Overlap } from '../utils/jobSlots.js';
6
6
  import { type JobRunRecord, type JobStateProvider } from '../providers/jobState.js';
7
+ /** Who holds a slot lock: this host, this process, this start. */
8
+ /**
9
+ * One run between a backup and the restore of it (#1876): what admins are
10
+ * told was done on the site and is no longer in the restored job state.
11
+ */
12
+ export interface JobRunSince {
13
+ jobId: string;
14
+ startedAt: string;
15
+ completedAt?: string;
16
+ status: JobRunRecord['status'];
17
+ error?: string;
18
+ }
7
19
  /**
8
20
  * Callback supplied to job run functions so they can push live progress
9
21
  * messages that the client can display while polling.
@@ -212,6 +224,19 @@ declare class BackgroundJobManager extends BaseManager {
212
224
  private controllers;
213
225
  /** For each running scheduled run: write it as interrupted and let go of its slot. */
214
226
  private handoffs;
227
+ /** `ngdpbase.jobs.state.dir`, when job state is files: what a backup copies (#1876). */
228
+ private stateDir;
229
+ /**
230
+ * The job state was put back from a backup at this start (#1876): every
231
+ * job then starts from now — no slot since the backup is caught up, and a
232
+ * run unfinished in the backup is closed, never resumed or retried. Jobs
233
+ * are never re-run because of a restore.
234
+ */
235
+ private restoredAtStart;
236
+ /** Jobs whose restored state has been settled, since this start. */
237
+ private settledAfterRestore;
238
+ /** The runs found in the live file state before a restore replaced it (#1876). */
239
+ private runsReplacedAtStart;
215
240
  /**
216
241
  * @param options - for tests: a state provider in place of the configured
217
242
  * one, and the clock.
@@ -390,6 +415,45 @@ declare class BackgroundJobManager extends BaseManager {
390
415
  /** Whether the person who started a run still holds the job's permission — by policy, as at the time it is asked (P2). */
391
416
  private stillHolds;
392
417
  /** A run a restart cut off and that is not run again: recorded as failed, audited, and told to the admins. Never silent. */
418
+ /**
419
+ * A scheduled job's first look after its state was restored (#1876): a run
420
+ * the backup caught unfinished is closed as failed, never resumed or
421
+ * retried, and the next slot is the next one from now, so no slot between
422
+ * the backup and the restore is caught up. The restore's notification
423
+ * lists the runs that had happened since the backup.
424
+ */
425
+ private settleAfterRestore;
426
+ /**
427
+ * Job state in a backup (#1876). Files (`ngdpbase.jobs.state.dir`): the
428
+ * state, run history and checkpoints as `<stem>.jobs.tar` beside the
429
+ * document, without the slot locks, which belong to the server that holds
430
+ * them. In the database: nothing here — it is in the database backup.
431
+ */
432
+ backup(options?: ManagerBackupOptions): Promise<BackupData>;
433
+ /** File job state is staged for the next start with the rest of a site restore (#1876). Database job state comes back with the database. */
434
+ restore(backupData: BackupData, options?: ManagerRestoreOptions): Promise<void>;
435
+ restoreIsStaged(): boolean;
436
+ /**
437
+ * Put staged file job state in place, at start, before it is opened
438
+ * (#1876). The runs in the live state since the backup was taken are read
439
+ * first, before it is replaced, for the restore's notification.
440
+ */
441
+ private applyRestoreAtStart;
442
+ /**
443
+ * The job runs the live application database held, since the backup, when
444
+ * this start's restore replaced it (#1876): read from the copy DatabaseManager
445
+ * kept, which is the database as it was at the moment of the swap — so runs
446
+ * between staging and the restart are named too.
447
+ */
448
+ private readReplacedDatabaseRuns;
449
+ /** The runs in the file job state at `stateDir` started after `since`, read from disk. */
450
+ private readLiveRunsSince;
451
+ /** The runs of every registered job started after `since`, from the live state (#1876): read before a restore replaces it. */
452
+ runsSince(since: string): Promise<JobRunSince[]>;
453
+ /** The runs the live file state held before a restore replaced it at this start (#1876). */
454
+ getRunsReplacedAtStart(): JobRunSince[];
455
+ /** A job's display name, or its id when no job of that id is registered. */
456
+ displayNameOf(jobId: string): string;
393
457
  private reportCutOff;
394
458
  /**
395
459
  * Get the current state of a run by runId.
@@ -78,6 +78,13 @@ export interface ManagerRestoreOptions {
78
78
  replaceStaged?: boolean;
79
79
  /** The backup of the live data BackupManager took just before this restore (#1723, D6), by filename. */
80
80
  safetyBackup?: string;
81
+ /**
82
+ * The site restore manifest this restore is staged under (#1876). A
83
+ * manager whose restore is staged (`restoreIsStaged()`) stages its files
84
+ * beside its live ones and records its part there; the next start applies
85
+ * every part or none (`src/utils/siteRestore.ts`).
86
+ */
87
+ siteRestore?: string;
81
88
  }
82
89
  /**
83
90
  * Base class for all managers
@@ -341,6 +348,13 @@ declare abstract class BaseManager {
341
348
  * }
342
349
  */
343
350
  restore(backupData: BackupData, _options?: ManagerRestoreOptions): Promise<void>;
351
+ /**
352
+ * Whether this manager's `restore()` stages for the next start instead of
353
+ * writing into the running site (#1723, #1876). BackupManager stages every
354
+ * such manager in a site restore together, all or nothing, after a backup
355
+ * of the live data. The default is false: the restore writes live.
356
+ */
357
+ restoreIsStaged(): boolean;
344
358
  /**
345
359
  * Return a plain-text string suitable for use as MarqueePlugin banner text.
346
360
  *
@@ -299,6 +299,15 @@ class BaseManager {
299
299
  throw new Error(`${this.constructor.name}: No backup data provided for restore`);
300
300
  }
301
301
  }
302
+ /**
303
+ * Whether this manager's `restore()` stages for the next start instead of
304
+ * writing into the running site (#1723, #1876). BackupManager stages every
305
+ * such manager in a site restore together, all or nothing, after a backup
306
+ * of the live data. The default is false: the restore writes live.
307
+ */
308
+ restoreIsStaged() {
309
+ return false;
310
+ }
302
311
  /**
303
312
  * Return a plain-text string suitable for use as MarqueePlugin banner text.
304
313
  *
@@ -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 { PageComment } from '../types/Comment.js';
4
4
  import { type ActorContext } from '../context/ActorContext.js';
@@ -10,6 +10,15 @@ export default class CommentManager extends BaseManager {
10
10
  private readonly outsideCheck;
11
11
  constructor(engine: WikiEngine);
12
12
  initialize(): Promise<void>;
13
+ /**
14
+ * The public folder is in the backup (#1876), as `<stem>.comments.tar` beside
15
+ * the document: the public pages' comments. A vault page's are in its vault, which
16
+ * PageManager backs up with the vault, sealed as stored.
17
+ */
18
+ backup(options?: ManagerBackupOptions): Promise<BackupData>;
19
+ /** Staged for the next start with the rest of a site restore (#1876); the live folder is untouched. */
20
+ restore(backupData: BackupData, options?: ManagerRestoreOptions): Promise<void>;
21
+ restoreIsStaged(): boolean;
13
22
  isEnabled(): boolean;
14
23
  /** The public folder and the vaults, as configured now (#1839). */
15
24
  private roots;
@@ -1,27 +1,16 @@
1
1
  import BaseManager from './BaseManager.js';
2
2
  import type { WikiEngine } from '../types/WikiEngine.js';
3
+ import { type SqliteHandle } from '../providers/SqliteDatabaseProvider.js';
3
4
  import type { Migration } from '../providers/sqliteMigrations.js';
4
5
  import { type ActorContext } from '../context/ActorContext.js';
5
6
  import type { BackupData, ManagerBackupOptions, ManagerRestoreOptions } from './BaseManager.js';
6
7
  import { type AddonDatabaseDeclaration } from '../utils/addonDatabaseRules.js';
7
8
  import { type StagedRestoreManifest } from '../providers/sqliteBackup.js';
8
- /** The environment variable holding the SQLCipher key. Never a config key: config is readable from the admin screens. */
9
- export declare const DATABASE_KEY_ENV = "NGDPBASE_DATABASE_KEY";
10
- /**
11
- * The environment variable holding the key every database backup is encrypted
12
- * under (#1723). Its own secret, not the database key: the copy that travels
13
- * and the copy that stays should not fall to the same compromise. Never a
14
- * config key. Unset, no database is backed up, and admins are told.
15
- */
16
- export declare const BACKUP_KEY_ENV = "NGDPBASE_BACKUP_KEY";
9
+ import { DATABASE_KEY_ENV } from '../utils/databaseKey.js';
10
+ export { DATABASE_KEY_ENV };
11
+ export { BACKUP_KEY_ENV, backupKeyFingerprint } from '../utils/backupKey.js';
17
12
  /** How long a database backup or restore staging may run on its worker before it is stopped, when not configured. */
18
13
  export declare const DEFAULT_BACKUP_WORKER_TIMEOUT_MS = 3600000;
19
- /**
20
- * A short fingerprint of a backup key (#1723, D3): the first 16 hex digits of
21
- * an HMAC of a fixed label under the key. Recorded in each backup so a restore
22
- * under another key says so plainly; it never reveals the key.
23
- */
24
- export declare function backupKeyFingerprint(key: string): string;
25
14
  /** One database in a backup, as DatabaseManager's entry in the backup document lists it. */
26
15
  export interface DatabaseBackupEntry {
27
16
  /** `application`, or `addon-<owner>`. */
@@ -91,6 +80,10 @@ declare class DatabaseManager extends BaseManager {
91
80
  private readonly addonDatabases;
92
81
  /** What the start did with a staged restore (#1723), reported once the notifications exist. */
93
82
  private restoreAtStart;
83
+ /** The databases a staged restore put back at this start (#1876): kept after the report, for the managers whose data is in them. */
84
+ private restoredNow;
85
+ /** When the restored backup was taken, and where the replaced application database is kept, when a restore was applied at this start (#1876). */
86
+ private replaced;
94
87
  /** Why this manager is degraded, one entry per cause, so one cause clearing leaves the others. */
95
88
  private readonly faults;
96
89
  /** Why the configured application database could not be opened safely (#1892); null otherwise. */
@@ -108,8 +101,8 @@ declare class DatabaseManager extends BaseManager {
108
101
  getUnavailableReason(): string | null;
109
102
  /**
110
103
  * #1892 (operator D3 rule, 2026-10-18): the application database can't be
111
- * opened safely. The process stays up in maintenance mode through core's
112
- * survivable path (#1152): visitors see only the public message (#1883),
104
+ * opened safely. The process stays up in maintenance mode through the one
105
+ * door (`engine.maintenance`, #1906): visitors see only the site's maintenance message,
113
106
  * administrators can sign in and reach /admin, and `reportAtBoot()` sends
114
107
  * them the reason as a critical notification once notifications exist.
115
108
  */
@@ -181,6 +174,20 @@ declare class DatabaseManager extends BaseManager {
181
174
  * staged, and admins are told.
182
175
  */
183
176
  restore(backupData: BackupData, options?: ManagerRestoreOptions): Promise<void>;
177
+ /** Whether a staged restore put the databases back at this start (#1876): the job state in them is then the backup's. */
178
+ restoredAtStart(): boolean;
179
+ /**
180
+ * Read the application database as it was just before this start's restore
181
+ * replaced it (#1876): the kept `*.pre-restore` file, read-only, under the
182
+ * site's database key, which it was encrypted under. For the owner of data
183
+ * in it (BackgroundJobManager's job runs since the backup) to say what the
184
+ * restore took away. Null when no restore replaced it at this start.
185
+ */
186
+ readReplacedApplicationDatabase<T>(read: (db: SqliteHandle, backupTakenAt: string) => T): T | null;
187
+ /** The databases a staged restore put back at this start, by name (#1876). */
188
+ getRestoredAtStart(): string[];
189
+ /** Its restore is staged for the next start (#1723), and joins a site restore (#1876). */
190
+ restoreIsStaged(): boolean;
184
191
  /** The restore staged and waiting for the next start, or null. */
185
192
  getStagedRestore(): StagedRestoreManifest | null;
186
193
  /**
@@ -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() {