@jwilleke/ngdpbase-types 5.20.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 (62) 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 -8
  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 +71 -1
  7. package/lib/src/managers/BaseManager.d.ts +20 -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/ConfigurationManager.d.ts +16 -1
  11. package/lib/src/managers/DatabaseManager.d.ts +53 -16
  12. package/lib/src/managers/DatabaseManager.js +172 -38
  13. package/lib/src/managers/EmailManager.d.ts +42 -0
  14. package/lib/src/managers/FootnoteManager.d.ts +10 -1
  15. package/lib/src/managers/NotificationManager.d.ts +13 -0
  16. package/lib/src/managers/OrganizationManager.d.ts +40 -6
  17. package/lib/src/managers/PersonManager.d.ts +106 -9
  18. package/lib/src/managers/RoleManager.d.ts +19 -1
  19. package/lib/src/managers/UserManager.d.ts +15 -0
  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 +12 -0
  23. package/lib/src/providers/SqliteDatabaseProvider.js +50 -7
  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/utils/addonConfigLayer.d.ts +19 -0
  36. package/lib/src/utils/addonConfigLayer.js +45 -3
  37. package/lib/src/utils/addonPageSync.d.ts +24 -0
  38. package/lib/src/utils/administrators.d.ts +21 -0
  39. package/lib/src/utils/auditEventNames.generated.d.ts +4 -0
  40. package/lib/src/utils/auditEventNames.generated.js +4 -0
  41. package/lib/src/utils/backupCrypto.d.ts +28 -0
  42. package/lib/src/utils/backupKey.d.ts +23 -0
  43. package/lib/src/utils/backupKey.js +37 -0
  44. package/lib/src/utils/configPaths.d.ts +55 -0
  45. package/lib/src/utils/configPaths.js +133 -0
  46. package/lib/src/utils/contactAccess.d.ts +58 -0
  47. package/lib/src/utils/databaseKey.d.ts +7 -0
  48. package/lib/src/utils/databaseKey.js +7 -0
  49. package/lib/src/utils/directoryBackup.d.ts +78 -0
  50. package/lib/src/utils/emailForLog.d.ts +20 -0
  51. package/lib/src/utils/maintenanceState.d.ts +153 -0
  52. package/lib/src/utils/pluginFormatters.d.ts +6 -1
  53. package/lib/src/utils/pluginFormatters.js +5 -2
  54. package/lib/src/utils/privateStoreTakeout.d.ts +2 -2
  55. package/lib/src/utils/redactSecrets.js +31 -11
  56. package/lib/src/utils/sensitiveValues.d.ts +49 -3
  57. package/lib/src/utils/sensitiveValues.js +139 -11
  58. package/lib/src/utils/siteRestore.d.ts +110 -0
  59. package/lib/src/utils/siteRestore.js +309 -0
  60. package/lib/src/utils/treeArchive.d.ts +44 -0
  61. package/lib/src/utils/username.d.ts +7 -1
  62. package/package.json +1 -1
@@ -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
  /**
@@ -461,6 +461,14 @@ declare class UserManager extends BaseManager {
461
461
  limit?: number;
462
462
  activeOnly?: boolean;
463
463
  }, ctx: ActorContext): Promise<Omit<User, 'password'>[]>;
464
+ /**
465
+ * The account lifecycle reaches PersonManager's door under a system context
466
+ * (#1930): keeping an account's person in step with the account is core's
467
+ * own bookkeeping, not the caller's contact management, so it never depends
468
+ * on the caller holding `contact-manage`. The reason names the account and
469
+ * who changed it, so the record says why.
470
+ */
471
+ private personSyncContext;
464
472
  /**
465
473
  * Persist a Person record paired with a newly-created User. The install's
466
474
  * anchor org (when configured) is referenced via `memberOf`; without it
@@ -468,6 +476,13 @@ declare class UserManager extends BaseManager {
468
476
  * thrown — auth must still succeed if Person storage is degraded.
469
477
  */
470
478
  private syncPersonOnCreate;
479
+ /**
480
+ * At sign-up, offer the new account a person with no account who has the
481
+ * same email (#1930): PersonManager mails an "Is this you?" link, and joins
482
+ * them only when the account confirms. Skipped when email is off. Never
483
+ * fails the account write.
484
+ */
485
+ private offerContactLink;
471
486
  private syncPersonOnUpdate;
472
487
  private syncPersonOnDelete;
473
488
  createSession(username: string, additionalData?: Record<string, unknown>): Promise<string>;
@@ -3,6 +3,8 @@ import type { StoreFileEntry, StoreFileLocation } from '../types/Provider.js';
3
3
  import type { WikiEngine } from '../types/WikiEngine.js';
4
4
  import { ProviderInfo } from './BasePageProvider.js';
5
5
  import BaseProvider from './BaseProvider.js';
6
+ import type { AttachmentFilesBackupData, AttachmentFilesBackupOutcome } from './attachmentFilesBackup.js';
7
+ import type { PartOutcome } from '../utils/siteRestore.js';
6
8
  /**
7
9
  * File information for attachment uploads
8
10
  */
@@ -187,6 +189,18 @@ declare abstract class BaseAttachmentProvider extends BaseProvider implements At
187
189
  * @returns {Promise<void>}
188
190
  */
189
191
  abstract restore(backupData: Record<string, unknown>): Promise<void>;
192
+ /**
193
+ * Write the attachment files themselves beside the backup document (#1876),
194
+ * streamed, bytes as stored. A provider without it can't be backed up whole,
195
+ * and AttachmentManager's backup says so.
196
+ */
197
+ backupFiles?(directory: string, stem: string): Promise<AttachmentFilesBackupOutcome>;
198
+ /** Stage the files and metadata from a backup for the next start, as part of a site restore (#1876). */
199
+ stageFilesRestore?(providerBackup: Record<string, unknown>, files: AttachmentFilesBackupData, directory: string, siteRestore: string): Promise<{
200
+ skippedVaults: string[];
201
+ }>;
202
+ /** What the start did with a staged restore of the files (#1876), once; null when there was none. */
203
+ takeRestoreAtStart?(): PartOutcome | null;
190
204
  /**
191
205
  * Shutdown the provider (cleanup resources)
192
206
  * @returns {Promise<void>}
@@ -5,6 +5,8 @@ import type { AssetProvider, AssetRecord, AssetQuery, AssetPage, AssetInput, Ass
5
5
  import type { CreativeWork } from '../types/Schema.js';
6
6
  import type { ActorContext } from '../context/ActorContext.js';
7
7
  import type { WikiEngine } from '../types/WikiEngine.js';
8
+ import { type PartOutcome } from '../utils/siteRestore.js';
9
+ import { type AttachmentFilesBackupData, type AttachmentFilesBackupOutcome } from './attachmentFilesBackup.js';
8
10
  /**
9
11
  * Schema.org Person metadata
10
12
  */
@@ -122,6 +124,8 @@ export declare function safeStoredExtension(originalName: string, mimeType?: str
122
124
  */
123
125
  declare class BasicAttachmentProvider extends BaseAttachmentProvider implements AssetProvider {
124
126
  private storageDirectory;
127
+ /** What the start did with a staged restore of the files (#1876). */
128
+ private restoreAtStart;
125
129
  /** Pre-#1386 attachments/{legacyprivateroot}/{user}/ — migrate-FROM and read fallback only. */
126
130
  private legacyPrivateDir;
127
131
  private pagesDirectory;
@@ -149,8 +153,15 @@ declare class BasicAttachmentProvider extends BaseAttachmentProvider implements
149
153
  /**
150
154
  * Initialize the provider
151
155
  * All configuration access via ConfigurationManager
156
+ *
157
+ * @param options.readOnly - Open for reading only (#1852): load the index and
158
+ * change nothing on disk. No staged restore is applied, no folder is
159
+ * created, and neither start-up migration (legacy private files, stale
160
+ * paths) runs. For an operator report such as the files dry run.
152
161
  */
153
- initialize(): Promise<void>;
162
+ initialize(options?: {
163
+ readOnly?: boolean;
164
+ }): Promise<void>;
154
165
  /**
155
166
  * Verify that the attachment storage directory is accessible.
156
167
  *
@@ -210,7 +221,7 @@ declare class BasicAttachmentProvider extends BaseAttachmentProvider implements
210
221
  * @param fileBuffer - File data
211
222
  * @param fileInfo - { originalName, mimeType, size }
212
223
  * @param metadata - Schema.org CreativeWork metadata
213
- * @param user - User object { name, email }
224
+ * @param user - The uploader: `username` is recorded as author and editor (#1926)
214
225
  * @param options - Privacy-aware storage options
215
226
  * @returns Complete attachment metadata
216
227
  * @private
@@ -435,6 +446,19 @@ declare class BasicAttachmentProvider extends BaseAttachmentProvider implements
435
446
  * @returns Backup data
436
447
  */
437
448
  backup(): Promise<BackupData>;
449
+ /** Where the attachment files are, for their backup and staged restore (#1876). */
450
+ private filePlaces;
451
+ /** The files themselves, beside the backup document (#1876): the shared pool and every vault's, as stored. */
452
+ backupFiles(directory: string, stem: string): Promise<AttachmentFilesBackupOutcome>;
453
+ /**
454
+ * Stage the files and the shared metadata from a backup for the next start
455
+ * (#1876). The metadata is the document's; the live files and metadata are
456
+ * not touched.
457
+ */
458
+ stageFilesRestore(providerBackup: Record<string, unknown>, files: AttachmentFilesBackupData, directory: string, siteRestore: string): Promise<{
459
+ skippedVaults: string[];
460
+ }>;
461
+ takeRestoreAtStart(): PartOutcome | null;
438
462
  /**
439
463
  * Restore provider data from backup
440
464
  * @param backupData - Backup data
@@ -35,6 +35,16 @@ declare class SqliteDatabaseProvider extends BaseDatabaseProvider<SqliteHandle>
35
35
  constructor(file: string, key: string, ledger: Migration[], ledgerTable?: string, options?: {
36
36
  busyTimeoutMs?: number;
37
37
  });
38
+ private static unlock;
39
+ /**
40
+ * Read a database file once, read-only, under `key`, and close it (#1876):
41
+ * nothing is migrated or written. Used for the copy a restore kept as
42
+ * `*.pre-restore`, to read what the site had recorded just before the
43
+ * restore replaced it.
44
+ */
45
+ static readOnce<T>(file: string, key: string, read: (db: SqliteHandle) => T): T;
46
+ /** Read an existing file read-only: the key opens it and it passes the quick check, or this throws. */
47
+ private static probe;
38
48
  /**
39
49
  * Run a further migration ledger on this connection, recorded in its own
40
50
  * table: an add-on that depends on the add-on owning this database brings
@@ -49,6 +59,8 @@ declare class SqliteDatabaseProvider extends BaseDatabaseProvider<SqliteHandle>
49
59
  encrypted: boolean;
50
60
  };
51
61
  integrityOk(): boolean;
62
+ /** SQLite's quick check; throws what the driver throws (a wrong key: "file is not a database"). */
63
+ private quickCheckOk;
52
64
  getDurability(): ProviderDurability;
53
65
  close(): void;
54
66
  }
@@ -36,14 +36,17 @@ class SqliteDatabaseProvider extends BaseDatabaseProvider {
36
36
  super();
37
37
  this.file = file;
38
38
  refuseNetworkFilesystem(file);
39
+ // #1892: an existing file is first read through a read-only connection.
40
+ // A wrong or missing key, or a damaged file, fails there, and nothing is
41
+ // written: a read-write connection that fails to read still checkpoints
42
+ // the WAL into the file on close, or deletes a WAL it can't parse. Only
43
+ // the -shm index, which SQLite rebuilds from the WAL, may be rewritten.
44
+ if (existsSync(file))
45
+ SqliteDatabaseProvider.probe(file, key);
39
46
  this.db = new Database(file, { timeout: options.busyTimeoutMs ?? DEFAULT_BUSY_TIMEOUT_MS });
40
47
  this.encrypted = key !== '';
41
48
  try {
42
- if (this.encrypted) {
43
- this.db.pragma("cipher='sqlcipher'");
44
- this.db.pragma(`key='${key.replace(/'/g, "''")}'`);
45
- }
46
- // The first read: a wrong key fails here, before anything is written.
49
+ SqliteDatabaseProvider.unlock(this.db, key);
47
50
  this.db.pragma('journal_mode = WAL');
48
51
  this.db.pragma('synchronous = FULL');
49
52
  this.migrations = runMigrations(this.db, ledger, ledgerTable);
@@ -53,6 +56,42 @@ class SqliteDatabaseProvider extends BaseDatabaseProvider {
53
56
  throw err;
54
57
  }
55
58
  }
59
+ static unlock(db, key) {
60
+ if (!key)
61
+ return;
62
+ db.pragma("cipher='sqlcipher'");
63
+ db.pragma(`key='${key.replace(/'/g, "''")}'`);
64
+ }
65
+ /**
66
+ * Read a database file once, read-only, under `key`, and close it (#1876):
67
+ * nothing is migrated or written. Used for the copy a restore kept as
68
+ * `*.pre-restore`, to read what the site had recorded just before the
69
+ * restore replaced it.
70
+ */
71
+ static readOnce(file, key, read) {
72
+ const db = new Database(file, { readonly: true, fileMustExist: true });
73
+ try {
74
+ SqliteDatabaseProvider.unlock(db, key);
75
+ return read(db);
76
+ }
77
+ finally {
78
+ db.close();
79
+ }
80
+ }
81
+ /** Read an existing file read-only: the key opens it and it passes the quick check, or this throws. */
82
+ static probe(file, key) {
83
+ const db = new Database(file, { readonly: true, fileMustExist: true });
84
+ try {
85
+ SqliteDatabaseProvider.unlock(db, key);
86
+ const rows = db.pragma('quick_check');
87
+ if (String(rows[0]?.quick_check ?? '').toLowerCase() !== 'ok') {
88
+ throw new Error(`The database ${file} failed its integrity check — nothing was migrated or written`);
89
+ }
90
+ }
91
+ finally {
92
+ db.close();
93
+ }
94
+ }
56
95
  /**
57
96
  * Run a further migration ledger on this connection, recorded in its own
58
97
  * table: an add-on that depends on the add-on owning this database brings
@@ -70,13 +109,17 @@ class SqliteDatabaseProvider extends BaseDatabaseProvider {
70
109
  }
71
110
  integrityOk() {
72
111
  try {
73
- const rows = this.db.pragma('quick_check');
74
- return String(rows[0]?.quick_check ?? '').toLowerCase() === 'ok';
112
+ return this.quickCheckOk();
75
113
  }
76
114
  catch {
77
115
  return false;
78
116
  }
79
117
  }
118
+ /** SQLite's quick check; throws what the driver throws (a wrong key: "file is not a database"). */
119
+ quickCheckOk() {
120
+ const rows = this.db.pragma('quick_check');
121
+ return String(rows[0]?.quick_check ?? '').toLowerCase() === 'ok';
122
+ }
80
123
  getDurability() {
81
124
  return { bufferedForMs: 0, bufferedRecords: 0, fsync: true };
82
125
  }
@@ -39,3 +39,13 @@ declare class SqliteJobStateProvider implements JobStateProvider {
39
39
  releaseLock(jobId: string, slot: string, owner: string): Promise<void>;
40
40
  }
41
41
  export default SqliteJobStateProvider;
42
+ /**
43
+ * Every job's runs started after `since`, from a database handle (#1876): the
44
+ * application database a restore kept as `*.pre-restore`, read just after it
45
+ * was replaced, so the restore's notice names the runs the restored job
46
+ * history no longer has. The same table `listRuns` reads.
47
+ */
48
+ export declare function runsStartedAfter(db: Handle, since: string): Array<{
49
+ jobId: string;
50
+ run: JobRunRecord;
51
+ }>;
@@ -0,0 +1,52 @@
1
+ import { type PartOutcome } from '../utils/siteRestore.js';
2
+ import { type PrivateStoreLayout } from '../utils/privateStorePath.js';
3
+ /** Where the attachment files live on this site. */
4
+ export interface AttachmentFilePlaces {
5
+ /** The shared pool folder. */
6
+ pool: string;
7
+ /** The shared pool's metadata file (inside the pool by default). */
8
+ metadataFile: string;
9
+ /** The pages folder, which holds the vaults. */
10
+ pagesDirectory: string | null;
11
+ layout: PrivateStoreLayout;
12
+ }
13
+ /** The files part of AttachmentManager's entry in the backup document. */
14
+ export interface AttachmentFilesBackupData {
15
+ format: 1;
16
+ pool: {
17
+ archive: string;
18
+ files: number;
19
+ bytes: number;
20
+ };
21
+ vaults: {
22
+ archive: string;
23
+ files: number;
24
+ bytes: number;
25
+ stores: string[];
26
+ };
27
+ /** `backupKeyFingerprint()` of the key both archives are encrypted under; absent when they are not. */
28
+ keyFingerprint?: string;
29
+ }
30
+ export interface AttachmentFilesBackupOutcome {
31
+ data: AttachmentFilesBackupData;
32
+ /** Parts left out (a symbolic link, a vault folder whose name can't be a path segment), each named. */
33
+ warnings: string[];
34
+ }
35
+ /** Write the attachment files beside the backup document: the pool, then every vault's. Never base64, never whole in memory. */
36
+ export declare function backupAttachmentFiles(places: AttachmentFilePlaces, directory: string, stem: string): Promise<AttachmentFilesBackupOutcome>;
37
+ /**
38
+ * Stage the attachment files from a backup for the next start, and record
39
+ * the part in the site restore. All or nothing: on any failure every staged
40
+ * path is removed and nothing is recorded.
41
+ *
42
+ * @returns the vaults the backup holds that this site doesn't have (not staged).
43
+ */
44
+ export declare function stageAttachmentFiles(places: AttachmentFilePlaces, directory: string, data: AttachmentFilesBackupData, metadataJson: string, siteRestore: string): Promise<{
45
+ skippedVaults: string[];
46
+ }>;
47
+ /**
48
+ * Put staged attachment files in place, at start, before the metadata is
49
+ * read: only the pool, its metadata file and vault attachment paths may be
50
+ * replaced, whatever the manifest says.
51
+ */
52
+ export declare function applyAttachmentFilesAtStart(places: AttachmentFilePlaces, siteRestore: string): PartOutcome | null;
@@ -126,3 +126,9 @@ export interface ApplyOutcome {
126
126
  * be replaced, so a tampered manifest cannot rename an arbitrary file.
127
127
  */
128
128
  export declare function applyStagedRestore(applicationFile: string, allowed: (live: string) => boolean): ApplyOutcome | null;
129
+ /**
130
+ * Set a staged restore aside without applying any of it (#1876): every
131
+ * `*.db.staged` beside the application database and the manifest become
132
+ * `*.incomplete`. Used when the site restore it belongs to is not applied.
133
+ */
134
+ export declare function setAsideStagedRestore(applicationFile: string, reason: string): ApplyOutcome;
@@ -307,12 +307,7 @@ export function applyStagedRestore(applicationFile, allowed) {
307
307
  move(f, `${f}.incomplete`);
308
308
  return { applied: [], refused: `staged database files with no restore manifest (an interrupted staging): ${leftovers.map((f) => path.basename(f)).join(', ')} — set aside as *.incomplete` };
309
309
  }
310
- const setAside = (reason) => {
311
- for (const f of stray())
312
- move(f, `${f}.incomplete`);
313
- move(manifestFile, `${manifestFile}.incomplete`);
314
- return { applied: [], refused: reason };
315
- };
310
+ const setAside = (reason) => setAsideStagedRestore(applicationFile, reason);
316
311
  let manifest;
317
312
  try {
318
313
  manifest = JSON.parse(readFileSync(manifestFile, 'utf8'));
@@ -351,6 +346,21 @@ export function applyStagedRestore(applicationFile, allowed) {
351
346
  unlinkSync(manifestFile);
352
347
  return { applied };
353
348
  }
349
+ /**
350
+ * Set a staged restore aside without applying any of it (#1876): every
351
+ * `*.db.staged` beside the application database and the manifest become
352
+ * `*.incomplete`. Used when the site restore it belongs to is not applied.
353
+ */
354
+ export function setAsideStagedRestore(applicationFile, reason) {
355
+ const manifestFile = manifestPath(applicationFile);
356
+ const dir = path.dirname(applicationFile);
357
+ const staged = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.db.staged')).map((f) => path.join(dir, f)) : [];
358
+ for (const f of staged)
359
+ move(f, `${f}.incomplete`);
360
+ if (existsSync(manifestFile))
361
+ move(manifestFile, `${manifestFile}.incomplete`);
362
+ return { applied: [], refused: reason };
363
+ }
354
364
  /** A rename; a copy and unlink when the two paths are on different devices. */
355
365
  function move(from, to) {
356
366
  try {
@@ -22,6 +22,10 @@ export type CorePermission =
22
22
  | 'comment-create'
23
23
  /** Change any configuration setting, and download a backup (#1525). Admin only by default; asks for a fresh sign-in when listed in ngdpbase.auth.step-up */
24
24
  | 'config-manage'
25
+ /** Create, edit, delete and link contacts: persons (with or without an account) and organizations, and a person's affiliations, through PersonManager and OrganizationManager (#1930). Reading them is contact-read. Admins by default; an add-on grants it to its own roles in its own policy */
26
+ | 'contact-manage'
27
+ /** List, search and view contacts: persons (with or without an account) and organizations, and their affiliations, through PersonManager and OrganizationManager, and the /admin/contacts and /admin/organizations views (#1930). Admins and demo admins by default; an add-on grants it to its own roles in its own policy */
28
+ | 'contact-read'
25
29
  /** Browse and view the media library (not delegated to anonymous visitors) */
26
30
  | 'media-read'
27
31
  /** Create new pages */
@@ -6,6 +6,14 @@
6
6
  * while AttachmentManager / MediaManager remain thin wrappers.
7
7
  */
8
8
  import type { ActorContext } from '../context/ActorContext.js';
9
+ /**
10
+ * What an attachment record says when it was stored with no uploader, in the
11
+ * pool's `author.name`, and what `uploadedBy` shows for a record with none.
12
+ * Only records written before #1926 carry it: a new upload names its
13
+ * uploader's username or is refused. Not a user; the files layer reads it as
14
+ * "no owner" (#1852).
15
+ */
16
+ export declare const UNKNOWN_UPLOADER = "Unknown";
9
17
  /**
10
18
  * Capabilities that an AssetProvider may declare.
11
19
  * Used to advertise which optional operations the provider supports.
@@ -207,6 +215,8 @@ export interface AssetRecord {
207
215
  author?: string;
208
216
  /** ISO 8601 timestamp of the last metadata or file modification — schema.org/dateModified */
209
217
  dateModified?: string;
218
+ /** Username of whoever last changed it, when the provider records one — schema.org/editor */
219
+ editor?: string;
210
220
  /** Licensing and rights information */
211
221
  usageRights?: AssetUsageRights;
212
222
  /** Download counts and access statistics */
@@ -116,6 +116,11 @@ export interface WikiConfig {
116
116
  */
117
117
  'ngdpbase.auth.throttle.max-lock-minutes': number;
118
118
  'ngdpbase.mail.rate-limit.enabled': boolean;
119
+ /**
120
+ * Outbound email failures: one administrator notice per this many minutes,
121
+ * counting every failure inside it (#1916). Default: 60.
122
+ */
123
+ 'ngdpbase.mail.failure-notice.window-minutes': number;
119
124
  /**
120
125
  * Anti-spam: max submissions per IP per `window-minutes` window before
121
126
  * the 429 trips (#670 Phase E). Default: 5.
@@ -305,6 +310,12 @@ export interface ApplicationOrganizationConfig {
305
310
  'ngdpbase.application.persons.provider.default': string;
306
311
  /** Active Person provider class (lowercased; falls back to .default) */
307
312
  'ngdpbase.application.persons.provider': string;
313
+ /** #1930: the most results `PersonManager.search` returns when the caller gives no limit */
314
+ 'ngdpbase.application.persons.search.limit': number;
315
+ /** #1930: minutes the emailed "Is this you?" sign-up link stays usable */
316
+ 'ngdpbase.application.persons.link-offer.ttl-minutes': number;
317
+ /** #1930: the most results `OrganizationManager.search` returns when the caller gives no limit */
318
+ 'ngdpbase.application.organization.search.limit': number;
308
319
  /** Storage directory for OrganizationRole JSON files (one per (org, namedPosition) pair) */
309
320
  'ngdpbase.application.roles.storagedir': string;
310
321
  /** Default Role provider class (lowercased) */
@@ -71,6 +71,13 @@ export interface Organization {
71
71
  /** Allow extension fields (additionalProperty, etc.) */
72
72
  [key: string]: unknown;
73
73
  }
74
+ /**
75
+ * What a caller gives `OrganizationManager.create` (#1930): the fields, with
76
+ * `@id` optional (it defaults to the `url`, else a new `urn:uuid:`).
77
+ */
78
+ export type OrganizationInput = Partial<Organization> & {
79
+ name: string;
80
+ };
74
81
  /**
75
82
  * Patch shape for update operations.
76
83
  * `@context`/`@type` are immutable; `@id` cannot be changed via update.