@jwilleke/ngdpbase-types 5.24.0 → 5.26.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 (63) hide show
  1. package/README.md +2 -2
  2. package/lib/packages/ngdpbase-types/index.d.ts +6 -2
  3. package/lib/packages/ngdpbase-types/testing.d.ts +7 -2
  4. package/lib/packages/ngdpbase-types/testing.js +7 -2
  5. package/lib/src/http/egressPolicy.js +61 -0
  6. package/lib/src/http/guardedElasticsearch.d.ts +27 -0
  7. package/lib/src/http/guardedElasticsearch.js +99 -0
  8. package/lib/src/managers/AddonsManager.d.ts +119 -2
  9. package/lib/src/managers/AttachmentManager.d.ts +4 -5
  10. package/lib/src/managers/AuthManager.d.ts +150 -32
  11. package/lib/src/managers/BackgroundJobManager.d.ts +22 -12
  12. package/lib/src/managers/BackupManager.d.ts +779 -0
  13. package/lib/src/managers/BackupManager.js +2042 -0
  14. package/lib/src/managers/BaseManager.d.ts +156 -6
  15. package/lib/src/managers/BaseManager.js +64 -2
  16. package/lib/src/managers/CommentManager.d.ts +6 -6
  17. package/lib/src/managers/ConsentManager.d.ts +130 -0
  18. package/lib/src/managers/DatabaseManager.d.ts +6 -0
  19. package/lib/src/managers/DatabaseManager.js +25 -15
  20. package/lib/src/managers/FootnoteManager.d.ts +6 -6
  21. package/lib/src/managers/OrganizationManager.d.ts +8 -6
  22. package/lib/src/managers/PageManager.d.ts +16 -0
  23. package/lib/src/managers/PersonManager.d.ts +8 -6
  24. package/lib/src/managers/RoleManager.d.ts +6 -8
  25. package/lib/src/providers/BaseAttachmentProvider.d.ts +4 -4
  26. package/lib/src/providers/BaseBackupProvider.d.ts +77 -0
  27. package/lib/src/providers/BaseBackupProvider.js +52 -0
  28. package/lib/src/providers/BasicAttachmentProvider.d.ts +4 -4
  29. package/lib/src/providers/FileBackupProvider.d.ts +34 -0
  30. package/lib/src/providers/FileBackupProvider.js +116 -0
  31. package/lib/src/providers/FileConsentProvider.d.ts +18 -0
  32. package/lib/src/providers/SqliteDatabaseProvider.d.ts +2 -2
  33. package/lib/src/providers/SqliteDatabaseProvider.js +12 -11
  34. package/lib/src/providers/attachmentFilesBackup.d.ts +5 -4
  35. package/lib/src/providers/databaseMigrations.js +45 -0
  36. package/lib/src/security/PolicyDecisionPoint.d.ts +14 -0
  37. package/lib/src/security/PolicyInformationPoint.d.ts +8 -0
  38. package/lib/src/types/Consent.d.ts +90 -0
  39. package/lib/src/types/express.d.ts +6 -0
  40. package/lib/src/utils/addonHost.d.ts +51 -3
  41. package/lib/src/utils/addonHost.js +29 -2
  42. package/lib/src/utils/addonPageSync.d.ts +2 -2
  43. package/lib/src/utils/auditEventNames.generated.d.ts +20 -0
  44. package/lib/src/utils/auditEventNames.generated.js +20 -0
  45. package/lib/src/utils/backupCrypto.js +275 -0
  46. package/lib/src/utils/backupDocument.d.ts +36 -0
  47. package/lib/src/utils/backupDocument.js +99 -0
  48. package/lib/src/utils/browserDescription.d.ts +11 -0
  49. package/lib/src/utils/directoryBackup.d.ts +48 -48
  50. package/lib/src/utils/directoryBackup.js +291 -0
  51. package/lib/src/utils/instanceEnvBackup.d.ts +46 -0
  52. package/lib/src/utils/instanceEnvBackup.js +120 -0
  53. package/lib/src/utils/instanceEnvSecret.js +79 -0
  54. package/lib/src/utils/ipLocation.d.ts +11 -0
  55. package/lib/src/utils/schedule.js +171 -0
  56. package/lib/src/utils/secretFileMode.js +78 -0
  57. package/lib/src/utils/siteRestore.d.ts +13 -16
  58. package/lib/src/utils/siteRestore.js +33 -6
  59. package/lib/src/utils/treeArchive.d.ts +2 -0
  60. package/lib/src/utils/treeArchive.js +242 -0
  61. package/lib/src/utils/version.js +408 -0
  62. package/package.json +6 -1
  63. package/lib/src/utils/administrators.d.ts +0 -21
package/README.md CHANGED
@@ -11,7 +11,7 @@ import type { AddonModule, AddonHost, WikiEngine } from '@jwilleke/ngdpbase-type
11
11
  import { buildAddonHost, openAddonTestDatabase, toCsv, formatters } from '@jwilleke/ngdpbase-types/testing';
12
12
  ```
13
13
 
14
- - The main entry is types only. Importing it at run time fails, on purpose.
15
- - `/testing` is for tests. `buildAddonHost(slug, defaults)` is the real host. `openAddonTestDatabase(owner, { migrations, ledgerTable })` is the handle `openAddonDatabase` returns, in memory. `toCsv` is `ExportManager.toCsv`. `formatters` is a plugin's `context.formatters`.
14
+ - The main entry is types only. Importing it at run time fails, on purpose. An add-on takes core's values from `host` (`AddonHost`: the request and audit helpers, the logger, `guardedFetch`, the egress tools, `isAtLeast`) and a manager's job from `engine.getManager` (such as `PageManager.pageUrl` or `PolicyDecisionPoint.usersHolding`); it never imports core's values ([#1813](https://github.com/jwilleke/ngdpbase/issues/1813)).
15
+ - `/testing` is for tests. `buildAddonHost(slug, defaults, setEnabledForPerson?, engine?)` is the real host; `host.setEnabledForPerson` calls the setter you pass (it refuses without one), and `engine` names the system principal `host.systemContext(reason)` uses (core's fallback without one). `openAddonTestDatabase(owner, { migrations, ledgerTable })` is the handle `openAddonDatabase` returns, in memory. `toCsv` is `ExportManager.toCsv`. `formatters` is a plugin's `context.formatters`.
16
16
 
17
17
  The add-on guide is [docs/guides/addons-developer-guide.md](https://github.com/jwilleke/ngdpbase/blob/master/docs/guides/addons-developer-guide.md).
@@ -14,8 +14,8 @@
14
14
  * `@jwilleke/ngdpbase-types/testing`. A test holds this file to `export type`
15
15
  * only, so the published entry carries no JavaScript.
16
16
  */
17
- export type { AddonModule } from '../../src/managers/AddonsManager.js';
18
- export type { AddonHost } from '../../src/utils/addonHost.js';
17
+ export type { AddonModule, AddonManifest, AddonListing } from '../../src/managers/AddonsManager.js';
18
+ export type { AddonHost, PersonSwitchSetter } from '../../src/utils/addonHost.js';
19
19
  export type { WikiEngine } from '../../src/types/WikiEngine.js';
20
20
  export type { ApiContext } from '../../src/context/ApiContext.js';
21
21
  export type { ActorContext } from '../../src/context/ActorContext.js';
@@ -30,6 +30,10 @@ 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 PageManager } from '../../src/managers/PageManager.js';
34
+ export type { PageAction } from '../../src/utils/pageUrl.js';
35
+ export type { default as ConsentManager, ConsentScopeStatus } from '../../src/managers/ConsentManager.js';
36
+ export type { AddonConsentRegistration, ConsentDocument, ConsentRecord, ConsentRequiredError, ConsentScope, ConsentSeen, ConsentStatus } from '../../src/types/Consent.js';
33
37
  export type { default as PersonManager, LinkOfferOutcome } from '../../src/managers/PersonManager.js';
34
38
  export type { default as OrganizationManager } from '../../src/managers/OrganizationManager.js';
35
39
  export type { Person, PersonAffiliation, PersonInput, PersonUpdate } from '../../src/types/Person.js';
@@ -4,7 +4,7 @@
4
4
  import ExportManager from '../../src/managers/ExportManager.js';
5
5
  import { type AddonDatabaseOptions } from '../../src/managers/DatabaseManager.js';
6
6
  import { type SqliteHandle } from '../../src/providers/SqliteDatabaseProvider.js';
7
- import { type AddonHost } from '../../src/utils/addonHost.js';
7
+ import { buildAddonHost as buildHost, type AddonHost, type PersonSwitchSetter } from '../../src/utils/addonHost.js';
8
8
  export { formatters } from '../../src/managers/PluginManager.js';
9
9
  /**
10
10
  * Core's `buildAddonHost`, with the audit registry bound to this add-on's
@@ -15,8 +15,13 @@ export { formatters } from '../../src/managers/PluginManager.js';
15
15
  *
16
16
  * @param slug - The add-on's canonical slug
17
17
  * @param addonDefaults - The add-on's own `config/default-config.json`
18
+ * @param setEnabledForPerson - What `host.setEnabledForPerson` does in the
19
+ * test (#1963); a site binds core's setter. Without one it refuses.
20
+ * @param engine - Optional: an engine (or stand-in) whose PolicyInformationPoint
21
+ * names the system principal for `host.systemContext`; without one it is
22
+ * core's fallback principal
18
23
  */
19
- export declare function buildAddonHost(slug: string, addonDefaults: Record<string, unknown>): AddonHost;
24
+ export declare function buildAddonHost(slug: string, addonDefaults: Record<string, unknown>, setEnabledForPerson?: PersonSwitchSetter, engine?: Parameters<typeof buildHost>[3]): AddonHost;
20
25
  /** The add-on whose declaration a test database is checked against: its slug and its own `config/default-config.json`. */
21
26
  export interface AddonTestDatabaseDeclarer {
22
27
  slug: string;
@@ -14,10 +14,15 @@ export { formatters } from '../../src/managers/PluginManager.js';
14
14
  *
15
15
  * @param slug - The add-on's canonical slug
16
16
  * @param addonDefaults - The add-on's own `config/default-config.json`
17
+ * @param setEnabledForPerson - What `host.setEnabledForPerson` does in the
18
+ * test (#1963); a site binds core's setter. Without one it refuses.
19
+ * @param engine - Optional: an engine (or stand-in) whose PolicyInformationPoint
20
+ * names the system principal for `host.systemContext`; without one it is
21
+ * core's fallback principal
17
22
  */
18
- export function buildAddonHost(slug, addonDefaults) {
23
+ export function buildAddonHost(slug, addonDefaults, setEnabledForPerson, engine) {
19
24
  bindAuditEvents((key, fallback) => addonDefaults[key] ?? fallback);
20
- return buildHost(slug, addonDefaults);
25
+ return buildHost(slug, addonDefaults, setEnabledForPerson, engine);
21
26
  }
22
27
  /**
23
28
  * An add-on's database as `DatabaseManager.openAddonDatabase` returns it, in
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Configuration to policy (#1133).
3
+ *
4
+ * The operator sets two CIDR lists; this turns them into the `EgressPolicy`
5
+ * the guard enforces, and reconciles them at BOOT so a contradiction is a
6
+ * startup message rather than one dropped image months later — which would
7
+ * surface as a `fetch` warning indistinguishable from the remote host being
8
+ * down.
9
+ *
10
+ * Contradictions resolve by FIREWALL CONVENTION and none of them stops the
11
+ * boot (#1144, D8). #1133 chose longest-prefix match — the routing rule,
12
+ * because the values are routes — so a general overlap is not a conflict at
13
+ * all. The cases longest prefix cannot decide are settled here: an allow entry
14
+ * reaching the mechanism is unsatisfiable and dropped, and a range in both
15
+ * lists is a prefix-length tie the DENY wins.
16
+ *
17
+ * `ngdpbase.security.profile` used to decide whether a contradiction stopped
18
+ * the boot. That was the profile looking for a job: `iptables` rejects a bad
19
+ * rule and keeps the chain, and the Kubernetes API server rejects an invalid
20
+ * NetworkPolicy while the others keep applying. Neither takes the workload
21
+ * down. The one case with no safe silent resolution — a malformed range, where
22
+ * dropping a DENY fails open — is reported separately for #1152.
23
+ */
24
+ import { reconcilePolicy } from './ssrf.js';
25
+ export const DENIED_RANGES_KEY = 'ngdpbase.security.egress.denied-ranges';
26
+ export const ALLOWED_RANGES_KEY = 'ngdpbase.security.egress.allowed-ranges';
27
+ /** Only strings can be CIDRs; anything else in the list is discarded, not coerced. */
28
+ function stringList(value) {
29
+ return Array.isArray(value) ? value.filter((v) => typeof v === 'string') : [];
30
+ }
31
+ /**
32
+ * Read the configured ranges, reconcile them, and report what could not be
33
+ * resolved.
34
+ *
35
+ * A contradiction never widens access. Where the two lists disagree the
36
+ * stricter reading wins, which is the only direction a security control should
37
+ * fail when nobody can be asked.
38
+ */
39
+ export function resolveEgressPolicy(read) {
40
+ const deniedRanges = stringList(read(DENIED_RANGES_KEY, []));
41
+ const allowedRanges = stringList(read(ALLOWED_RANGES_KEY, []));
42
+ const { conflicts, malformed, duplicates } = reconcilePolicy(deniedRanges, allowedRanges);
43
+ const unusable = new Set(malformed);
44
+ // An allow entry is dropped when it cannot be parsed, when it reaches the
45
+ // mechanism, or when the same range is also denied. The last is D8's tie
46
+ // break: equal prefix lengths give longest-prefix nothing to work with, so
47
+ // the deny wins.
48
+ const safeAllowed = allowedRanges.filter((range) => !unusable.has(range)
49
+ && !duplicates.includes(range)
50
+ && !conflicts.some((c) => c.startsWith('allowed-ranges:') && c.includes(`'${range}'`)));
51
+ // A denied range is dropped ONLY when it cannot be parsed. Previously a
52
+ // range appearing in both lists was removed from here too, so the operator's
53
+ // stricter statement was discarded along with the looser one and the range
54
+ // fell back to the built-in defaults.
55
+ const safeDenied = deniedRanges.filter((range) => !unusable.has(range));
56
+ return {
57
+ policy: { deniedRanges: safeDenied, allowedRanges: safeAllowed },
58
+ conflicts,
59
+ malformed
60
+ };
61
+ }
@@ -0,0 +1,27 @@
1
+ import { Client, type ClientOptions } from '@elastic/elasticsearch';
2
+ import { type Resolver } from './guardedLookup.js';
3
+ import { type ConfigReader } from './egressPolicy.js';
4
+ import { type EgressPolicy } from './ssrf.js';
5
+ export interface GuardedElasticsearchOptions {
6
+ /** Per-request timeout in ms; the SDK's default when omitted. */
7
+ requestTimeout?: number;
8
+ /**
9
+ * The resolution step, for tests only — the same single seam `guardedLookup`
10
+ * exposes. Production leaves it unset and `node:dns` answers.
11
+ */
12
+ resolver?: Resolver;
13
+ }
14
+ /**
15
+ * The options that make a client guarded. Exported so a test can assert the
16
+ * wiring — that the connection class is the `http` one and that its agents
17
+ * carry the guarded lookup and the guarded connect — without opening a socket.
18
+ */
19
+ export declare function guardedClientOptions(node: string, policy: EgressPolicy, options?: GuardedElasticsearchOptions): ClientOptions;
20
+ /** An Elasticsearch client whose every connection is subject to the instance's egress policy. */
21
+ export declare function createGuardedElasticsearchClient(node: string, read: ConfigReader, options?: GuardedElasticsearchOptions): Client;
22
+ /**
23
+ * The boot-time line both call sites log when the configured node would be
24
+ * refused — one sentence, the same shape as sist2's (#1186), so the operator
25
+ * is sent to the egress configuration and not to the Elasticsearch host.
26
+ */
27
+ export declare function refusedNodeMessage(who: string, node: string, reason: string): string;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The Elasticsearch client, built inside the boundary (#1188).
3
+ *
4
+ * `@elastic/elasticsearch` opens sockets the same way `fetch` does, and until
5
+ * #1188 it did so from two places outside `src/http/` — the search provider
6
+ * and the elasticsearch addon — with `new Client({ node })` and no policy at
7
+ * all. The boundary check (#1139) could not see it because the SDK was not in
8
+ * its list of client modules, so the guard read green while the process
9
+ * connected to whatever `es-url` said, loopback and RFC1918 included.
10
+ *
11
+ * This is the only place the SDK is constructed. It hands the client Node's
12
+ * `http` connection class with agents whose `lookup` is the guarded one, so
13
+ * every socket the client opens — the first request, a retry, a sniffed node —
14
+ * is judged against the instance's egress policy at connect time, exactly as
15
+ * `guardedFetch` does. There is no ungated constructor to reach for: the
16
+ * scanner now names the SDK, so an `import { Client }` outside this directory
17
+ * fails `lint:http`.
18
+ *
19
+ * What this means for an operator: an Elasticsearch node on `localhost` is
20
+ * refused — loopback is mechanism, not policy (#1186), and no allow entry
21
+ * opens it. A node on the LAN needs its prefix in
22
+ * `ngdpbase.security.egress.allowed-ranges`, the same as sist2. Both call
23
+ * sites say so at boot when the configured address would be refused.
24
+ */
25
+ import { Agent as HttpAgent } from 'node:http';
26
+ import { Agent as HttpsAgent } from 'node:https';
27
+ import { isIP } from 'node:net';
28
+ import { Client, HttpConnection } from '@elastic/elasticsearch';
29
+ import { ESSRF_BLOCKED, guardedLookup } from './guardedLookup.js';
30
+ import { resolveEgressPolicy } from './egressPolicy.js';
31
+ import { isAddressAllowed, REFUSAL } from './ssrf.js';
32
+ /**
33
+ * The connect step, guarding the case the lookup never sees.
34
+ *
35
+ * Node calls an agent's `lookup` only for a NAME. A URL whose host is an IP
36
+ * literal — `http://192.168.0.10:9200`, `http://127.0.0.1:9200` — goes
37
+ * straight to the socket, so a guard that lives only in `lookup` waves every
38
+ * literal address through. Found by pointing the first version of this at
39
+ * jimstest's LAN node under an empty policy and watching `ping` return true.
40
+ * `guardedFetch` covers the same hole with `validateUrl` before each hop;
41
+ * here the judgement sits in `createConnection`, which every socket passes
42
+ * through, literal or resolved.
43
+ */
44
+ function guardedCreateConnection(policy, connect) {
45
+ return function guarded(options, callback) {
46
+ const host = String(options.host ?? options.hostname ?? 'localhost');
47
+ if (isIP(host) && !isAddressAllowed(host, policy)) {
48
+ const err = Object.assign(new Error(`${REFUSAL}: ${host}`), { code: ESSRF_BLOCKED });
49
+ if (callback) {
50
+ process.nextTick(callback, err);
51
+ return undefined;
52
+ }
53
+ throw err;
54
+ }
55
+ // Node's own connect for this agent — `net.createConnection` for http,
56
+ // the TLS one for https. The agent has merged its options in, so `lookup`
57
+ // (the guarded one) rides along for a host that is a name.
58
+ return connect.call(this, options, callback);
59
+ };
60
+ }
61
+ /**
62
+ * The options that make a client guarded. Exported so a test can assert the
63
+ * wiring — that the connection class is the `http` one and that its agents
64
+ * carry the guarded lookup and the guarded connect — without opening a socket.
65
+ */
66
+ export function guardedClientOptions(node, policy, options = {}) {
67
+ // keepAlive stays on, as the SDK's own default agent has it: a kept socket
68
+ // was judged when it was opened, and both guards run again for every new one.
69
+ const lookup = guardedLookup(policy, options.resolver);
70
+ const agents = { http: new HttpAgent({ lookup, keepAlive: true }), https: new HttpsAgent({ lookup, keepAlive: true }) };
71
+ // `createConnection` is a method of the agent, not an option it accepts:
72
+ // Node reads `this.createConnection` when it opens a socket. Replaced on the
73
+ // instance, wrapping the prototype's own so TLS setup stays Node's.
74
+ const method = (agent) => Object.getPrototypeOf(agent).createConnection;
75
+ agents.http.createConnection =
76
+ guardedCreateConnection(policy, method(agents.http));
77
+ agents.https.createConnection =
78
+ guardedCreateConnection(policy, method(agents.https));
79
+ return {
80
+ node,
81
+ ...(options.requestTimeout !== undefined ? { requestTimeout: options.requestTimeout } : {}),
82
+ Connection: HttpConnection,
83
+ agent: (connection) => (connection.url.protocol === 'https:' ? agents.https : agents.http)
84
+ };
85
+ }
86
+ /** An Elasticsearch client whose every connection is subject to the instance's egress policy. */
87
+ export function createGuardedElasticsearchClient(node, read, options = {}) {
88
+ return new Client(guardedClientOptions(node, resolveEgressPolicy(read).policy, options));
89
+ }
90
+ /**
91
+ * The boot-time line both call sites log when the configured node would be
92
+ * refused — one sentence, the same shape as sist2's (#1186), so the operator
93
+ * is sent to the egress configuration and not to the Elasticsearch host.
94
+ */
95
+ export function refusedNodeMessage(who, node, reason) {
96
+ return (`[${who}] Elasticsearch node ${node} is refused by the egress policy (${reason}). ` +
97
+ 'Loopback and link-local can never be opened; a LAN address needs its prefix in ' +
98
+ 'ngdpbase.security.egress.allowed-ranges. Every request to it will fail. (#1188)');
99
+ }
@@ -10,13 +10,15 @@
10
10
  *
11
11
  * @see {@link https://github.com/jwilleke/ngdpbase/issues/158}
12
12
  */
13
- import type { ActorContext } from '../context/ActorContext.js';
13
+ import { type ActorContext } from '../context/ActorContext.js';
14
+ import type { BackupFolder } from './BaseManager.js';
14
15
  import BaseManager from './BaseManager.js';
15
16
  import type { BackupData } from './BaseManager.js';
16
17
  import type { WikiEngine } from '../types/WikiEngine.js';
17
18
  import type { User } from '../types/User.js';
18
19
  import { type StoreOwnerState } from '../utils/privateStoreDoor.js';
19
20
  import { type AddonHost } from '../utils/addonHost.js';
21
+ import { ConsentRequiredError } from '../types/Consent.js';
20
22
  /**
21
23
  * Type passed to `AddonModule.profileSection()` (#534). Matches what
22
24
  * `UserManager.getUser()` returns at runtime — `User` minus the password
@@ -76,6 +78,14 @@ export interface AddonModule {
76
78
  * frozen. An add-on that takes two arguments keeps working until it moves.
77
79
  */
78
80
  register(engine: WikiEngine, config: Record<string, unknown>, host?: AddonHost): Promise<void> | void;
81
+ /**
82
+ * Folders it keeps data in besides its `dataPath` (#1985), given its
83
+ * configuration. Its `dataPath` is backed up and restored without being
84
+ * declared; an add-on declares only further folders, and writes no backup
85
+ * code. BackupManager archives each one, stages it in a site restore, and
86
+ * puts it in place before `register()` runs at the next start.
87
+ */
88
+ backupFolders?(config: Record<string, unknown>): BackupFolder[];
79
89
  /**
80
90
  * Optional health check returning add-on status details.
81
91
  */
@@ -208,7 +218,49 @@ export interface AddonManifest {
208
218
  * docs/guides/addons-developer-guide.md "Declare a Private Store".
209
219
  */
210
220
  stores?: unknown;
221
+ /**
222
+ * #1963: each person turns this add-on on or off for themselves. Their
223
+ * switch is the preference `<slug>.enabled` (`personSwitchKey`), read only
224
+ * by `AddonsManager.enabledAddonsFor` and written only through
225
+ * `host.setEnabledForPerson`. Absent or anything but `true`: not per-person.
226
+ */
227
+ perPerson?: boolean;
228
+ /**
229
+ * #1965: the add-on's About page, a page it ships in `pages/`, by uuid or
230
+ * slug. `[{ConfigAccessor type='addons'}]` links to it ("More about it").
231
+ * The site overrides it with `ngdpbase.addons.<slug>.about-page`.
232
+ */
233
+ aboutPage?: string;
234
+ /**
235
+ * #1965: the heading the add-on is listed under, shared by related add-ons
236
+ * (e.g. "Accounting" for accounting, accounting-donations and accounting-dues).
237
+ */
238
+ family?: string;
239
+ /** #1965: `false` keeps plumbing (e.g. elasticsearch) out of the add-on listings. */
240
+ listed?: boolean;
241
+ }
242
+ /**
243
+ * One add-on as the listings show it (#1965), from `AddonsManager.listedAddons()`.
244
+ * `version`, `loaded` and `error` are for administrators; the plugin that
245
+ * renders a listing decides who sees them.
246
+ */
247
+ export interface AddonListing {
248
+ slug: string;
249
+ description: string;
250
+ /** The heading it is grouped under, or null */
251
+ family: string | null;
252
+ /** The About page (uuid or slug), the site's override first; null when none */
253
+ aboutPage: string | null;
254
+ perPerson: boolean;
255
+ version: string;
256
+ loaded: boolean;
257
+ error: string | null;
211
258
  }
259
+ /**
260
+ * The preference that holds a person's switch for a per-person add-on (#1963):
261
+ * `<slug>.enabled`. The one place the key is spelled.
262
+ */
263
+ export declare function personSwitchKey(slug: string): string;
212
264
  /**
213
265
  * Details returned by add-on's status() method
214
266
  */
@@ -262,6 +314,7 @@ export interface AddonStatus {
262
314
  * AddonsManager - Manages optional add-on modules
263
315
  */
264
316
  declare class AddonsManager extends BaseManager {
317
+ #private;
265
318
  /** Map of discovered add-ons by name */
266
319
  private addons;
267
320
  /** Configured paths to addons directories (one or more) */
@@ -282,6 +335,8 @@ declare class AddonsManager extends BaseManager {
282
335
  private domainAddonName;
283
336
  /** #1883: the enabled add-ons that did not load this start, with why — each reported once. */
284
337
  private readonly loadFailures;
338
+ /** #1973: every enabled add-on has had its turn to load this boot (see {@link addonsLoaded}). */
339
+ private loadPassDone;
285
340
  constructor(engine: WikiEngine);
286
341
  /**
287
342
  * Initialize the AddonsManager
@@ -405,6 +460,34 @@ declare class AddonsManager extends BaseManager {
405
460
  ok: false;
406
461
  blockedBy: string[];
407
462
  };
463
+ /**
464
+ * The installed version of an add-on (#1973): the `version` in its
465
+ * package.json, or its module's `version` when the package.json has none.
466
+ * Undefined for an add-on that was not discovered.
467
+ *
468
+ * @param slug - The add-on's slug
469
+ */
470
+ getAddonVersion(slug: string): string | undefined;
471
+ /**
472
+ * Whether this boot's load pass is over (#1973): every enabled add-on has
473
+ * had its `register()` called, loaded or not. Until then, what an add-on has
474
+ * not registered yet says nothing about whether it still provides it.
475
+ */
476
+ addonsLoaded(): boolean;
477
+ /**
478
+ * An add-on's folders (#1985): its `dataPath` — `ngdpbase.addons.<slug>.dataPath`,
479
+ * or `<FAST_STORAGE>/<slug>` when that is not set, as the bundled add-ons
480
+ * read it — under the part name of its slug, then any further folders its
481
+ * module declares. A `dataPath` that is the instance data folder, or holds
482
+ * it, is never one: a restore would swap the whole site's data.
483
+ */
484
+ private addonFolders;
485
+ /** Each loaded add-on's `dataPath` and declared folders (#1985), in load order: what BackupManager backs up for it. */
486
+ addonBackupFolders(): Array<{
487
+ slug: string;
488
+ dataPath: string | null;
489
+ folders: BackupFolder[];
490
+ }>;
408
491
  /**
409
492
  * Get add-on specific configuration
410
493
  */
@@ -612,8 +695,42 @@ declare class AddonsManager extends BaseManager {
612
695
  *
613
696
  * Errors are caught per-addon and logged so one bad addon cannot block
614
697
  * other addons' saves or the core-preferences save.
698
+ *
699
+ * #1603: an add-on whose switch was refused because its documents are not
700
+ * accepted yet (`ConsentRequiredError`) is returned, so the route can tell
701
+ * the person what to accept instead of the save failing silently.
702
+ */
703
+ saveProfileSections(ctx: ActorContext, body: Record<string, unknown>): Promise<{
704
+ consentRequired: ConsentRequiredError[];
705
+ }>;
706
+ /**
707
+ * The add-ons the site's listings show (#1965): enabled, and not
708
+ * `listed: false` in their manifest, in load order. Cheap: it calls no
709
+ * add-on's status(), so a page can render it.
710
+ */
711
+ listedAddons(): AddonListing[];
712
+ /**
713
+ * Whether `slug` is an add-on whose manifest says `perPerson: true` (#1963),
714
+ * loaded or still loading. ConsentManager asks it when an add-on registers
715
+ * documents: they guard the per-person switch, so only such an add-on has any.
716
+ */
717
+ isPerPerson(slug: string): boolean;
718
+ /** #1963: the loaded add-ons whose manifest says `perPerson: true`, in load order. */
719
+ private perPersonAddons;
720
+ /**
721
+ * The per-person add-ons this subject has turned on (#1963): each loaded
722
+ * add-on whose manifest says `perPerson: true` and whose switch, the
723
+ * person's preference `<slug>.enabled`, is `true`.
724
+ *
725
+ * It reads exactly those keys and no other preference, so no access check
726
+ * or listing built on it can come to depend on an arbitrary setting. An
727
+ * anonymous subject, an unknown or inactive account, and the system
728
+ * principal (which has no account) have none.
729
+ *
730
+ * @param ctx - Whose add-ons: the request's subject, or a job's context
731
+ * @returns The slugs, in load order
615
732
  */
616
- saveProfileSections(ctx: ActorContext, body: Record<string, unknown>): Promise<void>;
733
+ enabledAddonsFor(ctx: ActorContext): Promise<string[]>;
617
734
  /**
618
735
  * Get count of loaded add-ons
619
736
  */
@@ -1,6 +1,5 @@
1
- import BaseManager, { BackupData, type ManagerBackupOptions, type ManagerRestoreOptions, type ManagerStats } from './BaseManager.js';
1
+ import BaseManager, { BackupData, type BackupArchiveTarget, type ManagerBackupOptions, type ManagerRestoreOptions, type ManagerStats, type SiteRestoreStaging, type StagedRestoreAtStart } from './BaseManager.js';
2
2
  import type { AttachmentFilesBackupData, AttachmentFilesBackupOutcome } from '../providers/attachmentFilesBackup.js';
3
- import type { PartOutcome } from '../utils/siteRestore.js';
4
3
  import { type ActorContext } from '../context/ActorContext.js';
5
4
  /**
6
5
  * Marks an error as "the audit record could not be written", not "the action
@@ -36,11 +35,11 @@ interface BaseAttachmentProvider {
36
35
  backup(): Promise<unknown>;
37
36
  restore(backupData: unknown): Promise<void>;
38
37
  /** #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<{
38
+ backupFiles?(archive: (part: string) => BackupArchiveTarget): Promise<AttachmentFilesBackupOutcome>;
39
+ stageFilesRestore?(providerBackup: Record<string, unknown>, files: AttachmentFilesBackupData, readArchive: NonNullable<ManagerRestoreOptions['readArchive']>, siteRestore: SiteRestoreStaging): Promise<{
41
40
  skippedVaults: string[];
42
41
  }>;
43
- takeRestoreAtStart?(): PartOutcome | null;
42
+ takeRestoreAtStart?(): StagedRestoreAtStart | null;
44
43
  shutdown(): Promise<void>;
45
44
  getProviderInfo(): {
46
45
  features: string[];