@jwilleke/ngdpbase-types 5.22.0 → 5.24.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.
@@ -56,6 +56,10 @@ export interface GuardedFetchOptions {
56
56
  maxRedirects?: number;
57
57
  maxBytes?: number;
58
58
  timeoutMs?: number;
59
+ /**
60
+ * Request headers. Without a `User-Agent` (any case), the configured default
61
+ * is added (#1947, ./userAgent.ts).
62
+ */
59
63
  headers?: Record<string, string>;
60
64
  method?: 'GET' | 'HEAD' | 'POST';
61
65
  /**
@@ -27,6 +27,7 @@ import { Agent as HttpAgent, request as httpRequest } from 'node:http';
27
27
  import { Agent as HttpsAgent, request as httpsRequest } from 'node:https';
28
28
  import { validateUrl } from './ssrf.js';
29
29
  import { guardedLookup } from './guardedLookup.js';
30
+ import { defaultUserAgent } from './userAgent.js';
30
31
  const DEFAULTS = { maxRedirects: 5, maxBytes: 8 * 1024 * 1024, timeoutMs: 30_000 };
31
32
  /** Statuses that carry a `Location` worth following. */
32
33
  const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
@@ -57,6 +58,16 @@ async function readCapped(body, maxBytes) {
57
58
  }
58
59
  return Buffer.concat(parts);
59
60
  }
61
+ /** The caller's headers, plus the default User-Agent when they name none (#1947). */
62
+ function withDefaultUserAgent(headers) {
63
+ const out = { ...headers };
64
+ if (Object.keys(out).some((name) => name.toLowerCase() === 'user-agent'))
65
+ return out;
66
+ const userAgent = defaultUserAgent();
67
+ if (userAgent)
68
+ out['User-Agent'] = userAgent;
69
+ return out;
70
+ }
60
71
  /** The first header value, since node presents repeated headers as an array. */
61
72
  function headerValue(value) {
62
73
  return Array.isArray(value) ? value[0] : value;
@@ -74,6 +85,7 @@ export async function guardedFetch(target, options) {
74
85
  const timeoutMs = options.timeoutMs ?? DEFAULTS.timeoutMs;
75
86
  const transport = options.transport ?? nodeTransport;
76
87
  const body = options.body === undefined ? undefined : Buffer.from(options.body);
88
+ const headers = withDefaultUserAgent(options.headers);
77
89
  // keepAlive: false is load-bearing. A pooled connection skips resolution on
78
90
  // reuse, so the guard would run for the first request to a host and never
79
91
  // again — every later request riding a connection validated once, long ago.
@@ -90,7 +102,7 @@ export async function guardedFetch(target, options) {
90
102
  chain.push(url.toString());
91
103
  const res = await transport(url, {
92
104
  method: options.method ?? 'GET',
93
- headers: body ? { ...options.headers, 'Content-Length': String(body.length) } : (options.headers ?? {}),
105
+ headers: body ? { ...headers, 'Content-Length': String(body.length) } : headers,
94
106
  agent: url.protocol === 'https:' ? agents.https : agents.http,
95
107
  timeoutMs,
96
108
  ...(body ? { body } : {})
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The default `User-Agent` of outbound requests (#1947).
3
+ *
4
+ * Node's `http.request` adds no User-Agent, so before this every request from
5
+ * `guardedFetch` went out without one, and APIs that require one refused it
6
+ * (api.weather.gov answers 403). A caller that names its own User-Agent keeps
7
+ * it; every other request gets the one built here.
8
+ *
9
+ * The value is configuration, `ngdpbase.http.user-agent`, a template read on
10
+ * every request so an operator's change applies without a restart:
11
+ *
12
+ * - `{version}` is `ngdpbase.version`;
13
+ * - `{base-url}` is `ngdpbase.application.base-url`, only when it was set
14
+ * explicitly. When it was not, the localhost default would be a wrong
15
+ * contact, so a parenthesised part holding `{base-url}` is left out,
16
+ * brackets and all (`ngdpbase/5.23.0 (+{base-url})` → `ngdpbase/5.23.0`);
17
+ * - an empty template sends no default User-Agent.
18
+ *
19
+ * `guardedFetch` has no engine to read configuration from, so the engine hands
20
+ * it the source at boot (`setUserAgentSource`), the way the log-redaction table
21
+ * is filled. Until then, and in tests that set none, no default is added.
22
+ */
23
+ export declare const USER_AGENT_KEY = "ngdpbase.http.user-agent";
24
+ /** What reading the template needs from `ConfigurationManager`. */
25
+ export interface UserAgentConfig {
26
+ getProperty(key: string, fallback?: unknown): unknown;
27
+ isBaseUrlExplicit?(): boolean;
28
+ }
29
+ /** Fill a template. `baseUrl` null leaves out the part that names it. */
30
+ export declare function renderUserAgent(template: string, version: string, baseUrl: string | null): string;
31
+ /** The User-Agent the configuration asks for, '' for none. */
32
+ export declare function userAgentFromConfig(config: UserAgentConfig): string;
33
+ /** Set at boot by the engine; `null` clears it (tests). */
34
+ export declare function setUserAgentSource(next: (() => string) | null): void;
35
+ /** The default User-Agent now, '' when none is configured. */
36
+ export declare function defaultUserAgent(): string;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The default `User-Agent` of outbound requests (#1947).
3
+ *
4
+ * Node's `http.request` adds no User-Agent, so before this every request from
5
+ * `guardedFetch` went out without one, and APIs that require one refused it
6
+ * (api.weather.gov answers 403). A caller that names its own User-Agent keeps
7
+ * it; every other request gets the one built here.
8
+ *
9
+ * The value is configuration, `ngdpbase.http.user-agent`, a template read on
10
+ * every request so an operator's change applies without a restart:
11
+ *
12
+ * - `{version}` is `ngdpbase.version`;
13
+ * - `{base-url}` is `ngdpbase.application.base-url`, only when it was set
14
+ * explicitly. When it was not, the localhost default would be a wrong
15
+ * contact, so a parenthesised part holding `{base-url}` is left out,
16
+ * brackets and all (`ngdpbase/5.23.0 (+{base-url})` → `ngdpbase/5.23.0`);
17
+ * - an empty template sends no default User-Agent.
18
+ *
19
+ * `guardedFetch` has no engine to read configuration from, so the engine hands
20
+ * it the source at boot (`setUserAgentSource`), the way the log-redaction table
21
+ * is filled. Until then, and in tests that set none, no default is added.
22
+ */
23
+ export const USER_AGENT_KEY = 'ngdpbase.http.user-agent';
24
+ /** Fill a template. `baseUrl` null leaves out the part that names it. */
25
+ export function renderUserAgent(template, version, baseUrl) {
26
+ let out = template.split('{version}').join(version);
27
+ out = baseUrl
28
+ ? out.split('{base-url}').join(baseUrl)
29
+ : out.replace(/\s*\([^()]*\{base-url\}[^()]*\)/g, '').split('{base-url}').join('');
30
+ return out.trim();
31
+ }
32
+ /** The User-Agent the configuration asks for, '' for none. */
33
+ export function userAgentFromConfig(config) {
34
+ const template = config.getProperty(USER_AGENT_KEY, '');
35
+ if (typeof template !== 'string' || template.trim() === '')
36
+ return '';
37
+ const text = (key) => {
38
+ const value = config.getProperty(key, '');
39
+ return typeof value === 'string' ? value : '';
40
+ };
41
+ const version = text('ngdpbase.version');
42
+ const baseUrl = config.isBaseUrlExplicit?.() ? text('ngdpbase.application.base-url').replace(/\/+$/, '') : '';
43
+ return renderUserAgent(template, version, baseUrl || null);
44
+ }
45
+ let source = null;
46
+ /** Set at boot by the engine; `null` clears it (tests). */
47
+ export function setUserAgentSource(next) {
48
+ source = next;
49
+ }
50
+ /** The default User-Agent now, '' when none is configured. */
51
+ export function defaultUserAgent() {
52
+ return source ? source() : '';
53
+ }
@@ -620,7 +620,9 @@ declare class AuthManager extends BaseManager {
620
620
  * Disable and delete both come through here, from UserManager, so nothing
621
621
  * an account holds is left reachable by one path and not the other.
622
622
  *
623
- * - Both: every session of the account ends.
623
+ * - Both: every session of the account ends, destroyed in the live session
624
+ * store (#1937); a session the store could not reach is still refused on
625
+ * its next request (`checkSessionAccount`).
624
626
  * - Disable: its agent tokens, app grants and shares are suspended by the
625
627
  * live owner-is-active checks that refuse them, and its credentials stay,
626
628
  * so turning the account back on restores them.
@@ -1039,7 +1041,8 @@ declare class AuthManager extends BaseManager {
1039
1041
  /**
1040
1042
  * Stamp the session with the account's password-change generation (#1482):
1041
1043
  * at every sign-in, and on the session that changed the password, so that
1042
- * one stays signed in while every other session of the account ends.
1044
+ * one stays signed in while every other session of the account ends. Also
1045
+ * binds the session to the account itself (`accountCreatedAt`, #1937).
1043
1046
  */
1044
1047
  stampSessionGeneration(req: Request, username: string): Promise<void>;
1045
1048
  /**
@@ -319,6 +319,14 @@ declare class BackgroundJobManager extends BaseManager {
319
319
  private startDrain;
320
320
  /** Every scheduled job, its state and its recent history. */
321
321
  listScheduledJobs(): Promise<ScheduledJobView[]>;
322
+ /**
323
+ * One scheduled job as `listScheduledJobs` shows it, without its run
324
+ * history, or null when no job of that id is scheduled. For a manager that
325
+ * shows its own job's next run (BackupManager, #1563) without reading every
326
+ * job's history.
327
+ */
328
+ scheduledJobView(jobId: string): Promise<ScheduledJobView | null>;
329
+ private viewOf;
322
330
  private scheduledOrThrow;
323
331
  /** Refused while the job runs, waits for a retry, or the server is stopping: one run of a job at a time. */
324
332
  private assertIdle;
@@ -114,6 +114,20 @@ export interface ManagerRestoreOptions {
114
114
  * nobody reads, and then the real warning is missed with it.
115
115
  */
116
116
  export type ManagerState = 'ready' | 'degraded' | 'disabled' | 'failed';
117
+ /**
118
+ * What a `degraded` or `failed` state means for the person reading it (#1946).
119
+ *
120
+ * - `outage` — the feature is NOT working: what it provides is not happening
121
+ * - `attention` — the feature works, but an administrator has something to do:
122
+ * save the backup key off the server, fix a mistyped value that is read as
123
+ * false, move vault files in. Telling them their backups are "silently doing
124
+ * nothing" while backups run is a false alarm, and false alarms teach people
125
+ * to skip the real one.
126
+ *
127
+ * Declared where the state is set (`markDegraded` versus `markNeedsAttention`),
128
+ * never inferred from the reason's wording.
129
+ */
130
+ export type DegradedKind = 'outage' | 'attention';
117
131
  /**
118
132
  * What a manager holds, in one shape (#1006).
119
133
  *
@@ -147,6 +161,8 @@ export interface ManagerStatus {
147
161
  reason?: string;
148
162
  /** The config key at fault, when there is one, so the admin UI can link to it. */
149
163
  configKey?: string;
164
+ /** Whether the feature is down or works but needs an admin (#1946). Present when degraded or failed. */
165
+ kind?: DegradedKind;
150
166
  }
151
167
  declare abstract class BaseManager {
152
168
  /** Reference to the wiki engine */
@@ -262,6 +278,13 @@ declare abstract class BaseManager {
262
278
  * the signal is buried in its own noise.
263
279
  */
264
280
  protected markDegraded(reason: string, configKey?: string): boolean;
281
+ /**
282
+ * Record that this manager works, but an administrator has something to do
283
+ * (#1946). Still `degraded` — it stays on the dashboard, is audited, and
284
+ * clears with `markReadyIfDegradedBy` — but shown as needing attention, not
285
+ * as down.
286
+ */
287
+ protected markNeedsAttention(reason: string, configKey?: string): boolean;
265
288
  /** Record that this manager is deliberately off. Not a problem (#1155). */
266
289
  protected markDisabled(reason: string): boolean;
267
290
  /** Record that this manager could not initialise at all (#1155). */
@@ -162,7 +162,16 @@ class BaseManager {
162
162
  * the signal is buried in its own noise.
163
163
  */
164
164
  markDegraded(reason, configKey) {
165
- return this.transition({ state: 'degraded', reason, configKey });
165
+ return this.transition({ state: 'degraded', reason, configKey, kind: 'outage' });
166
+ }
167
+ /**
168
+ * Record that this manager works, but an administrator has something to do
169
+ * (#1946). Still `degraded` — it stays on the dashboard, is audited, and
170
+ * clears with `markReadyIfDegradedBy` — but shown as needing attention, not
171
+ * as down.
172
+ */
173
+ markNeedsAttention(reason, configKey) {
174
+ return this.transition({ state: 'degraded', reason, configKey, kind: 'attention' });
166
175
  }
167
176
  /** Record that this manager is deliberately off. Not a problem (#1155). */
168
177
  markDisabled(reason) {
@@ -170,7 +179,7 @@ class BaseManager {
170
179
  }
171
180
  /** Record that this manager could not initialise at all (#1155). */
172
181
  markFailed(reason, configKey) {
173
- return this.transition({ state: 'failed', reason, configKey });
182
+ return this.transition({ state: 'failed', reason, configKey, kind: 'outage' });
174
183
  }
175
184
  /**
176
185
  * Record that this manager is working.
@@ -205,7 +214,7 @@ class BaseManager {
205
214
  * PROVABLE.
206
215
  */
207
216
  transition(next) {
208
- const changed = this.status.state !== next.state || this.status.reason !== next.reason;
217
+ const changed = this.status.state !== next.state || this.status.reason !== next.reason || this.status.kind !== next.kind;
209
218
  this.status = next;
210
219
  if (!changed)
211
220
  return false;
@@ -30,6 +30,16 @@ export default class CommentManager extends BaseManager {
30
30
  * clear.
31
31
  */
32
32
  private place;
33
+ /**
34
+ * Whether comments are on for `page` (#1951): the page's `comments:` flag,
35
+ * else its system-category's, else on (`pageNotesAllowed`). The view asks
36
+ * this before it shows the Comments tab or the plugin's list, and every
37
+ * write here asks it first. When off, the page's comments are kept and
38
+ * hidden. `metadata` saves a read when the caller already holds it.
39
+ */
40
+ isAllowedOn(page: PageRef, ctx: ActorContext, metadata?: Record<string, unknown> | null): Promise<boolean>;
41
+ /** Throws {@link PageNotesTurnedOffError} when comments are off for `page` (#1951). */
42
+ private refuseWhenOff;
33
43
  private commentFile;
34
44
  /**
35
45
  * A page's comments, read as `ctx` may (#1839). Whether `ctx` may read the
@@ -216,6 +216,8 @@ declare class DatabaseManager extends BaseManager {
216
216
  /** Degrade for one cause; the dashboard shows every cause still standing. */
217
217
  private fault;
218
218
  private clearFault;
219
+ /** Every cause still standing; down when any one of them is (#1946). */
220
+ private markFaults;
219
221
  private notify;
220
222
  /** Whether every open database passes SQLite's quick check; true when none is configured, false when the configured one could not be opened (#1892). */
221
223
  integrityOk(): boolean;
@@ -164,7 +164,8 @@ class DatabaseManager extends BaseManager {
164
164
  const keyProblem = this.backupKeyProblem();
165
165
  if (keyProblem) {
166
166
  logger.error(`[DatabaseManager] ${keyProblem}`);
167
- this.fault('backup-key', keyProblem);
167
+ // No key: no database is backed up (down). The database key reused: still backed up (attention, #1946).
168
+ this.fault('backup-key', keyProblem, this.backupKey() ? 'attention' : 'outage');
168
169
  }
169
170
  }
170
171
  /** Whether an application database is open. */
@@ -346,7 +347,7 @@ class DatabaseManager extends BaseManager {
346
347
  const backupKey = this.backupKey();
347
348
  if (!backupKey) {
348
349
  const reason = `${LEFT_OUT_FOR_NO_KEY}no database was backed up. Database backups are always encrypted and are never written without it (#1723)`;
349
- this.fault('backup-key', reason);
350
+ this.fault('backup-key', reason, 'outage');
350
351
  throw new Error(reason);
351
352
  }
352
353
  const key = process.env[DATABASE_KEY_ENV] ?? '';
@@ -354,7 +355,7 @@ class DatabaseManager extends BaseManager {
354
355
  if (keyProblem) {
355
356
  // D3: still backed up, but as loudly as a missing key.
356
357
  logger.error(`[DatabaseManager] ${keyProblem}`);
357
- this.fault('backup-key', keyProblem);
358
+ this.fault('backup-key', keyProblem, 'attention');
358
359
  await this.notify('error', 'The backup key is the database key', `${keyProblem}. This backup was still taken.`);
359
360
  }
360
361
  else {
@@ -642,7 +643,8 @@ class DatabaseManager extends BaseManager {
642
643
  if (outcome.refused) {
643
644
  const reason = `A staged database restore was NOT applied: ${outcome.refused}. The site started on its current databases (#1723)`;
644
645
  logger.error(`[DatabaseManager] ${reason}`);
645
- this.fault('restore', reason);
646
+ // The site runs on its current databases; an admin decides about the restore (#1946).
647
+ this.fault('restore', reason, 'attention');
646
648
  return;
647
649
  }
648
650
  this.restoredNow = outcome.applied.map((a) => a.name);
@@ -677,9 +679,9 @@ class DatabaseManager extends BaseManager {
677
679
  return configured;
678
680
  }
679
681
  /** Degrade for one cause; the dashboard shows every cause still standing. */
680
- fault(cause, reason) {
681
- this.faults.set(cause, reason);
682
- this.markDegraded([...this.faults.values()].join('; '));
682
+ fault(cause, reason, kind) {
683
+ this.faults.set(cause, { reason, kind });
684
+ this.markFaults();
683
685
  }
684
686
  clearFault(cause) {
685
687
  if (!this.faults.delete(cause))
@@ -687,7 +689,16 @@ class DatabaseManager extends BaseManager {
687
689
  if (this.faults.size === 0)
688
690
  this.markReady();
689
691
  else
690
- this.markDegraded([...this.faults.values()].join('; '));
692
+ this.markFaults();
693
+ }
694
+ /** Every cause still standing; down when any one of them is (#1946). */
695
+ markFaults() {
696
+ const faults = [...this.faults.values()];
697
+ const reason = faults.map((f) => f.reason).join('; ');
698
+ if (faults.some((f) => f.kind === 'outage'))
699
+ this.markDegraded(reason);
700
+ else
701
+ this.markNeedsAttention(reason);
691
702
  }
692
703
  async notify(level, title, message) {
693
704
  try {
@@ -37,6 +37,19 @@ export default class FootnoteManager extends BaseManager {
37
37
  * the clear.
38
38
  */
39
39
  private place;
40
+ /**
41
+ * Whether footnotes are on for `page` (#1951): the page's `footnotes:` flag,
42
+ * else its system-category's, else on (`pageNotesAllowed`). Each page is
43
+ * asked about itself, so one page's flag never reaches another's. The view
44
+ * asks this before it shows the Footnotes tab, and every write here asks it
45
+ * first. When off, the page's footnotes are kept and stay readable where
46
+ * the page's own content lists them (`[{FootnotesPlugin}]`), without the
47
+ * add, edit and delete controls. `metadata` saves a read when the caller
48
+ * already holds it.
49
+ */
50
+ isAllowedOn(page: PageRef, ctx: ActorContext, metadata?: Record<string, unknown> | null): Promise<boolean>;
51
+ /** Throws {@link PageNotesTurnedOffError} when footnotes are off for `page` (#1951). */
52
+ private refuseWhenOff;
40
53
  private readMap;
41
54
  private writeMap;
42
55
  /**
@@ -1273,6 +1273,22 @@ declare class PageManager extends BaseManager implements CatalogSource {
1273
1273
  * @param action - Legacy verb (`view`, `edit`) or policy action; default `view`
1274
1274
  */
1275
1275
  listPagesFor(subject: PermissionSubject | null | undefined, action?: string): Promise<string[]>;
1276
+ /**
1277
+ * Which of these named pages may `subject` perform `action` on — the
1278
+ * {@link listPagesFor} door for a list someone else found: search hits
1279
+ * (#1962), whatever index they came from.
1280
+ *
1281
+ * Each name is resolved as `/view/<name>` resolves it, and the batch is
1282
+ * decided by `PolicyInformationPoint.filterAccessiblePages`, so a hit is
1283
+ * kept exactly when its reader may open the page. A name that resolves to
1284
+ * no page (a ghost in the index) is dropped. Order is preserved; no log line
1285
+ * or audit record per page. Without a PolicyInformationPoint nothing is kept.
1286
+ *
1287
+ * @param subject - The reader's own context, forwarded
1288
+ * @param names - Page names, as the index holds them
1289
+ * @param action - Legacy verb (`view`, `edit`) or policy action; default `view`
1290
+ */
1291
+ filterPagesFor(subject: ActorContext | PermissionSubject | null | undefined, names: readonly string[], action?: string): Promise<string[]>;
1276
1292
  /**
1277
1293
  * Get all page titles (explicit alias for getAllPages)
1278
1294
  * Prefer this for new code that only needs page names.
@@ -186,6 +186,28 @@ declare class SearchManager extends BaseManager {
186
186
  * console.log(`Found ${results.length} pages`);
187
187
  */
188
188
  searchWithContext(wikiContext: WikiContext, query: string, options?: SearchOptions): Promise<SearchResult[]>;
189
+ /**
190
+ * How many hits a search returns when its caller does not say:
191
+ * `ngdpbase.search.maxresults`.
192
+ */
193
+ private resultLimit;
194
+ /**
195
+ * The provider's hits that this reader may open, up to `wanted` (#1962).
196
+ *
197
+ * The search index knows nothing of a page's audience, its `access` rules
198
+ * or the policies, so every hit is decided by the page-list door
199
+ * (`PageManager.filterPagesFor` → `PolicyInformationPoint.filterAccessiblePages`),
200
+ * the same decision `/view` makes, in one batch per round. The decision is
201
+ * made BEFORE the limit: the provider is asked for `wanted`, and asked again
202
+ * for twice as many while hidden hits leave the page short and the index
203
+ * may hold more, so a result count, a total and a page number never count a
204
+ * page the reader cannot open. `ngdpbase.search.access-filter.max-candidates`
205
+ * bounds how many hits one search may decide; at the bound the result is
206
+ * short rather than the site slow.
207
+ *
208
+ * Without the door nothing is returned — never the unfiltered index.
209
+ */
210
+ private hitsVisibleTo;
189
211
  /**
190
212
  * Public results, plus the requester's own private pages (#1458).
191
213
  *
@@ -0,0 +1,94 @@
1
+ /**
2
+ * SessionStatsManager — in-process reads of the session store (#1246).
3
+ *
4
+ * Two things want to know how many sessions exist and who holds them: the
5
+ * `/api/session-count` and `/api/session-users` routes, and SessionsPlugin,
6
+ * which renders those numbers into a page. The plugin used to get them by
7
+ * making an HTTP request to this server's own address through a bare global
8
+ * `fetch` — an outbound call outside `src/http/` (#1133) that `guardedFetch`
9
+ * cannot take, because loopback is refused unconditionally (#1186), and one
10
+ * that silently rendered `0` in any container whose configured host was not
11
+ * reachable from inside the process.
12
+ *
13
+ * The store is an in-process object. Nothing about reading it needs a socket.
14
+ * app.ts attaches the store it built for express-session; the routes keep
15
+ * reading `req.sessionStore` (the same object) and both go through the same
16
+ * two helpers, so there is exactly one implementation of "count sessions" and
17
+ * "list session users".
18
+ */
19
+ import BaseManager from './BaseManager.js';
20
+ import type { WikiEngine } from '../types/WikiEngine.js';
21
+ /** The two optional store methods express-session stores may implement. */
22
+ export interface SessionStoreLike {
23
+ length?(callback: (err: unknown, count?: number) => void): void;
24
+ all?(callback: (err: unknown, sessions?: unknown) => void): void;
25
+ /** session-file-store: the session file names, not the sessions. */
26
+ list?(callback: (err: unknown, files?: string[]) => void): void;
27
+ get?(sid: string, callback: (err: unknown, session?: unknown) => void): void;
28
+ destroy?(sid: string, callback: (err: unknown) => void): void;
29
+ /** session-file-store keeps its file extension here (default `.json`). */
30
+ options?: {
31
+ fileExtension?: string;
32
+ };
33
+ }
34
+ export interface SessionCount {
35
+ sessionCount: number;
36
+ distinctUsers: number;
37
+ }
38
+ export interface SessionUsers {
39
+ users: string[];
40
+ anonymous: number;
41
+ total: number;
42
+ }
43
+ /** Thrown when the store implements neither `length` nor `all`. */
44
+ export declare class SessionStoreUnsupportedError extends Error {
45
+ constructor(what: string);
46
+ }
47
+ /**
48
+ * Session count and distinct-user count. Prefers `length` (cheap), falls back
49
+ * to `all`. Under `length` the distinct-user figure equals the session count,
50
+ * which is what the route always reported on that path.
51
+ */
52
+ export declare function countSessions(store: SessionStoreLike): Promise<SessionCount>;
53
+ /**
54
+ * Authenticated usernames (sorted, distinct) and the anonymous session count.
55
+ * Needs `all`; under `length` alone the list is empty and every session is
56
+ * reported as anonymous.
57
+ */
58
+ export declare function listSessionUsers(store: SessionStoreLike): Promise<SessionUsers>;
59
+ /** A session in the store, with its id. */
60
+ export interface StoredSession {
61
+ sid: string;
62
+ session: Record<string, unknown>;
63
+ }
64
+ /**
65
+ * Every session in the store with its id (#1937): `all()` keyed by id where
66
+ * the store has it, else list and read each (the file store). A store whose
67
+ * `all()` returns a bare array gives no ids, so it cannot be listed this way.
68
+ */
69
+ export declare function storedSessions(store: SessionStoreLike): Promise<StoredSession[]>;
70
+ /**
71
+ * The private-store handles held by sessions still in the store (#1626).
72
+ * Uses `all()` where the store has it, else lists and reads each session:
73
+ * the file store has no `all()`, so the sweep failed on every run there and
74
+ * no orphaned key was ever dropped.
75
+ */
76
+ export declare function liveSessionHandles(store: SessionStoreLike): Promise<Set<string>>;
77
+ declare class SessionStatsManager extends BaseManager {
78
+ private store;
79
+ constructor(engine: WikiEngine);
80
+ /** app.ts hands over the store it built for express-session. */
81
+ attachStore(store: SessionStoreLike): void;
82
+ hasStore(): boolean;
83
+ /** @throws Error when no store is attached; SessionStoreUnsupportedError when it cannot count. */
84
+ count(): Promise<SessionCount>;
85
+ /** @throws Error when no store is attached; SessionStoreUnsupportedError when it cannot list. */
86
+ users(): Promise<SessionUsers>;
87
+ /** @throws Error when no store is attached; SessionStoreUnsupportedError when it cannot list. */
88
+ liveHandles(): Promise<Set<string>>;
89
+ /** Every session in the store with its id (#1937). @throws Error when no store is attached; SessionStoreUnsupportedError when it cannot list by id. */
90
+ sessions(): Promise<StoredSession[]>;
91
+ /** Remove one session from the store (#1937). @throws Error when no store is attached, or SessionStoreUnsupportedError when it cannot destroy. */
92
+ destroy(sid: string): Promise<void>;
93
+ }
94
+ export default SessionStatsManager;
@@ -488,7 +488,27 @@ declare class UserManager extends BaseManager {
488
488
  createSession(username: string, additionalData?: Record<string, unknown>): Promise<string>;
489
489
  getSession(sessionId: string): Promise<UserSession | null>;
490
490
  deleteSession(sessionId: string): Promise<void>;
491
- deleteUserSessions(username: string): Promise<void>;
491
+ /**
492
+ * End every signed-in session of `username` in the live session store
493
+ * (#1937): the account was deleted or turned off (`AuthManager.closeAccess`).
494
+ * Each goes through `endSession` (its keys dropped, `authentication-logout`
495
+ * recorded with the reason) and is removed from the store. Returns how many.
496
+ * The provider's own session list was cleared here before; nothing writes it,
497
+ * and the store express-session reads was never touched.
498
+ */
499
+ deleteUserSessions(username: string, reason: 'account-deleted' | 'account-disabled', from?: {
500
+ ipAddress?: string;
501
+ }): Promise<number>;
502
+ /**
503
+ * End every signed-in session in the store that no longer belongs to its
504
+ * account (#1937): the account is gone, turned off, another account of the
505
+ * same name, or its password changed since (`checkSessionAccount`, the
506
+ * per-request rule). Run once at startup, so sessions left from before the
507
+ * fix, or from a close the store missed, do not wait for their next request.
508
+ */
509
+ endStaleSessions(): Promise<number>;
510
+ /** End and remove each signed-in session `which` names a reason for; none without a session store. */
511
+ private endStoredSessions;
492
512
  backup(): Promise<BackupData>;
493
513
  restore(backupData: BackupData): Promise<void>;
494
514
  }
@@ -76,6 +76,10 @@ export interface CategoryConfig {
76
76
  source?: CategorySource;
77
77
  enabled?: boolean;
78
78
  key?: string;
79
+ /** `false`: its pages show no Comments tab and take no comment, unless a page says otherwise (#1951). */
80
+ comments?: boolean;
81
+ /** `false`: its pages show no Footnotes tab and take no footnote change, unless a page says otherwise (#1951). */
82
+ footnotes?: boolean;
79
83
  }
80
84
  /**
81
85
  * System categories configuration map
@@ -148,6 +148,10 @@ declare class LunrSearchProvider extends BaseSearchProvider {
148
148
  * owner's private container: it is found only by its owner — no role, and no
149
149
  * frontmatter audience, reaches in (docs/private-stores.md, Access;
150
150
  * the same rule PolicyInformationPoint applies at Tier 0).
151
+ *
152
+ * This only narrows; it is not the access decision. Audience, `access`
153
+ * rules and the policies are not here: SearchManager puts every hit through
154
+ * the page-list door before it reaches anyone (#1962).
151
155
  */
152
156
  private isDocVisible;
153
157
  /**
@@ -5,6 +5,19 @@ import type { Request } from 'express';
5
5
  import type { PageFrontmatter } from '../types/Page.js';
6
6
  import { type ShareGrant } from '../types/Share.js';
7
7
  import type { MediaItem } from '../providers/BaseMediaProvider.js';
8
+ /**
9
+ * The system principal's name from configuration, or a refusal (#631, #1953).
10
+ *
11
+ * `ngdpbase.system.principal` ships as the bare env-ref `$NGDPBASE_SYSTEM_USER`,
12
+ * which throws when the variable is unset; an empty value is refused the same
13
+ * way. One rule for both readers: the PIP at run time, and `WikiEngine` right
14
+ * after configuration loads, before any manager writes — so a first boot
15
+ * without a principal refuses before the admin account exists, not after it
16
+ * was written without its person (#1953).
17
+ */
18
+ export declare function systemPrincipalFromConfig(configManager: {
19
+ getProperty(key: string, defaultValue: unknown): unknown;
20
+ } | null | undefined): string;
8
21
  /**
9
22
  * Minimal WikiContext interface for type safety
10
23
  * TODO: Convert WikiContext.js to TypeScript and import proper type
@@ -30,6 +30,8 @@ declare module 'express-session' {
30
30
  privateStoreHandle?: string;
31
31
  /** The account's password-change generation when this session signed in (#1482). */
32
32
  sessionGeneration?: number;
33
+ /** The `createdAt` of the account this session signed in to (#1937): a later account of the same name is not it. */
34
+ accountCreatedAt?: string;
33
35
  /** How this session signed in: provider, factors with their times, amr / aal / acr (#1523). */
34
36
  signIn?: SignInRecord;
35
37
  /** A pending WebAuthn challenge (#448): single-use, tied to its purpose, short-lived. */
@@ -39,6 +39,8 @@ export declare function archiveReadKey(manager: string, data: {
39
39
  export declare function backupDirectoryPart(manager: string, part: string, folder: string | null, options?: ManagerBackupOptions, extra?: {
40
40
  exclude?: (relative: string) => boolean;
41
41
  }): Promise<BackupData>;
42
+ /** The log's note of entries deleted while they were backed up (#1563): skipped, not a failure. */
43
+ export declare function vanishedNote(vanished: string[]): string;
42
44
  /**
43
45
  * Stage the folder from a backup for the next start: the archive is
44
46
  * extracted to `<folder>.staged`, and the swap recorded in the site restore.
@@ -61,6 +61,28 @@ export declare function pageNotesPath(page: PageRef, roots: PageNotesRoots): Pag
61
61
  export declare function pageNotesPlace(ctx: ActorContext, page: PageRef, roots: PageNotesRoots): Promise<PageNotesPath & {
62
62
  io: StoreFileIO;
63
63
  }>;
64
+ /**
65
+ * Whether `kind` is on for a page (#1951, docs/system-category.md): THE rule,
66
+ * said once. The page's own frontmatter (`comments:` / `footnotes:`) wins;
67
+ * without it, its system-category entry's field of the same name decides;
68
+ * unset is on. Only a boolean counts, and only `false` turns it off.
69
+ *
70
+ * Pure. {@link pageNotesAllowed} gathers the page's metadata and entry for it.
71
+ */
72
+ export declare function pageNotesOn(kind: PageNotesKind, pageMetadata: Record<string, unknown> | null | undefined, categoryEntry: Record<string, unknown> | null | undefined): boolean;
73
+ /**
74
+ * {@link pageNotesOn} for `page`, as `ctx` reads it (#1951): its metadata
75
+ * (`metadata` when the caller already holds it, else the stored page's) and
76
+ * its system-category entry, the default system-category's when the page
77
+ * names none. A page not stored yet has no metadata, so its system-category
78
+ * decides. The managers' `isAllowedOn` doors are this; nothing else calls it.
79
+ */
80
+ export declare function pageNotesAllowed(engine: EngineLike, kind: PageNotesKind, page: PageRef, ctx: ActorContext, metadata?: Record<string, unknown> | null): Promise<boolean>;
81
+ /** A footnote or comment write refused because that kind is turned off for the page (#1951). Routes answer it with 403. */
82
+ export declare class PageNotesTurnedOffError extends Error {
83
+ readonly kind: PageNotesKind;
84
+ constructor(kind: PageNotesKind);
85
+ }
64
86
  /**
65
87
  * Move a page's notes when the page changes place — into a vault, out of one,
66
88
  * or between two — through both sides' I/O, as its history moves: sealed going
@@ -115,8 +137,8 @@ export declare function isVaultPageUuid(pagesDirectory: string, layout: PrivateS
115
137
  }, uuid: string): Promise<boolean>;
116
138
  /** The manager state a startup check sets and clears: the manager's own (BaseManager). */
117
139
  export interface CheckStateHooks {
118
- /** True when this is a new state. */
119
- markDegraded(reason: string): boolean;
140
+ /** True when this is a new state. The pages work; an admin moves the notes in, so it needs attention, not down (#1946). */
141
+ markNeedsAttention(reason: string): boolean;
120
142
  /** Clears the degraded state only while it is still `reason`, so another fault stays reported. */
121
143
  markReadyIfDegradedBy(reason: string): boolean;
122
144
  }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Where an operator reads about the settings a boot refuses without (#1953).
3
+ *
4
+ * SETUP.md's "Required settings" section lists each one with why; `.env.example`
5
+ * is the full list of variables. Every refusal for a missing or wrong required
6
+ * setting ends with this line, so the operator who hits one learns about the
7
+ * others in the same read instead of one boot at a time.
8
+ */
9
+ export declare const REQUIRED_SETTINGS_HELP = "See \"Required settings\" in SETUP.md: https://github.com/jwilleke/ngdpbase/blob/master/SETUP.md#required-settings";
@@ -0,0 +1,26 @@
1
+ export type AccountEndReason = 'account-deleted' | 'account-disabled' | 'password-changed';
2
+ interface AccountLike {
3
+ createdAt?: unknown;
4
+ isActive?: boolean;
5
+ sessionGeneration?: number;
6
+ }
7
+ interface SessionLike {
8
+ accountCreatedAt?: unknown;
9
+ signedInAt?: unknown;
10
+ sessionGeneration?: unknown;
11
+ }
12
+ /** The value a session is bound to: the account's creation time, or '' for a record without one. */
13
+ export declare function accountBindingOf(account: AccountLike | null | undefined): string;
14
+ /**
15
+ * What one request (or the sweep) does to a signed-in session, given its
16
+ * account as stored now (null when there is none). `bind` is set on a session
17
+ * signed in before the binding existed that still belongs to its account: the
18
+ * caller writes it to `accountCreatedAt`, once.
19
+ */
20
+ export declare function checkSessionAccount(stored: SessionLike | Record<string, unknown>, account: AccountLike | null | undefined): {
21
+ end: AccountEndReason;
22
+ } | {
23
+ end: null;
24
+ bind?: string;
25
+ };
26
+ export {};
@@ -1,4 +1,4 @@
1
- export type SessionEndReason = 'logout' | 'idle-timeout' | 'max-age' | 'password-changed' | 'superseded';
1
+ export type SessionEndReason = 'logout' | 'idle-timeout' | 'max-age' | 'password-changed' | 'superseded' | 'account-deleted' | 'account-disabled';
2
2
  export declare function endSession(engine: {
3
3
  getManager(name: string): unknown;
4
4
  }, session: {
@@ -9,10 +9,11 @@ export declare const SYSTEM_CATEGORY_KEY = "ngdpbase.system-category";
9
9
  /**
10
10
  * The fields of an entry that are its `owner`'s declaration (operator,
11
11
  * 2026-09-29): `encrypt` and `defaultPrivate` stand, and `shareMaxDays` is
12
- * decided by the owner. For an entry an add-on declares, a site's
12
+ * decided by the owner; `comments` and `footnotes` stand too (operator,
13
+ * 2026-10-21, #1951). For an entry an add-on declares, a site's
13
14
  * `app-custom-config.json` never changes them, nor `owner` itself.
14
15
  */
15
- export declare const OWNER_DECIDED_FIELDS: readonly ["owner", "encrypt", "defaultPrivate", "shareMaxDays"];
16
+ export declare const OWNER_DECIDED_FIELDS: readonly ["owner", "encrypt", "defaultPrivate", "shareMaxDays", "comments", "footnotes"];
16
17
  /** One mistyped value: what is wrong, and what every reader takes it to mean. */
17
18
  export interface SystemCategoryValueProblem {
18
19
  /** Names the entry, the field and the bad value. */
@@ -23,8 +24,11 @@ export interface SystemCategoryValueProblem {
23
24
  /**
24
25
  * What is wrong with the types of one entry's values, one per field, naming
25
26
  * the entry and the field. Empty when sound. `defaultPrivate` is the boolean
26
- * `true` or `false`, or the string `"choice"`; `encrypt` is a boolean.
27
- * Anything else is compared as false by every reader, so the string `"true"`
28
- * would start pages public and leave the vault unsealed.
27
+ * `true` or `false`, or the string `"choice"`; `encrypt`, `comments` and
28
+ * `footnotes` are booleans.
29
+ * Any other `defaultPrivate` or `encrypt` is compared as false by every
30
+ * reader, so the string `"true"` would start pages public and leave the vault
31
+ * unsealed. Any other `comments` or `footnotes` is read as on, since only
32
+ * `false` turns a tab off.
29
33
  */
30
34
  export declare function systemCategoryValueProblems(key: string, entry: unknown): SystemCategoryValueProblem[];
@@ -9,16 +9,20 @@ export const SYSTEM_CATEGORY_KEY = 'ngdpbase.system-category';
9
9
  /**
10
10
  * The fields of an entry that are its `owner`'s declaration (operator,
11
11
  * 2026-09-29): `encrypt` and `defaultPrivate` stand, and `shareMaxDays` is
12
- * decided by the owner. For an entry an add-on declares, a site's
12
+ * decided by the owner; `comments` and `footnotes` stand too (operator,
13
+ * 2026-10-21, #1951). For an entry an add-on declares, a site's
13
14
  * `app-custom-config.json` never changes them, nor `owner` itself.
14
15
  */
15
- export const OWNER_DECIDED_FIELDS = ['owner', 'encrypt', 'defaultPrivate', 'shareMaxDays'];
16
+ export const OWNER_DECIDED_FIELDS = ['owner', 'encrypt', 'defaultPrivate', 'shareMaxDays', 'comments', 'footnotes'];
16
17
  /**
17
18
  * What is wrong with the types of one entry's values, one per field, naming
18
19
  * the entry and the field. Empty when sound. `defaultPrivate` is the boolean
19
- * `true` or `false`, or the string `"choice"`; `encrypt` is a boolean.
20
- * Anything else is compared as false by every reader, so the string `"true"`
21
- * would start pages public and leave the vault unsealed.
20
+ * `true` or `false`, or the string `"choice"`; `encrypt`, `comments` and
21
+ * `footnotes` are booleans.
22
+ * Any other `defaultPrivate` or `encrypt` is compared as false by every
23
+ * reader, so the string `"true"` would start pages public and leave the vault
24
+ * unsealed. Any other `comments` or `footnotes` is read as on, since only
25
+ * `false` turns a tab off.
22
26
  */
23
27
  export function systemCategoryValueProblems(key, entry) {
24
28
  if (!entry || typeof entry !== 'object')
@@ -37,5 +41,14 @@ export function systemCategoryValueProblems(key, entry) {
37
41
  readAs: 'it is read as false, so the vault is not sealed'
38
42
  });
39
43
  }
44
+ // #1951: only the boolean false turns a page's Comments or Footnotes tab off.
45
+ for (const kind of ['comments', 'footnotes']) {
46
+ if (e[kind] !== undefined && typeof e[kind] !== 'boolean') {
47
+ problems.push({
48
+ message: `system-category '${key}': ${kind} must be true or false, got ${JSON.stringify(e[kind])}`,
49
+ readAs: `it is read as true, so its pages keep the ${kind === 'comments' ? 'Comments' : 'Footnotes'} tab`
50
+ });
51
+ }
52
+ }
40
53
  return problems;
41
54
  }
@@ -5,17 +5,35 @@ export interface TreeArchiveOutcome {
5
5
  bytes: number;
6
6
  /** Entries left out because they are not a file or a folder (a symbolic link), relative to the root. */
7
7
  skipped: string[];
8
+ /** Entries that were there when the folder was listed and were deleted before they were read (#1563), relative to the root. */
9
+ vanished: string[];
8
10
  }
9
11
  /**
10
- * Write `entries` (paths relative to `root`; default: everything in it) into
11
- * `target`, streamed. `exclude` leaves out a path relative to `root`, in
12
- * posix form. A missing `root` writes an empty archive. Never overwrites.
12
+ * Whether a backup's read failed because the entry it was reading was
13
+ * deleted meanwhile (#1563): a page, a version folder or a file removed
14
+ * while the backup walked past it. That entry is skipped and counted, never
15
+ * a failed backup — the site keeps running while it is backed up (#1213).
16
+ * Every other error (EACCES, EIO, …) is a real failure and still fails.
13
17
  */
14
- export declare function writeTreeArchive(target: string, root: string, options?: {
18
+ export declare function vanishedDuringBackup(err: unknown): boolean;
19
+ type TreeArchiveOptions = {
15
20
  entries?: string[];
16
21
  exclude?: (relative: string) => boolean;
17
22
  backupKey?: string;
18
- }): Promise<TreeArchiveOutcome>;
23
+ };
24
+ /**
25
+ * Write `entries` (paths relative to `root`; default: everything in it) into
26
+ * `target`, streamed. `exclude` leaves out a path relative to `root`, in
27
+ * posix form. A missing `root` writes an empty archive. Never overwrites.
28
+ *
29
+ * An entry deleted while the archive is written (#1563) — the tar stream
30
+ * stops at the first file it can no longer find — is not a failure: the
31
+ * archive is written again from a fresh listing, without it, and the entry
32
+ * is named in `vanished`. It is written again only while each attempt finds
33
+ * another entry gone, so it ends once the deleting stops; the same entry
34
+ * missing twice, or one still there, is a real failure.
35
+ */
36
+ export declare function writeTreeArchive(target: string, root: string, options?: TreeArchiveOptions): Promise<TreeArchiveOutcome>;
19
37
  /**
20
38
  * Extract `archive` into `dest`, which is emptied first, streamed.
21
39
  * `select(relative)` picks the entries to extract (default: all). Refuses —
@@ -42,3 +60,4 @@ export declare function listTreeArchive(archive: string, onEntry: (entry: {
42
60
  }): Promise<void>;
43
61
  /** The first `n` bytes of a file, read without reading the rest. */
44
62
  export declare function readHead(file: string, n: number): Promise<Buffer>;
63
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilleke/ngdpbase-types",
3
- "version": "5.22.0",
3
+ "version": "5.24.0",
4
4
  "description": "TypeScript types for ngdpbase add-ons, and a test kit of core's own values. A dev dependency, versioned with ngdpbase.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Jim Willeke",
@@ -46,7 +46,7 @@
46
46
  "ipaddr.js": "^2.5.0",
47
47
  "jose": "^6.2.12",
48
48
  "linkedom": "^0.18.13",
49
- "markdown-it": "^14.3.1",
49
+ "markdown-it": "^14.3.3",
50
50
  "minimatch": "^10.2.6",
51
51
  "minisearch": "7.2.0",
52
52
  "multer": "^2.4.0",