@jwilleke/ngdpbase-types 5.25.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.
- package/README.md +1 -1
- package/lib/packages/ngdpbase-types/index.d.ts +4 -0
- package/lib/src/http/egressPolicy.js +61 -0
- package/lib/src/http/guardedElasticsearch.d.ts +27 -0
- package/lib/src/http/guardedElasticsearch.js +99 -0
- package/lib/src/managers/AddonsManager.d.ts +37 -1
- package/lib/src/managers/AttachmentManager.d.ts +4 -5
- package/lib/src/managers/AuthManager.d.ts +150 -32
- package/lib/src/managers/BackgroundJobManager.d.ts +22 -12
- package/lib/src/managers/BackupManager.d.ts +779 -0
- package/lib/src/managers/BackupManager.js +2042 -0
- package/lib/src/managers/BaseManager.d.ts +156 -6
- package/lib/src/managers/BaseManager.js +64 -2
- package/lib/src/managers/CommentManager.d.ts +6 -6
- package/lib/src/managers/ConsentManager.d.ts +130 -0
- package/lib/src/managers/DatabaseManager.d.ts +6 -0
- package/lib/src/managers/DatabaseManager.js +25 -15
- package/lib/src/managers/FootnoteManager.d.ts +6 -6
- package/lib/src/managers/OrganizationManager.d.ts +8 -6
- package/lib/src/managers/PageManager.d.ts +16 -0
- package/lib/src/managers/PersonManager.d.ts +8 -6
- package/lib/src/managers/RoleManager.d.ts +6 -8
- package/lib/src/providers/BaseAttachmentProvider.d.ts +4 -4
- package/lib/src/providers/BaseBackupProvider.d.ts +77 -0
- package/lib/src/providers/BaseBackupProvider.js +52 -0
- package/lib/src/providers/BasicAttachmentProvider.d.ts +4 -4
- package/lib/src/providers/FileBackupProvider.d.ts +34 -0
- package/lib/src/providers/FileBackupProvider.js +116 -0
- package/lib/src/providers/FileConsentProvider.d.ts +18 -0
- package/lib/src/providers/SqliteDatabaseProvider.d.ts +2 -2
- package/lib/src/providers/SqliteDatabaseProvider.js +12 -11
- package/lib/src/providers/attachmentFilesBackup.d.ts +5 -4
- package/lib/src/providers/databaseMigrations.js +45 -0
- package/lib/src/security/PolicyDecisionPoint.d.ts +14 -0
- package/lib/src/types/Consent.d.ts +90 -0
- package/lib/src/types/express.d.ts +6 -0
- package/lib/src/utils/addonHost.d.ts +22 -0
- package/lib/src/utils/addonHost.js +14 -1
- package/lib/src/utils/auditEventNames.generated.d.ts +20 -0
- package/lib/src/utils/auditEventNames.generated.js +20 -0
- package/lib/src/utils/backupCrypto.js +275 -0
- package/lib/src/utils/backupDocument.d.ts +36 -0
- package/lib/src/utils/backupDocument.js +99 -0
- package/lib/src/utils/browserDescription.d.ts +11 -0
- package/lib/src/utils/directoryBackup.d.ts +48 -48
- package/lib/src/utils/directoryBackup.js +291 -0
- package/lib/src/utils/instanceEnvBackup.d.ts +46 -0
- package/lib/src/utils/instanceEnvBackup.js +120 -0
- package/lib/src/utils/instanceEnvSecret.js +79 -0
- package/lib/src/utils/ipLocation.d.ts +11 -0
- package/lib/src/utils/schedule.js +171 -0
- package/lib/src/utils/secretFileMode.js +78 -0
- package/lib/src/utils/siteRestore.d.ts +13 -16
- package/lib/src/utils/siteRestore.js +33 -6
- package/lib/src/utils/treeArchive.d.ts +2 -0
- package/lib/src/utils/treeArchive.js +242 -0
- package/lib/src/utils/version.js +408 -0
- package/package.json +6 -1
- 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.
|
|
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
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).
|
|
@@ -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';
|
|
@@ -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
|
+
}
|
|
@@ -11,12 +11,14 @@
|
|
|
11
11
|
* @see {@link https://github.com/jwilleke/ngdpbase/issues/158}
|
|
12
12
|
*/
|
|
13
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
|
*/
|
|
@@ -464,6 +474,20 @@ declare class AddonsManager extends BaseManager {
|
|
|
464
474
|
* not registered yet says nothing about whether it still provides it.
|
|
465
475
|
*/
|
|
466
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
|
+
}>;
|
|
467
491
|
/**
|
|
468
492
|
* Get add-on specific configuration
|
|
469
493
|
*/
|
|
@@ -671,14 +695,26 @@ declare class AddonsManager extends BaseManager {
|
|
|
671
695
|
*
|
|
672
696
|
* Errors are caught per-addon and logged so one bad addon cannot block
|
|
673
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.
|
|
674
702
|
*/
|
|
675
|
-
saveProfileSections(ctx: ActorContext, body: Record<string, unknown>): Promise<
|
|
703
|
+
saveProfileSections(ctx: ActorContext, body: Record<string, unknown>): Promise<{
|
|
704
|
+
consentRequired: ConsentRequiredError[];
|
|
705
|
+
}>;
|
|
676
706
|
/**
|
|
677
707
|
* The add-ons the site's listings show (#1965): enabled, and not
|
|
678
708
|
* `listed: false` in their manifest, in load order. Cheap: it calls no
|
|
679
709
|
* add-on's status(), so a page can render it.
|
|
680
710
|
*/
|
|
681
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;
|
|
682
718
|
/** #1963: the loaded add-ons whose manifest says `perPerson: true`, in load order. */
|
|
683
719
|
private perPersonAddons;
|
|
684
720
|
/**
|
|
@@ -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?(
|
|
40
|
-
stageFilesRestore?(providerBackup: Record<string, unknown>, files: AttachmentFilesBackupData,
|
|
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?():
|
|
42
|
+
takeRestoreAtStart?(): StagedRestoreAtStart | null;
|
|
44
43
|
shutdown(): Promise<void>;
|
|
45
44
|
getProviderInfo(): {
|
|
46
45
|
features: string[];
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
* @see {@link https://github.com/jwilleke/ngdpbase/issues/396}
|
|
31
31
|
*/
|
|
32
32
|
import BaseManager from './BaseManager.js';
|
|
33
|
-
import type { BackupData,
|
|
33
|
+
import type { BackupData, BackupFolder } from './BaseManager.js';
|
|
34
34
|
import type { WikiEngine } from '../types/WikiEngine.js';
|
|
35
35
|
import type { PermissionSubject } from './UserManager.js';
|
|
36
36
|
import type { JobContext } from '../context/JobContext.js';
|
|
@@ -107,6 +107,12 @@ export declare function credentialLabel(label: unknown): string;
|
|
|
107
107
|
*/
|
|
108
108
|
export interface PendingSignIn {
|
|
109
109
|
handle: string;
|
|
110
|
+
/**
|
|
111
|
+
* The id every audit record of this sign-in's approval carries (#1532), so
|
|
112
|
+
* the steps read as one trail. Random and separate from the handle and the
|
|
113
|
+
* link token, which are secrets.
|
|
114
|
+
*/
|
|
115
|
+
id: string;
|
|
110
116
|
username: string;
|
|
111
117
|
first: AuthenticateResult;
|
|
112
118
|
/** The requesting browser's binding value (an HTTP-only cookie); every step must present it. */
|
|
@@ -115,15 +121,54 @@ export interface PendingSignIn {
|
|
|
115
121
|
expiresAt: number;
|
|
116
122
|
requestIp?: string;
|
|
117
123
|
requestUserAgent?: string;
|
|
124
|
+
/** Roughly where the request came from ("Columbus, United States"), or null when no source says (#1532). */
|
|
125
|
+
requestLocation?: string | null;
|
|
118
126
|
/** Private-store keys unlocked by the password, held until the sign-in completes or is dropped. */
|
|
119
127
|
privateStoreHandle?: string;
|
|
120
128
|
/** The second factor, once satisfied. */
|
|
121
129
|
approved?: SatisfiedFactor;
|
|
122
|
-
/** Refused by the person (
|
|
130
|
+
/** Refused by the person (Deny). */
|
|
123
131
|
denied?: boolean;
|
|
124
132
|
/** When the last approval message went out, for the resend limit. */
|
|
125
133
|
lastSentAt?: number;
|
|
126
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* What an approval request says about a waiting sign-in (#1532): one source
|
|
137
|
+
* for the message and the page.
|
|
138
|
+
*/
|
|
139
|
+
export interface ApprovalAbout {
|
|
140
|
+
/** The site's name (`ngdpbase.application-name`). */
|
|
141
|
+
site: string;
|
|
142
|
+
/** When it was asked, in the account holder's own time zone. */
|
|
143
|
+
when: string;
|
|
144
|
+
/** "Chrome on Windows", or null when the browser could not be named. */
|
|
145
|
+
device: string | null;
|
|
146
|
+
/** The IP address it came from (Express's `req.ip`, so `trust-proxy` applies). */
|
|
147
|
+
ip?: string;
|
|
148
|
+
/** Roughly where ("Columbus, United States"), or null when no source says. */
|
|
149
|
+
location: string | null;
|
|
150
|
+
}
|
|
151
|
+
/** What the approval page shows about a waiting sign-in (#1532). */
|
|
152
|
+
export interface ApprovalDetails extends ApprovalAbout {
|
|
153
|
+
username: string;
|
|
154
|
+
requestedAt: number;
|
|
155
|
+
userAgent?: string;
|
|
156
|
+
/** The sentence the message says, as HTML with every value escaped. */
|
|
157
|
+
sentenceHtml: string;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* The approval sentence (#1532, decided wording plus the IP and place):
|
|
161
|
+
* "A sign-in request for Willeke Family was sent at 2 Oct 2026, 14:02 EDT
|
|
162
|
+
* from Chrome on Windows at IP address 81.2.69.160, near Columbus, United
|
|
163
|
+
* States." `strong` marks the site and time, `text` every other value; for
|
|
164
|
+
* HTML both escape.
|
|
165
|
+
*/
|
|
166
|
+
export declare function approvalSentence(about: ApprovalAbout, strong?: (v: string) => string, text?: (v: string) => string): string;
|
|
167
|
+
/** Who opened the approval page or pressed its button (#1532): recorded, never trusted for anything. */
|
|
168
|
+
export interface ApprovalVisitor {
|
|
169
|
+
ip?: string;
|
|
170
|
+
userAgent?: string;
|
|
171
|
+
}
|
|
127
172
|
/** A second factor a person has enrolled and can use now. */
|
|
128
173
|
export interface AvailableSecondFactor {
|
|
129
174
|
id: 'email-link';
|
|
@@ -202,6 +247,18 @@ export type OutsideSignIn =
|
|
|
202
247
|
/** The state was already used: no second session. */
|
|
203
248
|
| {
|
|
204
249
|
outcome: 'replayed';
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* #1532: the provider's sign-in reached only AAL1 and the account has a
|
|
253
|
+
* second factor: held as a pending sign-in, as after a password. The route
|
|
254
|
+
* binds the browser (`binding`), keeps `handle` in the session and sends the
|
|
255
|
+
* person to the second-factor page; `next` is where they were going.
|
|
256
|
+
*/
|
|
257
|
+
| {
|
|
258
|
+
outcome: 'second-step';
|
|
259
|
+
handle: string;
|
|
260
|
+
binding: string;
|
|
261
|
+
next: string;
|
|
205
262
|
};
|
|
206
263
|
/** What the one completion step leaves for the route (#1743): where to go instead, when a pending identity waits to be confirmed. */
|
|
207
264
|
export interface SignInCompleted {
|
|
@@ -1030,6 +1087,12 @@ declare class AuthManager extends BaseManager {
|
|
|
1030
1087
|
privateStoreHandle?: string;
|
|
1031
1088
|
returnTo?: string;
|
|
1032
1089
|
}): Promise<SignInCompleted>;
|
|
1090
|
+
/**
|
|
1091
|
+
* Whether `username` has core documents to accept (#1603), asked of
|
|
1092
|
+
* ConsentManager. A store that cannot be read asks nothing, loudly: a
|
|
1093
|
+
* broken consent folder must not lock everyone out.
|
|
1094
|
+
*/
|
|
1095
|
+
private termsPendingFor;
|
|
1033
1096
|
/**
|
|
1034
1097
|
* Start the signed-in session's lifetime (#1767), on the new session after
|
|
1035
1098
|
* regeneration: `signedInAt`, from which `ngdpbase.session.max-age` caps it
|
|
@@ -1117,32 +1180,79 @@ declare class AuthManager extends BaseManager {
|
|
|
1117
1180
|
* is not offered. Empty means the sign-in is one step, as before.
|
|
1118
1181
|
*/
|
|
1119
1182
|
secondFactorsFor(username: string): AvailableSecondFactor[];
|
|
1183
|
+
/**
|
|
1184
|
+
* Whether a successful first step needs a second (#1532, operator
|
|
1185
|
+
* 2026-10-22): everyone who arrives with only a password behind them — a
|
|
1186
|
+
* password typed here, or an outside provider's sign-in at AAL1 — when the
|
|
1187
|
+
* account has a second factor it can use now. Never after an email link
|
|
1188
|
+
* (the second factor would go to the same inbox), and never after a sign-in
|
|
1189
|
+
* already at AAL2 or above (a passkey, a provider whose second factor the
|
|
1190
|
+
* admin trusts).
|
|
1191
|
+
*/
|
|
1192
|
+
needsSecondStep(result: AuthenticateResult): boolean;
|
|
1193
|
+
/**
|
|
1194
|
+
* Roughly where `req` came from, for the approval message (#1532):
|
|
1195
|
+
* Cloudflare's location headers when `ngdpbase.server.behind-cloudflare`,
|
|
1196
|
+
* else the GeoLite2 City file at `ngdpbase.server.geolite2-city-file`, else
|
|
1197
|
+
* null. Read when asked, so a change applies at the next sign-in. The IP is
|
|
1198
|
+
* Express's `req.ip`, which honours `ngdpbase.server.trust-proxy`.
|
|
1199
|
+
*/
|
|
1200
|
+
approximateLocation(req: Pick<Request, 'ip'> & {
|
|
1201
|
+
get?: (name: string) => string | undefined;
|
|
1202
|
+
}): Promise<string | null>;
|
|
1120
1203
|
/** Hold a sign-in whose first factor passed, until a second factor completes it. Returns its handle. */
|
|
1121
1204
|
beginTwoStep(first: AuthenticateResult, ctx: {
|
|
1122
1205
|
binding: string;
|
|
1123
1206
|
ip?: string;
|
|
1124
1207
|
userAgent?: string;
|
|
1208
|
+
location?: string | null;
|
|
1125
1209
|
privateStoreHandle?: string;
|
|
1126
1210
|
}): Promise<string>;
|
|
1127
1211
|
/** The pending sign-in, for the browser that started it; null when gone, expired, or another browser. */
|
|
1128
1212
|
pendingSignIn(handle: string | undefined, binding: string | undefined): PendingSignIn | null;
|
|
1213
|
+
/**
|
|
1214
|
+
* Record one step of a sign-in's approval (#1532): every record carries the
|
|
1215
|
+
* pending sign-in's `id` (never its handle or the link token), the account
|
|
1216
|
+
* it is for, and who took the step — the IP and browser of the request, the
|
|
1217
|
+
* opener or the presser. `user` is the account signing in: there is no
|
|
1218
|
+
* session yet, so no proxy (whom to act for is chosen after sign-in).
|
|
1219
|
+
*/
|
|
1220
|
+
private recordApproval;
|
|
1221
|
+
/**
|
|
1222
|
+
* HTML escaping for the approval message and page (#1532): PluginManager's
|
|
1223
|
+
* formatter (#1820), the one escapeHtml. Its absence is a wiring fault, not
|
|
1224
|
+
* a reason to send a value unescaped.
|
|
1225
|
+
*/
|
|
1226
|
+
private escapeHtml;
|
|
1227
|
+
/** Where the sign-in asked from, as the request, send and expiry records say it. */
|
|
1228
|
+
private static askedFrom;
|
|
1129
1229
|
/**
|
|
1130
1230
|
* Email a one-time approval link for the pending sign-in (#1523, #1532).
|
|
1131
1231
|
* The link only opens the approval page; the button there approves, so a
|
|
1132
1232
|
* mail scanner opening links approves nothing. Limited to one per minute.
|
|
1233
|
+
* Each outcome is recorded: sent (`signinapproval-request`), too soon
|
|
1234
|
+
* (`-throttle`), or not sent (`-sendfail`).
|
|
1133
1235
|
*/
|
|
1134
1236
|
sendEmailApproval(handle: string, binding: string): Promise<'sent' | 'too-soon' | 'unavailable' | 'failed'>;
|
|
1135
|
-
/**
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1237
|
+
/**
|
|
1238
|
+
* What the approval page shows about the waiting sign-in; null for an
|
|
1239
|
+
* unknown or used link. Opening the page changes nothing, but it is
|
|
1240
|
+
* recorded (`signinapproval-open`) with who opened it (#1532): a mail
|
|
1241
|
+
* scanner opens links too.
|
|
1242
|
+
*/
|
|
1243
|
+
approvalDetails(token: string, opener?: ApprovalVisitor): Promise<ApprovalDetails | null>;
|
|
1244
|
+
/**
|
|
1245
|
+
* What an approval request says about a waiting sign-in (#1532), one source
|
|
1246
|
+
* for the message and the page: the site's name, when it was asked in the
|
|
1247
|
+
* account holder's own time zone (their preference, else the site default),
|
|
1248
|
+
* the browser and device it came from (null when the header names
|
|
1249
|
+
* neither), its IP address, and roughly where (null when no source says).
|
|
1250
|
+
*/
|
|
1251
|
+
private approvalContext;
|
|
1142
1252
|
/** Approve the waiting sign-in from its link (the button press). Single use; returns the username, or null. */
|
|
1143
|
-
approveByToken(token: string): string | null
|
|
1144
|
-
/** Refuse the waiting sign-in from its link (
|
|
1145
|
-
denyByToken(token: string): string | null
|
|
1253
|
+
approveByToken(token: string, presser?: ApprovalVisitor): Promise<string | null>;
|
|
1254
|
+
/** Refuse the waiting sign-in from its link (Deny). Returns the username, or null. */
|
|
1255
|
+
denyByToken(token: string, presser?: ApprovalVisitor): Promise<string | null>;
|
|
1146
1256
|
/**
|
|
1147
1257
|
* The finished sign-in, once its second factor is satisfied — both factors,
|
|
1148
1258
|
* assessed together. Single use: the pending entry is gone after this.
|
|
@@ -1151,8 +1261,15 @@ declare class AuthManager extends BaseManager {
|
|
|
1151
1261
|
result: AuthenticateResult;
|
|
1152
1262
|
privateStoreHandle?: string;
|
|
1153
1263
|
} | null;
|
|
1264
|
+
/**
|
|
1265
|
+
* The waiting browser gave up the sign-in (Cancel, #1532): dropped, its
|
|
1266
|
+
* keys locked, and recorded as `signinapproval-abandon`.
|
|
1267
|
+
*/
|
|
1268
|
+
abandonTwoStep(handle: string, binding: string, from?: ApprovalVisitor): Promise<void>;
|
|
1154
1269
|
/** Drop a pending sign-in and lock any keys it held. */
|
|
1155
1270
|
dropPending(handle: string): void;
|
|
1271
|
+
/** A pending sign-in past its time: dropped, and recorded once as `signinapproval-expire` (#1532). */
|
|
1272
|
+
private expirePending;
|
|
1156
1273
|
private sweepPending;
|
|
1157
1274
|
/**
|
|
1158
1275
|
* Start enrolling the account's email address as a second factor (#1523).
|
|
@@ -1265,25 +1382,26 @@ declare class AuthManager extends BaseManager {
|
|
|
1265
1382
|
isEnabled(providerId: string): boolean;
|
|
1266
1383
|
/** Returns all registered providers (for admin UI). */
|
|
1267
1384
|
getProviders(): AuthProvider[];
|
|
1268
|
-
/**
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
*
|
|
1272
|
-
*
|
|
1273
|
-
*
|
|
1274
|
-
*
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
*
|
|
1281
|
-
*
|
|
1282
|
-
*
|
|
1283
|
-
* restore's notification names it as not
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1385
|
+
/** `ngdpbase.auth.credentials.file`, resolved: the credentials store. */
|
|
1386
|
+
private configuredCredentialsFile;
|
|
1387
|
+
/**
|
|
1388
|
+
* The provider list. The credentials store (#1876) — passkeys, TOTP,
|
|
1389
|
+
* verified email and phone, devices, linked identities and proxy grants —
|
|
1390
|
+
* is a declared file (`backupFolders`), which BackupManager writes beside
|
|
1391
|
+
* the document as `data.credentials`.
|
|
1392
|
+
*/
|
|
1393
|
+
backup(): Promise<BackupData>;
|
|
1394
|
+
/**
|
|
1395
|
+
* The credentials store, as its own file beside the backup document,
|
|
1396
|
+
* __always encrypted under `NGDPBASE_BACKUP_KEY`__
|
|
1397
|
+
* (`<stem>.credentials.tar.enc`; #1876, declared since #1985). Without the
|
|
1398
|
+
* key it is left out and the backup is reported incomplete; it is never in
|
|
1399
|
+
* a backup unencrypted. A backup that left it out stages nothing: the store
|
|
1400
|
+
* on the site stays, and the restore's notification names it as not
|
|
1401
|
+
* restored. The rows stay signed with `NGDPBASE_CREDENTIALS_KEY`, which is
|
|
1402
|
+
* not in the backup: a site restored under another credentials key holds
|
|
1403
|
+
* them in quarantine (#1633).
|
|
1404
|
+
*/
|
|
1405
|
+
backupFolders(): BackupFolder[];
|
|
1288
1406
|
}
|
|
1289
1407
|
export default AuthManager;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import BaseManager, { type BackupData, type
|
|
1
|
+
import BaseManager, { type BackupData, type BackupFolder } from './BaseManager.js';
|
|
2
2
|
import type { WikiEngine } from '../types/WikiEngine.js';
|
|
3
3
|
import { type JobContext } from '../context/JobContext.js';
|
|
4
4
|
import { type ScheduleInput } from '../utils/schedule.js';
|
|
@@ -432,21 +432,31 @@ declare class BackgroundJobManager extends BaseManager {
|
|
|
432
432
|
*/
|
|
433
433
|
private settleAfterRestore;
|
|
434
434
|
/**
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
* them. In the database: nothing here — it is in the database backup.
|
|
435
|
+
* Where job state is kept in files, before this manager has started
|
|
436
|
+
* (#1985): null when it is in the database, held for an unavailable
|
|
437
|
+
* database, or a test's own provider. After the start, `stateDir`.
|
|
439
438
|
*/
|
|
440
|
-
|
|
441
|
-
/**
|
|
442
|
-
|
|
439
|
+
private configuredFileStateDir;
|
|
440
|
+
/**
|
|
441
|
+
* File job state (`ngdpbase.jobs.state.dir`) is a declared folder (#1876,
|
|
442
|
+
* #1985): the state, run history and checkpoints as `<stem>.jobs.tar`
|
|
443
|
+
* beside the document, without the slot locks, which belong to the server
|
|
444
|
+
* that holds them. A backup without it (its job state was in the database)
|
|
445
|
+
* stages nothing for it. Job state in the database is in the database
|
|
446
|
+
* backup, and comes back with it.
|
|
447
|
+
*/
|
|
448
|
+
backupFolders(): BackupFolder[];
|
|
449
|
+
/** Where job state is when it is not a declared folder: the database, or nowhere while held (#1876). */
|
|
450
|
+
backup(): Promise<BackupData>;
|
|
451
|
+
/** Database job state comes back with the database; file job state is a declared folder BackupManager stages (#1876, #1985). */
|
|
443
452
|
restoreIsStaged(): boolean;
|
|
444
453
|
/**
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
454
|
+
* Whether file job state was put back from a backup at this start (#1876):
|
|
455
|
+
* BaseManager applied it before this manager opened it (#1985). The runs
|
|
456
|
+
* in the state it replaced, started since the backup, are read from where
|
|
457
|
+
* it was kept, for the restore's notification.
|
|
448
458
|
*/
|
|
449
|
-
private
|
|
459
|
+
private noteRestoreAtStart;
|
|
450
460
|
/**
|
|
451
461
|
* The job runs the live application database held, since the backup, when
|
|
452
462
|
* this start's restore replaced it (#1876): read from the copy DatabaseManager
|