@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.
Files changed (59) hide show
  1. package/README.md +1 -1
  2. package/lib/packages/ngdpbase-types/index.d.ts +4 -0
  3. package/lib/src/http/egressPolicy.js +61 -0
  4. package/lib/src/http/guardedElasticsearch.d.ts +27 -0
  5. package/lib/src/http/guardedElasticsearch.js +99 -0
  6. package/lib/src/managers/AddonsManager.d.ts +37 -1
  7. package/lib/src/managers/AttachmentManager.d.ts +4 -5
  8. package/lib/src/managers/AuthManager.d.ts +150 -32
  9. package/lib/src/managers/BackgroundJobManager.d.ts +22 -12
  10. package/lib/src/managers/BackupManager.d.ts +779 -0
  11. package/lib/src/managers/BackupManager.js +2042 -0
  12. package/lib/src/managers/BaseManager.d.ts +156 -6
  13. package/lib/src/managers/BaseManager.js +64 -2
  14. package/lib/src/managers/CommentManager.d.ts +6 -6
  15. package/lib/src/managers/ConsentManager.d.ts +130 -0
  16. package/lib/src/managers/DatabaseManager.d.ts +6 -0
  17. package/lib/src/managers/DatabaseManager.js +25 -15
  18. package/lib/src/managers/FootnoteManager.d.ts +6 -6
  19. package/lib/src/managers/OrganizationManager.d.ts +8 -6
  20. package/lib/src/managers/PageManager.d.ts +16 -0
  21. package/lib/src/managers/PersonManager.d.ts +8 -6
  22. package/lib/src/managers/RoleManager.d.ts +6 -8
  23. package/lib/src/providers/BaseAttachmentProvider.d.ts +4 -4
  24. package/lib/src/providers/BaseBackupProvider.d.ts +77 -0
  25. package/lib/src/providers/BaseBackupProvider.js +52 -0
  26. package/lib/src/providers/BasicAttachmentProvider.d.ts +4 -4
  27. package/lib/src/providers/FileBackupProvider.d.ts +34 -0
  28. package/lib/src/providers/FileBackupProvider.js +116 -0
  29. package/lib/src/providers/FileConsentProvider.d.ts +18 -0
  30. package/lib/src/providers/SqliteDatabaseProvider.d.ts +2 -2
  31. package/lib/src/providers/SqliteDatabaseProvider.js +12 -11
  32. package/lib/src/providers/attachmentFilesBackup.d.ts +5 -4
  33. package/lib/src/providers/databaseMigrations.js +45 -0
  34. package/lib/src/security/PolicyDecisionPoint.d.ts +14 -0
  35. package/lib/src/types/Consent.d.ts +90 -0
  36. package/lib/src/types/express.d.ts +6 -0
  37. package/lib/src/utils/addonHost.d.ts +22 -0
  38. package/lib/src/utils/addonHost.js +14 -1
  39. package/lib/src/utils/auditEventNames.generated.d.ts +20 -0
  40. package/lib/src/utils/auditEventNames.generated.js +20 -0
  41. package/lib/src/utils/backupCrypto.js +275 -0
  42. package/lib/src/utils/backupDocument.d.ts +36 -0
  43. package/lib/src/utils/backupDocument.js +99 -0
  44. package/lib/src/utils/browserDescription.d.ts +11 -0
  45. package/lib/src/utils/directoryBackup.d.ts +48 -48
  46. package/lib/src/utils/directoryBackup.js +291 -0
  47. package/lib/src/utils/instanceEnvBackup.d.ts +46 -0
  48. package/lib/src/utils/instanceEnvBackup.js +120 -0
  49. package/lib/src/utils/instanceEnvSecret.js +79 -0
  50. package/lib/src/utils/ipLocation.d.ts +11 -0
  51. package/lib/src/utils/schedule.js +171 -0
  52. package/lib/src/utils/secretFileMode.js +78 -0
  53. package/lib/src/utils/siteRestore.d.ts +13 -16
  54. package/lib/src/utils/siteRestore.js +33 -6
  55. package/lib/src/utils/treeArchive.d.ts +2 -0
  56. package/lib/src/utils/treeArchive.js +242 -0
  57. package/lib/src/utils/version.js +408 -0
  58. package/package.json +6 -1
  59. 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<void>;
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?(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[];
@@ -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, ManagerBackupOptions, ManagerRestoreOptions } from './BaseManager.js';
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 ("this wasn't me"). */
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
- /** What the approval page shows about the waiting sign-in; null for an unknown or used link. */
1136
- approvalDetails(token: string): {
1137
- username: string;
1138
- requestedAt: number;
1139
- ip?: string;
1140
- userAgent?: string;
1141
- } | null;
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 ("this wasn't me"). Returns the username, or null. */
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
- * The provider list, and the credentials store (#1876): passkeys, TOTP,
1270
- * verified email and phone, devices, linked identities and proxy grants,
1271
- * as its own file beside the document, __always encrypted under
1272
- * `NGDPBASE_BACKUP_KEY`__ (`<stem>.credentials.tar.enc`). Without the key the
1273
- * store is left out and the backup is reported incomplete; it is never in a
1274
- * backup unencrypted. The rows stay signed with `NGDPBASE_CREDENTIALS_KEY`,
1275
- * which is not in the backup: a site restored under another credentials key
1276
- * holds them in quarantine (#1633).
1277
- */
1278
- backup(options?: ManagerBackupOptions): Promise<BackupData>;
1279
- /**
1280
- * Stage the credentials store from a backup for the next start, with the
1281
- * rest of a site restore (#1876). A backup that left it out (no backup key
1282
- * when it was taken) stages nothing: the store on the site stays, and the
1283
- * restore's notification names it as not restored.
1284
- */
1285
- restore(backupData: BackupData, options?: ManagerRestoreOptions): Promise<void>;
1286
- /** Staged for the next start with the rest of a site restore (#1876). */
1287
- restoreIsStaged(): boolean;
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 ManagerBackupOptions, type ManagerRestoreOptions } from './BaseManager.js';
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
- * Job state in a backup (#1876). Files (`ngdpbase.jobs.state.dir`): the
436
- * state, run history and checkpoints as `<stem>.jobs.tar` beside the
437
- * document, without the slot locks, which belong to the server that holds
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
- backup(options?: ManagerBackupOptions): Promise<BackupData>;
441
- /** File job state is staged for the next start with the rest of a site restore (#1876). Database job state comes back with the database. */
442
- restore(backupData: BackupData, options?: ManagerRestoreOptions): Promise<void>;
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
- * Put staged file job state in place, at start, before it is opened
446
- * (#1876). The runs in the live state since the backup was taken are read
447
- * first, before it is replaced, for the restore's notification.
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 applyRestoreAtStart;
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