@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
@@ -12,6 +12,14 @@
12
12
  * Implements the MailProvider interface so it can be passed directly to
13
13
  * MagicLinkAuthProvider without any changes to that class.
14
14
  *
15
+ * A failed send is never silent (#1916). Every sender goes through `send()`,
16
+ * so it is reported here, once for all of them: logged (provider, recipient
17
+ * domain, subject, error — never the body or the full address), the manager
18
+ * shows degraded until the next send succeeds, and administrators get one
19
+ * notification per `ngdpbase.mail.failure-notice.window-minutes` carrying a
20
+ * running count, not one per email. `send()` still rejects, so a caller with
21
+ * a person waiting can tell them.
22
+ *
15
23
  * @see docs/admin/email-setup.md for operator setup instructions
16
24
  * @see {@link https://github.com/jwilleke/ngdpbase/issues/456}
17
25
  */
@@ -19,21 +27,55 @@ import BaseManager from './BaseManager.js';
19
27
  import type { BackupData } from './BaseManager.js';
20
28
  import type { WikiEngine } from '../types/WikiEngine.js';
21
29
  import type { MailMessage, MailProvider } from '../mail/MailProvider.js';
30
+ /** The degraded reason while sends fail; cleared by the next success. */
31
+ export declare const SEND_FAILING_REASON = "outbound email is failing \u2014 see the server log and the administrator notification";
32
+ /** Title of the administrator notice. */
33
+ export declare const SEND_FAILING_TITLE = "Email could not be sent";
22
34
  declare class EmailManager extends BaseManager implements MailProvider {
23
35
  private provider;
24
36
  private providerName;
25
37
  private from;
26
38
  private enabled;
39
+ private noticeWindowMs;
40
+ private notice;
41
+ private failuresSinceSuccess;
27
42
  constructor(engine: WikiEngine);
28
43
  initialize(config?: Record<string, unknown>): Promise<void>;
29
44
  /**
30
45
  * Send an email. Injects the configured from address if the message omits it.
46
+ *
47
+ * A failure is reported here (#1916) and then rethrown, so a caller with a
48
+ * person waiting can tell them it was not sent.
31
49
  */
32
50
  send(message: MailMessage): Promise<void>;
33
51
  /**
34
52
  * Convenience wrapper — build and send a message in one call.
35
53
  */
36
54
  sendTo(to: string, subject: string, text: string, html?: string): Promise<void>;
55
+ /**
56
+ * Send without waiting, for a sender nobody is waiting on (a form's copy, a
57
+ * reservation notice). Never rejects: a failure is already logged and
58
+ * reported to administrators by `send()` (#1916), so there is nothing left
59
+ * for the caller to do with it.
60
+ */
61
+ sendInBackground(to: string, subject: string, text: string, html?: string): void;
62
+ /** Whether sends are failing right now: degraded by a failed send, not yet cleared by a success. */
63
+ isDeliveryFailing(): boolean;
64
+ private reportFailure;
65
+ private reportSuccess;
66
+ /**
67
+ * One notice per window, with a running count (#1916). A failure inside an
68
+ * open window updates that notice; the first failure after it opens a new
69
+ * one. The notice is created with `escalate: false`, so it is never emailed:
70
+ * that send would fail and report another failure.
71
+ *
72
+ * Never throws — it runs off the failed send, and a notice that cannot be
73
+ * written is already covered by the error log above.
74
+ */
75
+ private noticeAdministrators;
76
+ private createNotice;
77
+ private noticeMessage;
78
+ private notifications;
37
79
  /** Name of the active provider: 'console' | 'smtp' */
38
80
  getProviderName(): string;
39
81
  /** Effective from address (smtp.from or mail.from) */
@@ -1,4 +1,4 @@
1
- import BaseManager from './BaseManager.js';
1
+ import BaseManager, { type BackupData, type ManagerBackupOptions, type ManagerRestoreOptions } from './BaseManager.js';
2
2
  import type { WikiEngine } from '../types/WikiEngine.js';
3
3
  import { type ActorContext } from '../context/ActorContext.js';
4
4
  import { type VaultPageNotesOutside, type PageRef } from '../utils/pageNotes.js';
@@ -17,6 +17,15 @@ export default class FootnoteManager extends BaseManager {
17
17
  private readonly outsideCheck;
18
18
  constructor(engine: WikiEngine);
19
19
  initialize(): Promise<void>;
20
+ /**
21
+ * The public folder is in the backup (#1876), as `<stem>.footnotes.tar` beside
22
+ * the document: the public pages' footnotes. A vault page's are in its vault, which
23
+ * PageManager backs up with the vault, sealed as stored.
24
+ */
25
+ backup(options?: ManagerBackupOptions): Promise<BackupData>;
26
+ /** Staged for the next start with the rest of a site restore (#1876); the live folder is untouched. */
27
+ restore(backupData: BackupData, options?: ManagerRestoreOptions): Promise<void>;
28
+ restoreIsStaged(): boolean;
20
29
  isEnabled(): boolean;
21
30
  /** The public folder and the vaults, as configured now (#1839). */
22
31
  private roots;
@@ -25,6 +25,12 @@ export interface NotificationInput {
25
25
  level?: 'info' | 'warning' | 'error' | 'success';
26
26
  targetUsers?: string[];
27
27
  expiresAt?: Date | null;
28
+ /**
29
+ * False: never email this notification, whatever the escalation settings
30
+ * (#1916). A notice that email is failing must not itself be emailed — the
31
+ * send would fail and report another failure. Not stored.
32
+ */
33
+ escalate?: boolean;
28
34
  }
29
35
  /**
30
36
  * Notification statistics
@@ -102,6 +108,13 @@ declare class NotificationManager extends BaseManager {
102
108
  * @returns {Promise<string>} Notification ID
103
109
  */
104
110
  createNotification(notification: NotificationInput): Promise<string>;
111
+ /**
112
+ * Replace an active notification's message in place (#1916) — a running
113
+ * count on one notice instead of a new notice per event. Never escalates:
114
+ * the notification was already escalated, or not, when it was created.
115
+ * @returns false when there is no such notification
116
+ */
117
+ updateNotificationMessage(notificationId: string, message: string): Promise<boolean>;
105
118
  /**
106
119
  * Add a notification (alias for createNotification for backward compatibility)
107
120
  * @param {NotificationInput} notification - Notification object
@@ -1,7 +1,9 @@
1
- import BaseManager, { type ManagerStats } from './BaseManager.js';
1
+ import BaseManager, { type BackupData, type ManagerBackupOptions, type ManagerRestoreOptions, type ManagerStats } from './BaseManager.js';
2
2
  import type { WikiEngine } from '../types/WikiEngine.js';
3
- import type { Organization, OrganizationUpdate } from '../types/Organization.js';
3
+ import type { Organization, OrganizationInput, OrganizationUpdate } from '../types/Organization.js';
4
4
  import type { OrganizationProvider } from '../types/OrganizationProvider.js';
5
+ import type { Person } from '../types/Person.js';
6
+ import type { ActorContext } from '../context/ActorContext.js';
5
7
  /**
6
8
  * Subset of install-form data this manager needs to seed the anchor org.
7
9
  * Kept narrow so InstallService and tests can pass plain objects without
@@ -34,6 +36,8 @@ declare class OrganizationManager extends BaseManager {
34
36
  readonly description = "Canonical Organization records (#617)";
35
37
  private provider;
36
38
  private providerClass?;
39
+ /** `ngdpbase.application.organization.storagedir`, resolved at start: what a backup copies (#1876). */
40
+ private storageDir;
37
41
  private installOrgCache;
38
42
  private byIdCache;
39
43
  private byFileCache;
@@ -41,6 +45,11 @@ declare class OrganizationManager extends BaseManager {
41
45
  constructor(engine: WikiEngine);
42
46
  initialize(config?: Record<string, unknown>): Promise<void>;
43
47
  /** Provider accessor, mainly for tests. */
48
+ /** Organization records are in the backup (#1876), as `<stem>.organizations.tar` beside the document. */
49
+ backup(options?: ManagerBackupOptions): Promise<BackupData>;
50
+ /** Staged for the next start with the rest of a site restore (#1876); the live records are untouched. */
51
+ restore(backupData: BackupData, options?: ManagerRestoreOptions): Promise<void>;
52
+ restoreIsStaged(): boolean;
44
53
  getProvider(): OrganizationProvider | null;
45
54
  /**
46
55
  * Create the anchor organization from the instance's own identity (#1027).
@@ -81,10 +90,35 @@ declare class OrganizationManager extends BaseManager {
81
90
  getByFile(filename: string): Promise<Organization | null>;
82
91
  /** #1006: how many organizations this instance holds. Count only. */
83
92
  getManagerStats(): Promise<ManagerStats>;
84
- list(): Promise<Organization[]>;
85
- create(org: Organization, filename?: string): Promise<Organization>;
86
- update(id: string, patch: OrganizationUpdate): Promise<Organization | null>;
87
- delete(id: string): Promise<boolean>;
93
+ /** Every organization, for a caller who may read contacts (`contact-read`). */
94
+ list(ctx: ActorContext): Promise<Organization[]>;
95
+ /** One organization by `@id`, for a caller who may read contacts (`contact-read`). */
96
+ get(ctx: ActorContext, id: string): Promise<Organization | null>;
97
+ /**
98
+ * Organizations whose `name`, `legalName`, `email` or a contact point's
99
+ * email contains `query`, ignoring case; an empty query matches all. At
100
+ * most `limit` (`ngdpbase.application.organization.search.limit` by default).
101
+ */
102
+ search(ctx: ActorContext, query: string, options?: {
103
+ limit?: number;
104
+ }): Promise<Organization[]>;
105
+ /**
106
+ * The people affiliated with an organization, read from each person's
107
+ * `affiliation` (#1930): the link is stored once, on the person. Each comes
108
+ * with the role they hold there, when one is given.
109
+ */
110
+ people(ctx: ActorContext, organizationId: string): Promise<Array<{
111
+ person: Person;
112
+ role?: string;
113
+ }>>;
114
+ /**
115
+ * Create an organization. `name` is required; `@id` defaults to the `url`,
116
+ * else a new `urn:uuid:`. `filename` is for the install anchor (seeding);
117
+ * other records take one from the url or name.
118
+ */
119
+ create(ctx: ActorContext, org: OrganizationInput, filename?: string): Promise<Organization>;
120
+ update(ctx: ActorContext, id: string, patch: OrganizationUpdate): Promise<Organization | null>;
121
+ delete(ctx: ActorContext, id: string): Promise<boolean>;
88
122
  /**
89
123
  * Clear all caches. Called on every write — the simplest correct
90
124
  * invalidation. Per-key invalidation can come later if needed.
@@ -1,12 +1,37 @@
1
- import BaseManager, { type ManagerStats } from './BaseManager.js';
1
+ import BaseManager, { type BackupData, type ManagerBackupOptions, type ManagerRestoreOptions, type ManagerStats } from './BaseManager.js';
2
2
  import type { WikiEngine } from '../types/WikiEngine.js';
3
- import type { Person, PersonUpdate } from '../types/Person.js';
3
+ import type { Person, PersonInput, PersonUpdate } from '../types/Person.js';
4
4
  import type { PersonProvider } from '../types/PersonProvider.js';
5
+ import { type ActorContext } from '../context/ActorContext.js';
6
+ /** What the confirmation page shows: the person the link would join. */
7
+ export interface LinkOfferDetails {
8
+ personName: string | null;
9
+ email: string;
10
+ }
11
+ /** The outcome of offering a sign-up link (#1930). */
12
+ export type LinkOfferOutcome = 'sent' | 'no-match' | 'several' | 'unavailable' | 'failed';
5
13
  /**
6
- * PersonManager — canonical core record for persons (#617).
14
+ * PersonManager — canonical core record for persons (#617), and the door for
15
+ * contacts (#1930).
16
+ *
17
+ * A person may have an account or not. Each account has exactly one person
18
+ * (`identifier` = the username); a person with no account has the identifier
19
+ * `person:<uuid>`, which no username may take. UserManager calls the door on
20
+ * the account lifecycle under a system context.
21
+ *
22
+ * The door: `create`, `update`, `delete`, `get`, `list`, `search` and
23
+ * `linkAccount` take the caller's context first and ask the PDP: `contact-read`
24
+ * to list, search or get; `contact-manage` to change (`linkAccount` also `user-edit`). Each change is recorded as
25
+ * the `contact-manage` audit event. A refusal throws `ContactRefusedError`.
26
+ * `getById` / `getByIdentifier` stay as context-free lookups for core's own
27
+ * hot paths (RoleManager resolves roles through them on every request).
28
+ *
29
+ * A later sign-up joins a person with no account in one of two ways: the new
30
+ * account confirms an emailed link (`offerSignUpLink` → `confirmSignUpLink`),
31
+ * or an administrator links them (`linkAccount`). Either way the existing
32
+ * person keeps its `@id` and takes the account; the person made for the new
33
+ * account is folded into it and removed, so there is never a second person.
7
34
  *
8
- * Each User has at most one paired Person record. UserManager calls
9
- * `create`/`update`/`delete` here on the matching user lifecycle events.
10
35
  * `getByUserIdentifier(username)` remains as a lazy-migration entry point
11
36
  * for legacy users that pre-date the sync wiring.
12
37
  */
@@ -14,20 +39,91 @@ declare class PersonManager extends BaseManager {
14
39
  readonly description = "Canonical Person records (#617)";
15
40
  private provider;
16
41
  private providerClass?;
42
+ /** `ngdpbase.application.persons.storagedir`, resolved at start: what a backup copies (#1876). */
43
+ private storageDir;
17
44
  private byIdentifierCache;
18
45
  private byIdCache;
19
46
  private metricsRef;
47
+ /** Sign-up link offers by token (#1930). In memory: a restart drops them, and an administrator links by hand. */
48
+ private linkOffers;
20
49
  constructor(engine: WikiEngine);
21
50
  initialize(config?: Record<string, unknown>): Promise<void>;
51
+ /** Person records are in the backup (#1876), as `<stem>.persons.tar` beside the document. */
52
+ backup(options?: ManagerBackupOptions): Promise<BackupData>;
53
+ /** Staged for the next start with the rest of a site restore (#1876); the live records are untouched. */
54
+ restore(backupData: BackupData, options?: ManagerRestoreOptions): Promise<void>;
55
+ restoreIsStaged(): boolean;
22
56
  getProvider(): PersonProvider | null;
23
57
  getById(id: string): Promise<Person | null>;
24
58
  getByIdentifier(identifier: string): Promise<Person | null>;
25
59
  /** #1006: how many people this instance holds. Count only, never records. */
26
60
  getManagerStats(): Promise<ManagerStats>;
27
- list(): Promise<Person[]>;
28
- create(person: Person): Promise<Person>;
29
- update(id: string, patch: PersonUpdate): Promise<Person | null>;
30
- delete(id: string): Promise<boolean>;
61
+ /** True when the person has no account: its identifier is `person:<uuid>` (#1930). */
62
+ static isAccountless(person: Pick<Person, 'identifier'>): boolean;
63
+ /** Every person, for a caller who may read contacts (`contact-read`). */
64
+ list(ctx: ActorContext): Promise<Person[]>;
65
+ /** One person by `@id`, for a caller who may read contacts (`contact-read`). */
66
+ get(ctx: ActorContext, id: string): Promise<Person | null>;
67
+ /**
68
+ * Persons whose name (`name`, `givenName`, `familyName`) or `email` contains
69
+ * `query`, ignoring case; an empty query matches everyone. `accountless`
70
+ * narrows to persons with no account. At most `limit` results
71
+ * (`ngdpbase.application.persons.search.limit` by default).
72
+ */
73
+ search(ctx: ActorContext, query: string, options?: {
74
+ limit?: number;
75
+ accountless?: boolean;
76
+ }): Promise<Person[]>;
77
+ /**
78
+ * Create a person. With no `identifier` the person has no account and gets
79
+ * `person:<uuid>`. An `identifier` must be an existing account that has no
80
+ * person yet: that is how UserManager makes the person for a new account.
81
+ */
82
+ create(ctx: ActorContext, input: PersonInput): Promise<Person>;
83
+ /** Change a person's fields, affiliations included. `@id` and `identifier` never change here. */
84
+ update(ctx: ActorContext, id: string, patch: PersonUpdate): Promise<Person | null>;
85
+ delete(ctx: ActorContext, id: string): Promise<boolean>;
86
+ /**
87
+ * An administrator joins a person who has no account to an account (#1930),
88
+ * for when the email differs or none was given. Asks `contact-manage` and
89
+ * `user-edit`. The person keeps its `@id`; the account's own person, if it
90
+ * has one, is folded in and removed.
91
+ */
92
+ linkAccount(ctx: ActorContext, personId: string, username: string): Promise<Person>;
93
+ /**
94
+ * Offer a new account the person with no account who has the same email
95
+ * ("Is this you?"): a single-use link is mailed to that address, and the
96
+ * account joins the person only when it confirms. Called by UserManager
97
+ * after an account is created. Skipped when email links are not available
98
+ * on this site; nothing is offered when no person, or more than one, has
99
+ * that address (an administrator links those by hand). `ctx` is the context
100
+ * the account was created under, named in the log.
101
+ */
102
+ offerSignUpLink(ctx: ActorContext, username: string): Promise<LinkOfferOutcome>;
103
+ /** What the confirmation page shows, for the signed-in account the link was sent to; null for an unknown, used, expired or other account's link. */
104
+ signUpLinkOffer(ctx: ActorContext, token: string): Promise<LinkOfferDetails | null>;
105
+ /**
106
+ * The new account confirms the emailed link: it is joined to the person.
107
+ * Single use. The link is the authority — it proves the address and is
108
+ * bound to the account it was sent for — so no permission is asked; the
109
+ * change is recorded under the account's own context. Null for a link that
110
+ * is unknown, used, expired, or another account's.
111
+ */
112
+ confirmSignUpLink(ctx: ActorContext, token: string): Promise<Person | null>;
113
+ /** The new account says the record is not theirs: the link is dropped. */
114
+ declineSignUpLink(ctx: ActorContext, token: string): boolean;
115
+ private liveOffer;
116
+ private sweepLinkOffers;
117
+ /**
118
+ * Join `username` to the person `personId`, who has no account. The person
119
+ * takes the account's identifier and keeps its `@id`; fields it lacks are
120
+ * filled from the account's own person, that person's role memberships move
121
+ * to it, and that person is removed. Never a second person.
122
+ */
123
+ private join;
124
+ /** Each affiliation names an organization that exists; `role` is trimmed text. Throws on any other shape. */
125
+ private checkedAffiliations;
126
+ private configNumber;
31
127
  /**
32
128
  * Clear all caches. Called on every write — the simplest correct
33
129
  * invalidation. Per-key invalidation can come later if needed.
@@ -45,4 +141,5 @@ declare class PersonManager extends BaseManager {
45
141
  private getMetrics;
46
142
  private normalizeProviderName;
47
143
  }
144
+ export { ContactRefusedError } from '../utils/contactAccess.js';
48
145
  export default PersonManager;
@@ -1,4 +1,4 @@
1
- import BaseManager from './BaseManager.js';
1
+ import BaseManager, { type BackupData, type ManagerBackupOptions, type ManagerRestoreOptions } from './BaseManager.js';
2
2
  import type { WikiEngine } from '../types/WikiEngine.js';
3
3
  import type { Role, RoleUpdate } from '../types/Role.js';
4
4
  import type { RoleProvider } from '../types/RoleProvider.js';
@@ -8,6 +8,8 @@ declare class RoleManager extends BaseManager {
8
8
  readonly description = "Canonical OrganizationRole records (#617 follow-up)";
9
9
  private provider;
10
10
  private providerClass?;
11
+ /** `ngdpbase.application.roles.storagedir`, resolved at start: what a backup copies (#1876). */
12
+ private storageDir;
11
13
  private memberCache;
12
14
  private byOrgPositionCache;
13
15
  private metricsRef;
@@ -31,6 +33,15 @@ declare class RoleManager extends BaseManager {
31
33
  * as is any other `additionalProperty` entry.
32
34
  */
33
35
  private stripCatalogueCopies;
36
+ /**
37
+ * Role membership is in the backup (#1876): the role records folder, as
38
+ * `<stem>.roles.tar` beside the document. Without it restored users would
39
+ * have no roles.
40
+ */
41
+ backup(options?: ManagerBackupOptions): Promise<BackupData>;
42
+ /** Staged for the next start with the rest of a site restore (#1876); the live records are untouched. */
43
+ restore(backupData: BackupData, options?: ManagerRestoreOptions): Promise<void>;
44
+ restoreIsStaged(): boolean;
34
45
  /** Provider accessor, mainly for tests. */
35
46
  getProvider(): RoleProvider | null;
36
47
  getById(id: string): Promise<Role | null>;
@@ -102,6 +113,13 @@ declare class RoleManager extends BaseManager {
102
113
  * @returns how many accounts were given the role (0 once it has been given)
103
114
  */
104
115
  grantToEveryAccountOnce(roleName: string, usernames: readonly string[], ctx: JobContext): Promise<number>;
116
+ /**
117
+ * Move every membership held by one Person `@id` to another (#1930): when
118
+ * PersonManager joins an account to an existing person, the roles the
119
+ * account's person held go with the account. One write per role record;
120
+ * returns how many records changed. PersonManager's join records the change.
121
+ */
122
+ moveMemberships(fromPersonId: string, toPersonId: string): Promise<number>;
105
123
  /** Remove a deleted account's Person from every role it held. */
106
124
  removeAllMemberships(username: string): Promise<void>;
107
125
  /**
@@ -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
@@ -36,6 +36,13 @@ declare class SqliteDatabaseProvider extends BaseDatabaseProvider<SqliteHandle>
36
36
  busyTimeoutMs?: number;
37
37
  });
38
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;
39
46
  /** Read an existing file read-only: the key opens it and it passes the quick check, or this throws. */
40
47
  private static probe;
41
48
  /**
@@ -62,6 +62,22 @@ class SqliteDatabaseProvider extends BaseDatabaseProvider {
62
62
  db.pragma("cipher='sqlcipher'");
63
63
  db.pragma(`key='${key.replace(/'/g, "''")}'`);
64
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
+ }
65
81
  /** Read an existing file read-only: the key opens it and it passes the quick check, or this throws. */
66
82
  static probe(file, key) {
67
83
  const db = new Database(file, { readonly: true, fileMustExist: true });
@@ -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;