@jwilleke/ngdpbase-types 5.38.0 → 5.39.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.
@@ -40,7 +40,7 @@ export type { PageAction } from '../../src/utils/pageUrl.js';
40
40
  export type { default as ConsentManager, ConsentScopeStatus } from '../../src/managers/ConsentManager.js';
41
41
  export type { AddonConsentRegistration, ConsentDocument, ConsentRecord, ConsentRequiredError, ConsentScope, ConsentSeen, ConsentStatus } from '../../src/types/Consent.js';
42
42
  export type { default as ImportManager } from '../../src/managers/ImportManager.js';
43
- export type { IceShim, IceReceiver, IceItem, IceKind, IceWarning, ShimDirection, ShimAccepts, ShimTools, ShimReadResult, ReceiveInfo, ReceiveOutcome, IceCitation, KeptOriginal, OriginalChoice, IceOriginalChoices, OriginalKeep, IceUpload, IceSource, IceRunRequest, IceRunReport, IceFileReport, IceFileStatus } from '../../src/ice/types.js';
43
+ export type { IceShim, IceReceiver, IceItem, IceKind, IceWarning, ShimDirection, ShimAccepts, ShimTools, ShimReadResult, ReceiveInfo, ReceiveOutcome, IceCitation, KeptOriginal, OriginalChoice, IceOriginalChoices, OriginalKeep, IceUpload, IceSource, IceUrlSource, IceRunRequest, IceRecordData, FileDoneInfo, IceRunReport, IceFileReport, IceFileStatus, RecentImport } from '../../src/ice/types.js';
44
44
  export type { default as UserManager, UserUpdateInput, UserUpdateFn } from '../../src/managers/UserManager.js';
45
45
  export type { User, UserPreferences } from '../../src/types/User.js';
46
46
  export type { default as AuthManager, InviteOutcome, InvitationView } from '../../src/managers/AuthManager.js';
@@ -25,6 +25,8 @@ declare abstract class ScopedLedger implements IceLedger {
25
25
  recordFile(scope: string, entry: LedgerFileEntry): Promise<void>;
26
26
  hasItem(scope: string, itemKey: string): Promise<boolean>;
27
27
  recordItem(scope: string, itemKey: string): Promise<void>;
28
+ seenItems(scope: string, itemKeys: string[]): Promise<Set<string>>;
29
+ recordItems(scope: string, itemKeys: string[]): Promise<void>;
28
30
  files(scope: string): Promise<LedgerFileEntry[]>;
29
31
  }
30
32
  /** The ledger in memory: for tests, and for a site that keeps no import history. */
@@ -9,6 +9,8 @@ export interface IceEngineSettings {
9
9
  maxFileBytes: number;
10
10
  maxChainDepth: number;
11
11
  defaultOriginal: OriginalChoice;
12
+ /** The largest response a URL source may return (#2109). */
13
+ fetchMaxBytes: number;
12
14
  }
13
15
  export interface IceRunDeps {
14
16
  /** Enabled shims, in registration order. */
@@ -17,6 +19,8 @@ export interface IceRunDeps {
17
19
  ledger: IceLedger;
18
20
  settings: IceEngineSettings;
19
21
  tools(shim: RegisteredShim): ShimTools;
22
+ /** The engine's guarded fetch, for a URL source (#2109): the same one `tools` hands shims. */
23
+ fetch?: ShimTools['fetch'];
20
24
  now?: () => Date;
21
25
  }
22
26
  /** The id of the receiver that keeps an original when the chosen receiver does not keep its own (D11). */
@@ -39,6 +43,8 @@ export declare const SCOPE_FORM = "ice2";
39
43
  * `sub` (phr's source id) narrows it within that user's scope, never outside.
40
44
  */
41
45
  export declare function boundScope(receiverId: string, owner: string, sub?: string): string;
46
+ /** Does this shim list the receiver, by id or as any receiver (`'*'`, #2109)? */
47
+ export declare function handsTo(shim: Pick<RegisteredShim, 'receivers'>, receiverId: string): boolean;
42
48
  export interface ChainLeaf {
43
49
  item: IceItem;
44
50
  chain: string[];
@@ -0,0 +1,59 @@
1
+ /**
2
+ * csv shim (#685 slice 4+, #911; a core shim since #2109) — zero dependency (a
3
+ * small RFC 4180 parser). Needed for sources that only publish CSV, e.g. NASA
4
+ * FIRMS thermal hotspots (geohazardwatch#4), whose `/api/area/csv/...`
5
+ * endpoint has no JSON/GeoJSON/XML variant.
6
+ *
7
+ * Nothing in it is about feeds, so any receiver of records can use it (a
8
+ * bank statement as well as a feed). The first row is the header; each
9
+ * subsequent row becomes one `record` item keyed by the header column names
10
+ * (all values are strings — `map`/dot-path and the downstream schema
11
+ * mappers cast as needed, exactly as for the other shims).
12
+ *
13
+ * Rows rarely carry an `id` column, so when neither the `recordIdField`
14
+ * option nor an `id` field resolves, a stable content hash of the row is
15
+ * synthesized as the id — order-independent (keys sorted) so a column
16
+ * reorder upstream doesn't churn ids, and content-addressed so
17
+ * change-detection still works.
18
+ *
19
+ * Delimiter defaults to `,`; override with the `delimiter` option (e.g. `\t`).
20
+ *
21
+ * The `skipLines` option skips N leading lines before the header (#1102).
22
+ * Some sources — government data exports especially — emit a human-readable
23
+ * caption above the real header. Without skipping it, the caption becomes
24
+ * the sole column name, the real header is consumed as the first record,
25
+ * and every column but the first is discarded. That failure is entirely
26
+ * silent: HTTP 200, a successful parse, records stored, and a table of empty
27
+ * values. Hence {@link preambleSuspicion}, which makes it visible (a warning
28
+ * on the read) without blocking it.
29
+ */
30
+ import { type IceRecordData, type IceShim } from '../types.js';
31
+ import { type RecordShimOptions, type RawRecord } from './record.js';
32
+ /**
33
+ * Parse CSV text into header-keyed row objects. RFC 4180 subset: quoted fields,
34
+ * escaped quotes (`""`), embedded commas/newlines inside quotes, CRLF/CR/LF line
35
+ * endings, and a leading UTF-8 BOM. Values are returned verbatim (strings);
36
+ * header cells are trimmed. Fully-empty lines are skipped.
37
+ *
38
+ * `skipLines` drops that many leading lines before the header is taken, for
39
+ * feeds with a caption preamble (#1102). Default 0 — byte-identical to the
40
+ * previous behaviour.
41
+ */
42
+ export declare function parseCsv(text: string, delimiter?: string, skipLines?: number): Record<string, string>[];
43
+ /**
44
+ * Return the suspect header when a parse looks like it consumed an unskipped
45
+ * preamble, or `null` when it looks fine (#1102).
46
+ *
47
+ * The signature of the failure is a single column whose name reads like a
48
+ * sentence: a caption line has no delimiter in it, so the whole line becomes one
49
+ * field. A legitimate single-column CSV has a short, word-like header, so the
50
+ * two are distinguishable in practice.
51
+ *
52
+ * Deliberately advisory. A one-column CSV is legal, just unusual, and a caller
53
+ * that guessed and re-parsed would only trade a visible wrong answer for an
54
+ * invisible one.
55
+ */
56
+ export declare function preambleSuspicion(columns: readonly string[]): string | null;
57
+ /** One row as a record: its own id, or one synthesized from its content. */
58
+ export declare function csvRecord(raw: RawRecord, opts: RecordShimOptions): IceRecordData | null;
59
+ export declare const csvShim: IceShim;
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Trivial dot-path lookup (#685) — the only "mapping engine" the framework has.
3
+ * NO JSONPath dependency (design §7): `getByPath(obj, 'properties.mag')`,
4
+ * `getByPath(obj, 'geometry.coordinates.2')` (numeric segment = array index).
5
+ * Returns undefined for any missing segment.
6
+ */
7
+ export declare function getByPath(obj: unknown, path: string): unknown;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * GeoJSON shim (#685 slice 4; a core shim since #2109) — zero dependency (JSON.parse).
3
+ *
4
+ * Handles a GeoJSON FeatureCollection (`{ features: [...] }`), a bare array of
5
+ * features, or a single Feature. Per feature, the id comes from the
6
+ * `recordIdField` option (dot-path) or the feature's own `id`; properties come
7
+ * from the `map` option (dot-path per field) or, with no map, the feature's
8
+ * `properties` object.
9
+ *
10
+ * Reference feed: the USGS earthquakes GeoJSON
11
+ * (https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_week.geojson).
12
+ */
13
+ import { type IceRecordData, type IceShim } from '../types.js';
14
+ import { type RecordShimOptions, type RawRecord } from './record.js';
15
+ /** The features of a GeoJSON document. */
16
+ export declare function geojsonFeatures(data: unknown): RawRecord[];
17
+ /** One feature as a record. With no map, the feature's own `properties` object is lifted. */
18
+ export declare function geojsonRecord(raw: RawRecord, opts: RecordShimOptions): IceRecordData | null;
19
+ export declare const geojsonShim: IceShim;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Core's record shims (#2109; moved into core by the operator's decision of
3
+ * 2026-10-29, "generic work goes to core"): one per structured format,
4
+ * registered by ImportManager as `core.<name>`, so any receiver that takes
5
+ * records can use them, with no add-on enabled. The feeds add-on was their
6
+ * first user and names one per source as its `adapter`; accounting's bank
7
+ * statements (#2121) are the next.
8
+ *
9
+ * Shipped: geojson + rest-json (zero-dep), xml (fast-xml-parser), csv
10
+ * (zero-dep, #911), rss-atom (#913, xml-based) and xml-index (#912, two-phase).
11
+ * Each produces `record` items with per-item duplicates and hands to any
12
+ * receiver that takes records (`receivers: ['*']`).
13
+ */
14
+ import type { IceShim } from '../types.js';
15
+ /** Every record shim, in registration order. */
16
+ export declare const RECORD_SHIMS: readonly IceShim[];
@@ -0,0 +1,68 @@
1
+ /**
2
+ * What core's record shims share (#685 slice 8; core shims since #2109): the
3
+ * options a run hands its shim, turning one raw item into a `record` item,
4
+ * and the item's key.
5
+ *
6
+ * Nothing here is about any one receiver. A record shim reads one format
7
+ * into `record` items (`data` is `{ id, properties }`, `IceRecordData`); which
8
+ * receiver takes them, and what it does with them, is the receiver's
9
+ * business. So the same CSV shim serves a feed (the feeds add-on's
10
+ * `feeds.items`) and a bank-statement import alike, with neither add-on's
11
+ * code (operator, 2026-10-29: generic work goes to core).
12
+ *
13
+ * id ← the `recordIdField` option (dot-path) or the item's own `id`.
14
+ * properties ← the `map` option (dot-path per field), or — with no map — the
15
+ * supplied `defaultProps` object (geojson lifts `feature.properties`;
16
+ * rest-json lifts the item itself). No id resolves → no record (skipped).
17
+ */
18
+ import type { IceItem, IceRecordData, IceWarning } from '../types.js';
19
+ /** One raw item straight off the wire (a GeoJSON feature, an RSS item, a CSV row, …). */
20
+ export type RawRecord = Record<string, unknown>;
21
+ /** The kind every record shim produces: core's generic record (#2109). */
22
+ export declare const RECORD_KIND = "record";
23
+ /**
24
+ * A record shim's options (#2109): the site's `ngdpbase.import.shims.<id>.options`,
25
+ * with the run's own over them (a feed source's settings, an import's).
26
+ * Each is optional and generic: where the items are, which field is the id,
27
+ * a field map, and the few format settings (CSV's delimiter and preamble, an
28
+ * index's links).
29
+ */
30
+ export interface RecordShimOptions {
31
+ /** Dot-path to each item's id. */
32
+ recordIdField?: string;
33
+ /** Dot-path map: property → source path. */
34
+ map?: Record<string, string>;
35
+ /** Dot-path to the items array (rest-json, xml, rss-atom, xml-index). */
36
+ itemsPath?: string;
37
+ /** csv: the field delimiter. Default ','. */
38
+ delimiter?: string;
39
+ /** csv: lines skipped before the header (#1102). Default 0. */
40
+ skipLines?: number;
41
+ /** xml-index: a regex matched against the index's hrefs. */
42
+ linkPattern?: string;
43
+ /** xml-index: the most item documents fetched in one run. */
44
+ maxItems?: number;
45
+ }
46
+ /**
47
+ * The options as a shim reads them: each of the right type, or absent. The
48
+ * caller (a feed's configuration) validated its own; this only refuses a
49
+ * value of the wrong type from anywhere.
50
+ */
51
+ export declare function recordOptions(raw: Record<string, unknown>): RecordShimOptions;
52
+ export declare function buildRecord(raw: RawRecord, opts: RecordShimOptions, defaultProps: Record<string, unknown> | undefined): IceRecordData | null;
53
+ /**
54
+ * The key the engine knows a record by (per-item duplicates, #2109): its own
55
+ * id with a hash of its content. The id alone would skip a record that
56
+ * changed upstream under the same id (a revised advisory), so the hash is
57
+ * the version. A receiver's own duplicate check is still its own (the feeds
58
+ * add-on compares the hash its record store keeps).
59
+ */
60
+ export declare function recordItemKey(rec: IceRecordData): string;
61
+ /** The `record` items of one read, from its records in order; null records (no id) are dropped. */
62
+ export declare function recordItems(input: IceItem, records: Array<IceRecordData | null>, warnings?: IceWarning[]): IceItem[];
63
+ /**
64
+ * Locate the items array in a JSON response when no explicit `itemsPath` is
65
+ * configured: the body itself if it's an array, else the first array found
66
+ * under common envelope keys, else any array-valued property, else [].
67
+ */
68
+ export declare function pickItemsArray(data: unknown): unknown[];
@@ -0,0 +1,14 @@
1
+ /**
2
+ * rest-json shim (#685 slice 8; a core shim since #2109) — zero dependency (JSON.parse).
3
+ *
4
+ * Generic REST GET → JSON. Locates the items array via the `itemsPath` option
5
+ * (dot-path, e.g. 'results' or 'data.events'); with none, falls back to the
6
+ * body itself if it's an array, or the first array under a common envelope
7
+ * key (results/data/items/records). Each item IS the record (its own fields
8
+ * are lifted as properties when no `map` is set).
9
+ */
10
+ import { type IceShim } from '../types.js';
11
+ import { type RawRecord } from './record.js';
12
+ /** The items of a JSON document: at `itemsPath`, or the first likely array. */
13
+ export declare function jsonItems(data: unknown, itemsPath: string | undefined): RawRecord[];
14
+ export declare const restJsonShim: IceShim;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * rss-atom shim (#913; a core shim since #2109) — dependency: fast-xml-parser
3
+ * (shared with the xml shim). RSS 2.0 and Atom 1.0 are both XML, so this is a
4
+ * thin, sensible-default wrapper over the shared XML machinery rather than a
5
+ * separate parser.
6
+ *
7
+ * Item location (when the `itemsPath` option is not set):
8
+ * - RSS 2.0 → `rss.channel.item`
9
+ * - Atom 1.0 → `feed.entry`
10
+ * - else the generic envelope detection (pickItemsArray)
11
+ *
12
+ * Id resolution (when `recordIdField` is not set): RSS `<guid>` → `<link>`;
13
+ * Atom `<id>` → `<link href>`. guid/link may parse as objects (guid with
14
+ * `@isPermaLink`, Atom link as an attribute-only element) — both are unwrapped.
15
+ */
16
+ import { type IceRecordData, type IceShim } from '../types.js';
17
+ import { type RecordShimOptions, type RawRecord } from './record.js';
18
+ /** The items of an RSS or Atom document. */
19
+ export declare function rssAtomItems(text: string, itemsPath: string | undefined): RawRecord[];
20
+ /** One RSS item or Atom entry as a record. */
21
+ export declare function rssAtomRecord(raw: RawRecord, opts: RecordShimOptions): IceRecordData | null;
22
+ export declare const rssAtomShim: IceShim;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * xml-index shim (#912; a core shim since #2109) — dependency: fast-xml-parser
3
+ * (shared with the xml shim). For "index page + N item documents, no
4
+ * combined feed" sources common to bulletin-style publishers (e.g. Washington
5
+ * VAAC ash advisories, geohazardwatch#5): an HTML/text index links to many
6
+ * separately-fetchable per-record XML files, with no single URL returning
7
+ * them all.
8
+ *
9
+ * Two phases:
10
+ * 1. The index is the file the engine hands it (a URL source, fetched by the
11
+ * engine). Item URLs are the hrefs matching the `linkPattern` option (a
12
+ * regex string), resolved against the index's URL (`metadata.sourceUrl`,
13
+ * else the file's name). Deduped, capped at the `maxItems` option
14
+ * (default 100; a site sets its own with
15
+ * `ngdpbase.import.shims.core.xml-index.options.maxItems`).
16
+ * 2. Each item document is fetched by asking the engine (`tools.fetch`), so
17
+ * every request takes the one guarded path with the source's headers
18
+ * and timeout (design rule), then XML-parsed. Each yields one record (its
19
+ * unwrapped root), or — with `itemsPath` — the items located in it. The
20
+ * item's URL is kept as `__itemUrl` so it serves as the id when the
21
+ * document has none.
22
+ *
23
+ * A single failed item fetch or parse is skipped, never failing the whole
24
+ * read: the read is then `partial`, saying which.
25
+ */
26
+ import { type IceShim } from '../types.js';
27
+ import { buildRecord, type RecordShimOptions, type RawRecord } from './record.js';
28
+ /** Extract, resolve, dedupe and cap item URLs from index HTML/text. */
29
+ export declare function extractItemUrls(html: string, baseUrl: string, linkPattern: string, maxItems: number): string[];
30
+ /** One item document's record: `__itemUrl` is plumbing, kept out of the properties but usable as the id. */
31
+ export declare function xmlIndexRecord(raw: RawRecord, opts: RecordShimOptions): ReturnType<typeof buildRecord>;
32
+ export declare const xmlIndexShim: IceShim;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * xml shim (#685 slice 8; a core shim since #2109) — dependency: fast-xml-parser
3
+ * (itself zero-dep).
4
+ *
5
+ * Generic XML → records, for sources with no JSON surface (e.g. VAAC ash
6
+ * advisories, geohazardwatch#5). The document is parsed to plain objects so
7
+ * the existing dot-path machinery applies unchanged: locate the items array
8
+ * via the `itemsPath` option (e.g. 'rss.channel.item' or
9
+ * 'advisories.advisory'), map fields via `map` / `recordIdField`.
10
+ *
11
+ * XML-specific shape rules:
12
+ * - Attributes are kept, prefixed '@' — dot-path them as e.g. 'link.@href'.
13
+ * - Element text alongside attributes lands under '#text'.
14
+ * - A single repeated element parses as an object, not a 1-element array —
15
+ * the shim coerces the located items to an array, so `itemsPath` behaves
16
+ * identically for 1 and N items.
17
+ */
18
+ import { XMLParser } from 'fast-xml-parser';
19
+ import { type IceShim } from '../types.js';
20
+ import { type RawRecord } from './record.js';
21
+ /**
22
+ * Shared fast-xml-parser instance — reused by the rss-atom (#913) and xml-index
23
+ * (#912) shims so XML shape rules stay identical across all XML-based sources.
24
+ */
25
+ export declare const xmlParser: XMLParser;
26
+ /** XML parses a lone repeated element as an object — normalize to an array. */
27
+ export declare function coerceItems(located: unknown): RawRecord[];
28
+ /** The items of an XML document: at `itemsPath`, else under its (single) root by envelope detection. */
29
+ export declare function xmlItems(text: string, itemsPath: string | undefined): RawRecord[];
30
+ export declare const xmlShim: IceShim;
@@ -20,12 +20,16 @@
20
20
  */
21
21
  import type { ActorContext } from '../context/ActorContext.js';
22
22
  import type { GuardedFetchOptions, GuardedResponse } from '../http/guardedFetch.js';
23
+ /** In a shim's `receivers`: any receiver that takes the kind the shim produces (#2109). */
24
+ export declare const ANY_RECEIVER = "*";
23
25
  /** D3: which way a shim moves data. Most are `in`; PDF (#1974) would be `both`. */
24
26
  export type ShimDirection = 'in' | 'out' | 'both';
25
27
  /**
26
28
  * The kind of a piece of content moving through the engine. `file` is raw
27
- * input bytes; `html` and `ncm` are core's; an add-on's own kinds are named
28
- * `<slug>.<kind>`, e.g. `phr.health-record`.
29
+ * input bytes; `html` and `ncm` are core's; `record` is one structured entry
30
+ * of a table or feed (`data` is `{ id, properties }`, {@link IceRecordData}),
31
+ * so a format shim such as CSV serves any receiver that takes records (#2109);
32
+ * an add-on's own kinds are named `<slug>.<kind>`, e.g. `phr.health-record`.
29
33
  */
30
34
  export type IceKind = string;
31
35
  /** A structured note from a shim, the same shape as a converter's warning. */
@@ -57,6 +61,15 @@ export interface IceItem {
57
61
  */
58
62
  itemKey?: string;
59
63
  }
64
+ /**
65
+ * The `data` of a `record` item (#2109): one row of a table or one entry of a
66
+ * feed. `id` is the entry's own id (a configured id field, the format's own
67
+ * id, or a hash of the row when it has none); `properties` its fields.
68
+ */
69
+ export interface IceRecordData {
70
+ id: string;
71
+ properties: Record<string, unknown>;
72
+ }
60
73
  /** What a shim takes. A file matches by extension or media type; a chained input by kind. */
61
74
  export interface ShimAccepts {
62
75
  /** Lower-case, with the dot: `['.xml']`. */
@@ -78,7 +91,11 @@ export interface ShimTools {
78
91
  * path, with the site's egress policy, User-Agent and timeout.
79
92
  */
80
93
  fetch(url: string, options?: Omit<GuardedFetchOptions, 'policy' | 'transport' | 'resolver'>): Promise<GuardedResponse>;
81
- /** This shim's options from site configuration (D6): `ngdpbase.import.shims.<id>.options`. */
94
+ /**
95
+ * This shim's options from site configuration (D6):
96
+ * `ngdpbase.import.shims.<id>.options`, with the run's own `options` over
97
+ * them (a feed source's parse settings, #2109).
98
+ */
82
99
  options: Record<string, unknown>;
83
100
  }
84
101
  /** What a shim's read returns. */
@@ -104,7 +121,10 @@ export interface IceShim {
104
121
  produces: IceKind;
105
122
  /**
106
123
  * Receivers it may hand to directly (ids, `core.pages` or `<slug>.<name>`).
107
- * Empty for a shim whose output only feeds another shim.
124
+ * Empty for a shim whose output only feeds another shim. {@link ANY_RECEIVER}
125
+ * (`'*'`): any receiver that takes the kind it produces — a generic format
126
+ * shim such as core's CSV, whose `record` items any add-on's receiver may
127
+ * take (#2109). Each receiver still decides with its own permission (D5).
108
128
  */
109
129
  receivers: string[];
110
130
  /** Two duplicate kinds (design rule): a whole file by fingerprint, or each item by `itemKey`. Default `file`. */
@@ -185,6 +205,13 @@ export interface ReceiveInfo {
185
205
  citation?: IceCitation;
186
206
  original?: KeptOriginal;
187
207
  }
208
+ /** What a receiver's `fileDone` is told (#2109). */
209
+ export interface FileDoneInfo extends ReceiveInfo {
210
+ /** What the source said about the file: a URL source's `metadata`. */
211
+ metadata: Record<string, unknown>;
212
+ /** Every item the file held, in order: those handed to `receive`, and those skipped as already seen. */
213
+ items: IceItem[];
214
+ }
188
215
  /** A receiver's answer for one item. `refused` is its existing permission saying no (D5). */
189
216
  export interface ReceiveOutcome {
190
217
  status: 'stored' | 'duplicate' | 'refused';
@@ -211,6 +238,29 @@ export interface IceReceiver {
211
238
  mayUse(ctx: ActorContext): Promise<boolean>;
212
239
  /** Hand one item to the receiver's own door. */
213
240
  receive(item: IceItem, ctx: ActorContext, info: ReceiveInfo): Promise<ReceiveOutcome>;
241
+ /**
242
+ * Called once per file whose shims read it through (#2109), after each of
243
+ * its items was handed to `receive` or skipped as already seen, with every
244
+ * item the file held, in order. A receiver that holds a snapshot of a
245
+ * source (a feed's records) stores it here and drops what the source no
246
+ * longer holds: an item skipped as already seen is still in the source.
247
+ * Not called for a file that failed before its items were known. Returns a
248
+ * note for the file's status; a throw fails the file.
249
+ */
250
+ fileDone?(ctx: ActorContext, info: FileDoneInfo): Promise<string | void>;
251
+ /**
252
+ * `false`: this receiver is fed only by its owner's own sources (a feed's
253
+ * schedule, #2109). The import screen never offers it and an upload into
254
+ * it is refused. Default: it takes uploads.
255
+ */
256
+ uploads?: boolean;
257
+ /**
258
+ * Where this context can open what one of its references names (#2110),
259
+ * for "your recent imports" on the import screen: a URL, or null when
260
+ * the thing is gone or this context may not open it. The receiver decides
261
+ * with its own existing read rule (D5). Without it, nothing is linked.
262
+ */
263
+ linkFor?(ref: string, ctx: ActorContext): Promise<string | null>;
214
264
  /**
215
265
  * Keep the original file itself (D11: phr keeps originals in its own
216
266
  * store). Without this, `keep` hands the original to the files receiver.
@@ -241,13 +291,36 @@ export interface IceUpload {
241
291
  mediaType?: string;
242
292
  }
243
293
  /**
244
- * A source (D1). The first slice reads uploads; a URL fetched on a schedule
245
- * (D10, feeds) and a server folder join this union later.
294
+ * A URL the engine fetches as the run's one file (D1, D10, #2109): through
295
+ * the one guarded path (`guardedFetch`, #1139) with the site's egress policy
296
+ * and default User-Agent, and the source's own headers and timeout (#1959),
297
+ * which also apply to every follow-up fetch a shim asks of the engine during
298
+ * the run. A feed's schedule is its owner's job; the engine fetches when run.
299
+ */
300
+ export interface IceUrlSource {
301
+ type: 'url';
302
+ url: string;
303
+ /** The name the fetched file gets in reports and the ledger. Default: the URL. */
304
+ name?: string;
305
+ /** Request headers for every fetch of the run (a `User-Agent` replaces the site default). */
306
+ headers?: Record<string, string>;
307
+ /** Each fetch's time limit, ms. Default `ngdpbase.fetch-timeout-ms`. */
308
+ timeoutMs?: number;
309
+ /**
310
+ * Facts the source states about the file, carried on it and on every item
311
+ * read from it (a feed source's id). The engine adds `sourceUrl`, the URL
312
+ * fetched, so a shim can resolve the file's relative links.
313
+ */
314
+ metadata?: Record<string, unknown>;
315
+ }
316
+ /**
317
+ * A source (D1): uploaded files, or a URL fetched when the run starts
318
+ * (#2109). A server folder joins this union later.
246
319
  */
247
320
  export type IceSource = {
248
321
  type: 'upload';
249
322
  files: IceUpload[];
250
- };
323
+ } | IceUrlSource;
251
324
  /** One import, as a caller asks for it. */
252
325
  export interface IceRunRequest {
253
326
  source: IceSource;
@@ -267,6 +340,12 @@ export interface IceRunRequest {
267
340
  * value a client sent.
268
341
  */
269
342
  scope?: string;
343
+ /**
344
+ * Options for this run's shims, over the site's (`ShimTools.options`): a
345
+ * feed source's parse settings (#2109). Server-built; never a value a
346
+ * client sent.
347
+ */
348
+ options?: Record<string, unknown>;
270
349
  }
271
350
  /** Per-file status (first slice). */
272
351
  export type IceFileStatus = 'imported' | 'partial' | 'unchanged' | 'duplicate' | 'noise' | 'unreadable' | 'refused' | 'failed';
@@ -318,6 +397,25 @@ export interface LedgerFileEntry {
318
397
  /** The original kept for this fingerprint, whatever became of the conversion (D11). */
319
398
  original?: LedgerOriginal;
320
399
  }
400
+ /**
401
+ * One file in a user's recent imports (#2110), read from the ledger: what
402
+ * the import screen lists, so the list survives a restart. Only the user's
403
+ * own entries, from the receivers they may use.
404
+ */
405
+ export interface RecentImport {
406
+ name: string;
407
+ status: IceFileStatus;
408
+ message?: string;
409
+ /** When the ledger last recorded this file, ISO. */
410
+ at: string;
411
+ /** The receiver it went into. */
412
+ receiver: string;
413
+ receiverLabel?: string;
414
+ /** How many references the receiver gave (pages, records). */
415
+ refs: number;
416
+ /** Where the user can open the first of them (`linkFor`), when they may. */
417
+ link?: string;
418
+ }
321
419
  /**
322
420
  * Where the engine keeps fingerprints, per-file status and item keys, per
323
421
  * scope. One interface so a database table can replace the file store when
@@ -328,5 +426,9 @@ export interface IceLedger {
328
426
  recordFile(scope: string, entry: LedgerFileEntry): Promise<void>;
329
427
  hasItem(scope: string, itemKey: string): Promise<boolean>;
330
428
  recordItem(scope: string, itemKey: string): Promise<void>;
429
+ /** Which of these item keys the scope has seen: one read for a whole file's items (#2109). */
430
+ seenItems(scope: string, itemKeys: string[]): Promise<Set<string>>;
431
+ /** Record several item keys in one write (#2109). */
432
+ recordItems(scope: string, itemKeys: string[]): Promise<void>;
331
433
  files(scope: string): Promise<LedgerFileEntry[]>;
332
434
  }
@@ -221,6 +221,15 @@ export declare function backupKeyNotSaved(fingerprint: string): string;
221
221
  export declare const ENV_KEYS_SAVED_KEY = "ngdpbase.backup.env-keys-saved";
222
222
  /** The title of the one notification per change of the `.env` key set (#1976). */
223
223
  export declare const ENV_KEYS_CHANGED_TITLE = "Save a copy of .env";
224
+ /**
225
+ * The config key that records an admin's confirmation that the shared-folders
226
+ * key is saved off the server (#2124, epic #1775 decision 1): its fingerprint,
227
+ * recorded when they confirm a downloaded `.env` that holds it. A different
228
+ * key asks again.
229
+ */
230
+ export declare const SHARED_FOLDERS_KEY_SAVED_KEY = "ngdpbase.backup.shared-folders-key-saved-fingerprint";
231
+ /** The banner while no admin has confirmed a saved `.env` holding the current shared-folders key (#2124). */
232
+ export declare function sharedFoldersKeyNotSaved(fingerprint: string): string;
224
233
  /** The banner while the instance `.env` sets other names than the ones an admin last confirmed saved (#1976): names only, never a value. */
225
234
  export declare function envKeysChanged(change: {
226
235
  added: readonly string[];
@@ -240,6 +249,8 @@ export interface EnvKeysInfo {
240
249
  removed: string[];
241
250
  /** The file holds this site's current backup key, so a saved copy is a saved key too. */
242
251
  holdsBackupKey: boolean;
252
+ /** The file holds this site's current shared-folders key (#2124), so a saved copy is a saved key too. */
253
+ holdsSharedFoldersKey: boolean;
243
254
  }
244
255
  /** What Admin → Backup shows about the backup key: never the key itself. */
245
256
  export interface BackupKeyInfo {
@@ -458,6 +469,12 @@ declare class BackupManager extends BaseManager {
458
469
  private checkInstanceEnvKeys;
459
470
  /** The instance `.env` key set as Admin → Backup shows it (#1976), or null when the site has no instance data folder or the file can't be read. Never a value. */
460
471
  envKeysInfo(): EnvKeysInfo | null;
472
+ /**
473
+ * The shared-folders key as Admin → Backup shows it (#2124): set or not,
474
+ * its fingerprint, and whether an admin confirmed a saved `.env` holding
475
+ * it. Never the key. Read at use, like the backup key.
476
+ */
477
+ sharedFoldersKeyInfo(): BackupKeyInfo;
461
478
  /** The backup key as Admin → Backup shows it: set or not, its fingerprint, and whether an admin confirmed it is saved. Never the key. */
462
479
  backupKeyInfo(): BackupKeyInfo;
463
480
  /**
@@ -483,8 +500,9 @@ declare class BackupManager extends BaseManager {
483
500
  * With `envFingerprint` (#1976) the admin confirms the whole `.env` is
484
501
  * saved, the one confirmation for both reminders: the names it sets are
485
502
  * recorded (`ngdpbase.backup.env-keys-saved`), and so is the backup key
486
- * when the file holds it. Refused when the names changed since the page
487
- * was loaded.
503
+ * when the file holds it, and the shared-folders key when it holds that
504
+ * (#2124, `ngdpbase.backup.shared-folders-key-saved-fingerprint`). Refused
505
+ * when the names changed since the page was loaded.
488
506
  */
489
507
  confirmBackupKeySaved(ctx: ActorContext, fingerprint: string, envFingerprint?: string): Promise<void>;
490
508
  private configManager;
@@ -12,6 +12,7 @@ import { applyPartAtStart, beginSiteRestore, commitSiteRestore, discardSiteResto
12
12
  import { encryptBuffer, ENCRYPTED_SUFFIX } from '../utils/backupCrypto.js';
13
13
  import { readBackupDocument, verifyBackupOffThread } from '../utils/backupDocument.js';
14
14
  import { BACKUP_KEY_ENV, backupKeyFingerprint, currentBackupKey, isLeftOutForNoKey, LEFT_OUT_FOR_NO_KEY } from '../utils/backupKey.js';
15
+ import { currentSharedFoldersKey, SHARED_FOLDERS_KEY_ENV, sharedFoldersKeyFingerprint } from '../utils/sharedFoldersKey.js';
15
16
  import { applyDeclaredPartsAtStart, archiveReader, archiveTarget, archiveWriter, backupDeclaredParts, placePartRecords, stageDeclaredParts, stageFilePart } from '../utils/directoryBackup.js';
16
17
  import { writeTreeArchive } from '../utils/treeArchive.js';
17
18
  import { envForBackup, envKeyNames, envKeyNamesFingerprint, INSTANCE_ENV_PART, instanceEnvPath } from '../utils/instanceEnvBackup.js';
@@ -195,8 +196,20 @@ export function backupKeyNotSaved(fingerprint) {
195
196
  export const ENV_KEYS_SAVED_KEY = 'ngdpbase.backup.env-keys-saved';
196
197
  /** The title of the one notification per change of the `.env` key set (#1976). */
197
198
  export const ENV_KEYS_CHANGED_TITLE = 'Save a copy of .env';
199
+ /**
200
+ * The config key that records an admin's confirmation that the shared-folders
201
+ * key is saved off the server (#2124, epic #1775 decision 1): its fingerprint,
202
+ * recorded when they confirm a downloaded `.env` that holds it. A different
203
+ * key asks again.
204
+ */
205
+ export const SHARED_FOLDERS_KEY_SAVED_KEY = 'ngdpbase.backup.shared-folders-key-saved-fingerprint';
198
206
  const ENV_KEYS_CHANGED_PREFIX = 'The keys in .env changed since an admin last saved it';
199
207
  const KEY_NOT_SAVED_PREFIX = 'Save the backup key off this server';
208
+ const SHARED_FOLDERS_KEY_NOT_SAVED_PREFIX = 'Save .env off this server: it holds the shared-folders key';
209
+ /** The banner while no admin has confirmed a saved `.env` holding the current shared-folders key (#2124). */
210
+ export function sharedFoldersKeyNotSaved(fingerprint) {
211
+ return `${SHARED_FOLDERS_KEY_NOT_SAVED_PREFIX} (${SHARED_FOLDERS_KEY_ENV}, fingerprint ${fingerprint}), which opens every shared folder. Download .env on Admin → Backup and keep the copy off this server. This stays until an admin confirms it is saved`;
212
+ }
200
213
  /** The banner while the instance `.env` sets other names than the ones an admin last confirmed saved (#1976): names only, never a value. */
201
214
  export function envKeysChanged(change) {
202
215
  const parts = [
@@ -779,15 +792,18 @@ class BackupManager extends BaseManager {
779
792
  // #1876: the banner asking an admin to save the key off the server, until one confirms;
780
793
  // then #1976's, while the .env key set differs from the one last confirmed. One at a time,
781
794
  // and one confirmation ("I have saved .env", which holds the key) can clear both.
782
- const reminder = this.backupKeyInfo().saved ? envReminder : backupKeyNotSaved(backupKeyFingerprint(key));
795
+ // #2124: then the shared-folders key's, while no saved .env holding the current one is confirmed.
796
+ const shared = this.sharedFoldersKeyInfo();
797
+ const sharedReminder = shared.set && !shared.saved && shared.fingerprint ? sharedFoldersKeyNotSaved(shared.fingerprint) : null;
798
+ const reminder = this.backupKeyInfo().saved ? (envReminder ?? sharedReminder) : backupKeyNotSaved(backupKeyFingerprint(key));
783
799
  const status = this.getManagerStatus();
784
- const ours = status.state === 'degraded' && (status.reason?.startsWith(KEY_NOT_SAVED_PREFIX) || status.reason?.startsWith(ENV_KEYS_CHANGED_PREFIX));
800
+ const ours = status.state === 'degraded' && [KEY_NOT_SAVED_PREFIX, ENV_KEYS_CHANGED_PREFIX, SHARED_FOLDERS_KEY_NOT_SAVED_PREFIX].some((p) => status.reason?.startsWith(p));
785
801
  if (!reminder) {
786
802
  if (ours)
787
803
  this.markReady();
788
804
  }
789
805
  else if (status.state !== 'degraded' || ours) {
790
- this.markNeedsAttention(reminder, reminder === envReminder ? ENV_KEYS_SAVED_KEY : BACKUP_KEY_SAVED_KEY);
806
+ this.markNeedsAttention(reminder, reminder === envReminder ? ENV_KEYS_SAVED_KEY : reminder === sharedReminder ? SHARED_FOLDERS_KEY_SAVED_KEY : BACKUP_KEY_SAVED_KEY);
791
807
  }
792
808
  return;
793
809
  }
@@ -876,9 +892,22 @@ class BackupManager extends BaseManager {
876
892
  recorded: recorded !== null,
877
893
  added: recorded ? names.filter((n) => !recorded.includes(n)) : [],
878
894
  removed: recorded ? recorded.filter((n) => !names.includes(n)) : [],
879
- holdsBackupKey: Boolean(key) && readEnvLine(content, BACKUP_KEY_ENV) === key
895
+ holdsBackupKey: Boolean(key) && readEnvLine(content, BACKUP_KEY_ENV) === key,
896
+ holdsSharedFoldersKey: Boolean(currentSharedFoldersKey()) && readEnvLine(content, SHARED_FOLDERS_KEY_ENV) === currentSharedFoldersKey()
880
897
  };
881
898
  }
899
+ /**
900
+ * The shared-folders key as Admin → Backup shows it (#2124): set or not,
901
+ * its fingerprint, and whether an admin confirmed a saved `.env` holding
902
+ * it. Never the key. Read at use, like the backup key.
903
+ */
904
+ sharedFoldersKeyInfo() {
905
+ const key = currentSharedFoldersKey();
906
+ if (!key)
907
+ return { set: false, fingerprint: null, saved: false };
908
+ const fingerprint = sharedFoldersKeyFingerprint(key);
909
+ return { set: true, fingerprint, saved: this.setting(SHARED_FOLDERS_KEY_SAVED_KEY, '') === fingerprint };
910
+ }
882
911
  /** The backup key as Admin → Backup shows it: set or not, its fingerprint, and whether an admin confirmed it is saved. Never the key. */
883
912
  backupKeyInfo() {
884
913
  const key = currentBackupKey();
@@ -930,8 +959,9 @@ class BackupManager extends BaseManager {
930
959
  * With `envFingerprint` (#1976) the admin confirms the whole `.env` is
931
960
  * saved, the one confirmation for both reminders: the names it sets are
932
961
  * recorded (`ngdpbase.backup.env-keys-saved`), and so is the backup key
933
- * when the file holds it. Refused when the names changed since the page
934
- * was loaded.
962
+ * when the file holds it, and the shared-folders key when it holds that
963
+ * (#2124, `ngdpbase.backup.shared-folders-key-saved-fingerprint`). Refused
964
+ * when the names changed since the page was loaded.
935
965
  */
936
966
  async confirmBackupKeySaved(ctx, fingerprint, envFingerprint) {
937
967
  const STALE_KEY = 'That is not the fingerprint of the current backup key; reload Admin → Backup and check the key you saved';
@@ -950,6 +980,9 @@ class BackupManager extends BaseManager {
950
980
  await configManager.setProperty(BACKUP_KEY_SAVED_KEY, fingerprint, ctx);
951
981
  if (env)
952
982
  await configManager.setProperty(ENV_KEYS_SAVED_KEY, env.names, ctx);
983
+ const shared = this.sharedFoldersKeyInfo();
984
+ if (env?.holdsSharedFoldersKey && shared.fingerprint && !shared.saved)
985
+ await configManager.setProperty(SHARED_FOLDERS_KEY_SAVED_KEY, shared.fingerprint, ctx);
953
986
  await this.checkBackupKey();
954
987
  }
955
988
  // ---------------------------------------------------------------------------
@@ -10,6 +10,13 @@
10
10
  * (`ngdpbase.consent.documents`, asked at sign-up and at an interactive
11
11
  * sign-in) and each add-on's (asked before its per-person switch turns on,
12
12
  * and again on a version change when the person next uses it).
13
+ * - __The recovery words (#1928).__ A user's confirmation that they saved
14
+ * their recovery words (`RECOVERY_WORDS_CONSENT_SCOPE`), bound to the set
15
+ * of words it is about (`subject`): asked where the words are shown, and
16
+ * reminded by a banner — it never holds a session. An admin's confirmation
17
+ * that they saved the organization's shared-folders recovery words
18
+ * (`SHARED_FOLDERS_WORDS_CONSENT_SCOPE`, #2124) is the same kind of consent,
19
+ * bound to the organization's set of words.
13
20
  *
14
21
  * The documents themselves are pages, read when asked (`version:` and
15
22
  * `effective:` front matter), never copied. Whether a person has accepted
@@ -93,8 +100,12 @@ declare class ConsentManager extends BaseManager {
93
100
  * The documents of `scope` that `username` has not accepted at their
94
101
  * current version — what they are to be asked. Empty when there is
95
102
  * nothing to ask (no documents, or no consent folder).
103
+ *
104
+ * In a scope bound to a subject (#1928, the recovery words) only an
105
+ * acceptance about `subject` counts, and with no subject there is nothing
106
+ * to ask: a user with no recovery words has none to confirm.
96
107
  */
97
- pendingFor(username: string, scope?: ConsentScope): Promise<ConsentDocument[]>;
108
+ pendingFor(username: string, scope?: ConsentScope, subject?: string): Promise<ConsentDocument[]>;
98
109
  /**
99
110
  * "Your consents" for the person `ctx` acts as: each document of core's and
100
111
  * of each registered add-on, with their acceptance of the current version
@@ -114,10 +125,15 @@ declare class ConsentManager extends BaseManager {
114
125
  * - Each acceptance is recorded as `consent-grant` (on-failure refuse)
115
126
  * before it is stored: a failed audit record refuses the acceptance.
116
127
  *
117
- * @param how How they were asked: `sign-up`, `sign-in` or `add-on`
128
+ * - In a scope bound to a subject (#1928) the acceptance is about `subject`
129
+ * (which set of recovery words); it is required there.
130
+ *
131
+ * @param how How they were asked: `sign-up`, `sign-in` or `add-on`; for the
132
+ * recovery words `store-door`, `reminder` or `new-words`
133
+ * @param subject What the acceptance is about, in a scope bound to one
118
134
  * @returns The records written; empty when there was nothing to accept
119
135
  */
120
- accept(ctx: ActorContext, scope: ConsentScope, seen: ConsentSeen[], how: string): Promise<ConsentRecord[]>;
136
+ accept(ctx: ActorContext, scope: ConsentScope, seen: ConsentSeen[], how: string, subject?: string): Promise<ConsentRecord[]>;
121
137
  /**
122
138
  * The account `username` was deleted (`AuthManager.closeAccess`): its
123
139
  * records go with it, after `consent-withdraw` names what it had accepted
@@ -32,7 +32,7 @@ import type { WikiEngine } from '../types/WikiEngine.js';
32
32
  import { IContentConverter } from '../converters/IContentConverter.js';
33
33
  import { type ZipReadLimits } from '../utils/zipArchive.js';
34
34
  import { type JobContext } from '../context/JobContext.js';
35
- import type { IceLedger, IceOriginalChoices, IceReceiver, IceRunReport, IceRunRequest, IceShim, RegisteredShim } from '../ice/types.js';
35
+ import type { IceLedger, IceOriginalChoices, IceReceiver, IceRunReport, IceRunRequest, IceShim, RecentImport, RegisteredShim } from '../ice/types.js';
36
36
  /**
37
37
  * Options for import operations
38
38
  */
@@ -380,6 +380,28 @@ declare class ImportManager extends BaseManager {
380
380
  getImportRun(runId: string, ctx: ActorContext): IceRunReport | null;
381
381
  /** The runs this process holds for the user `ctx` names, newest first (#2021): their own, never anyone else's. */
382
382
  importRunsFor(ctx: ActorContext): IceRunReport[];
383
+ /**
384
+ * The user's own recent imports, file by file, read from the ledger
385
+ * (#2110), so the list survives a restart: newest first, across the
386
+ * receivers `ctx` may use, at most `ngdpbase.import.engine.recent-imports`.
387
+ * Only the scopes bound to this user's identity (#2013), never anyone
388
+ * else's; a caller's narrower scope (phr's source id) is not read. Each
389
+ * entry links to what it created when the receiver says this user may
390
+ * open it (`linkFor`). Nothing is stored for this.
391
+ *
392
+ * @throws IdentityStoreUnavailableError when the identity store cannot
393
+ * answer: whose entries they are cannot be told, so none is shown
394
+ */
395
+ recentImportsFor(ctx: ActorContext): Promise<RecentImport[]>;
396
+ /**
397
+ * Is an import into this receiver confidential (#2110)? Only when its
398
+ * destination is encrypted: a receiver that keeps into a sealed store
399
+ * (`keepsEncrypted`). No receiver can declare itself so otherwise. A
400
+ * confidential import's file names are shown to the uploader and kept in
401
+ * the audit log, and never written to application logs, notices or admin
402
+ * screens; every other import logs them as before.
403
+ */
404
+ isConfidential(receiverId: string): boolean;
383
405
  /** Use this ledger instead of the file one: a test's, or a database table's. */
384
406
  setImportLedger(ledger: IceLedger): void;
385
407
  /**
@@ -394,6 +416,13 @@ declare class ImportManager extends BaseManager {
394
416
  * land in a scope the user's other runs do not use
395
417
  */
396
418
  private importScope;
419
+ /**
420
+ * Whose a scope is (#2013): the identity `@id` of the user `ctx` names, or
421
+ * their username when the identity store answers that they have none.
422
+ *
423
+ * @throws IdentityStoreUnavailableError when the store cannot answer
424
+ */
425
+ private scopeOwner;
397
426
  private newRunReport;
398
427
  private executeRun;
399
428
  /** What a run, or keeping at upload, works with. */
@@ -414,6 +443,12 @@ declare class ImportManager extends BaseManager {
414
443
  private ledger;
415
444
  /** What a shim is handed: the engine's guarded fetch (design rule) and its own options (D6). */
416
445
  private shimTools;
446
+ /**
447
+ * The engine's one way out (design rule, #1139): `guardedFetch` with the
448
+ * site's egress policy, read per call, and its default timeout. A URL
449
+ * source and every shim's follow-up fetch go through here (#2109).
450
+ */
451
+ private engineFetch;
417
452
  /** A receiver's existing permission, asked of the PDP with the run's context (D5, P2). */
418
453
  private permits;
419
454
  /**
@@ -253,6 +253,17 @@ export type VaultRepairResult = ({
253
253
  message: string;
254
254
  };
255
255
  /** The move of existing captures into the capture vault (#1505), recorded per vault it moved them out of. */
256
+ /** The refusal when someone without `admin-system` names a system-category (#1503, operator 2026-10-29). */
257
+ export declare const SYSTEM_CATEGORY_ADMIN_ONLY = "Only administrators can set a page's system category";
258
+ /** What a write may give a page as its system-category ({@link PageManager.systemCategoryForWrite}). */
259
+ export type SystemCategoryForWrite = {
260
+ ok: true;
261
+ category: string | undefined;
262
+ admin: boolean;
263
+ } | {
264
+ ok: false;
265
+ reason: string;
266
+ };
256
267
  export declare const CAPTURE_VAULT_MIGRATION = "capture-vault";
257
268
  /** The move of private pages from the default vault into their system-category's own vault (#1507). */
258
269
  export declare const CATEGORY_VAULT_MIGRATION = "category-vaults";
@@ -429,6 +440,8 @@ declare class PageManager extends BaseManager implements CatalogSource {
429
440
  */
430
441
  private formerTitleIndex;
431
442
  private providerClass?;
443
+ /** The system-categories each shipped source ships, read once ({@link shippedSystemCategories}, #1503). */
444
+ private shippedCategoriesRead;
432
445
  /**
433
446
  * Creates a new PageManager instance
434
447
  *
@@ -583,9 +596,48 @@ declare class PageManager extends BaseManager implements CatalogSource {
583
596
  private loadSeededRecord;
584
597
  /**
585
598
  * The source for the required pages this release ships (#1405, #1406).
586
- * Pages in a category whose source is `repo` are excluded.
599
+ * Pages in a system-category with no `storageLocation` (never stored on the
600
+ * site, #1503) are excluded.
587
601
  */
588
602
  requiredPagesSource(): ShippedPageSource;
603
+ /**
604
+ * The system-categories that ship pages (#1503), lower-cased: the
605
+ * `system-category:` values in the frontmatter of required-pages/ (read
606
+ * once) and of each enabled add-on's pages (read once per add-on; a page
607
+ * without one is `addon`, as the seed stamps it). No setting names them
608
+ * (operator, 2026-09-28). A file the seed would exclude is left out.
609
+ *
610
+ * Only for category questions (moving a page into one, the #1377 check):
611
+ * whether a page is shipped is {@link isShippedPageCopy}, per page.
612
+ */
613
+ shippedSystemCategories(): Promise<Set<string>>;
614
+ /** One shipped source's system-categories, read once; a failed read is not kept, so the next call reads again. */
615
+ private categoriesShippedBy;
616
+ private readShippedSystemCategories;
617
+ /**
618
+ * Which system-category a write by `ctx` gives a page (#1503, operator
619
+ * 2026-10-29): only an administrator (`admin-system`, asked of the PDP) sets
620
+ * or changes one. Anyone else's new page takes `fallback` (else the default
621
+ * system-category) and an existing page keeps its own (`current`); a request
622
+ * naming any other is refused, never rewritten. An administrator's request
623
+ * comes back as given (`undefined` when it names none), for the caller's own
624
+ * validation. Every write path that takes a system-category from a request
625
+ * asks here: create, save, ingest, takeout import.
626
+ *
627
+ * @param ctx - Who is writing
628
+ * @param requested - The system-category the request names, if any
629
+ * @param current - The page's system-category now; absent for a new page
630
+ * @param fallback - A new page's system-category when not the default (a vault's own)
631
+ */
632
+ systemCategoryForWrite(ctx: ActorContext, requested: unknown, current?: string | null, fallback?: string): Promise<SystemCategoryForWrite>;
633
+ /**
634
+ * Whether a page on the site is a shipped page (#1503): a copy of a shipped
635
+ * original, its uuid in the site's seeded-pages record, from required-pages/
636
+ * or an add-on's pages alike (operator, 2026-10-29). Decided per page, so a
637
+ * page someone creates on the site in a shipped system-category is not
638
+ * taken for a shipped one. Shipped pages are admin-only and never private.
639
+ */
640
+ isShippedPageCopy(uuid: string): Promise<boolean>;
589
641
  /**
590
642
  * One-time access backfill for a live required page (#1411): a page with no
591
643
  * `access` gets the default for its category, as a metadata-only save that
@@ -1654,8 +1706,6 @@ declare class PageManager extends BaseManager implements CatalogSource {
1654
1706
  private roleNames;
1655
1707
  /** Whether an index entry still holds an author or editor the conversion would change (#2097). No lookup is written. */
1656
1708
  private authorshipNeedsConversion;
1657
- /** The system-categories whose pages are shipped with the code (source `shipped`), lower-cased (#2097). */
1658
- private shippedCategories;
1659
1709
  /** `ngdpbase.page.authorship-conversion.batch-size`: pages converted between yields (#2097). */
1660
1710
  private authorshipBatchSize;
1661
1711
  /** Counts only, never a name (#2097): the log, and the dashboard for orphans and refused pages. */
@@ -57,6 +57,33 @@ export default class ShareManager extends BaseManager {
57
57
  constructor(engine: WikiEngine);
58
58
  initialize(): Promise<void>;
59
59
  isEnabled(): boolean;
60
+ /**
61
+ * The origin every share link is minted and shown with, no trailing slash.
62
+ *
63
+ * `ngdpbase.share.public-base-url` when it is set and valid: an instance
64
+ * whose own address is private (a LAN name) can hand out links that open
65
+ * from outside, through a tunnel or proxy that carries only `/share/…`.
66
+ * Otherwise the configured `ngdpbase.application.base-url`, else the
67
+ * request's own origin. Read on every call, so a change made on the admin
68
+ * screen applies to the next link shown.
69
+ */
70
+ linkBaseUrl(requestOrigin: string): string;
71
+ /**
72
+ * The one startup warning for a public origin on an instance that terminates
73
+ * TLS itself (#2117), or null. A plain-http connection to such an instance
74
+ * gets the #1163 redirect to `ngdpbase.application.base-url`, so a tunnel or
75
+ * proxy in front that connects over http sends every link's visitor to the
76
+ * site's own (possibly private) address. Whether a proxy exists, and how it
77
+ * connects, cannot be seen from here; the warning says what it must do.
78
+ */
79
+ publicOriginTlsWarning(nativeTls: boolean): string | null;
80
+ /**
81
+ * `ngdpbase.share.public-base-url`, validated: an absolute http(s) URL with
82
+ * no path beyond `/`, no query, fragment or credentials. Unset is
83
+ * `{ url: null, problem: null }`; an invalid value is ignored and named in
84
+ * `problem`.
85
+ */
86
+ private readPublicBaseUrl;
60
87
  /**
61
88
  * Issue a new share: a delegation by `issuer` of `actions` over `resources`
62
89
  * (#1221, epic #1225).
@@ -36,22 +36,28 @@ export interface PageValidationResult extends MetadataValidationResult {
36
36
  export interface ContentValidationResult {
37
37
  warnings: string[];
38
38
  }
39
- /**
40
- * Where a category's pages come from (#1503): made on this site, shipped with
41
- * the release in required-pages/, or kept in the repository's docs/ and never
42
- * stored on the site. Replaces the old storageLocation words one for one
43
- * (regular → site, required → shipped, github → repo).
44
- */
45
39
  /** A new share link's maximum lifetime when a vault's system-category sets none (operator, 2026-09-30, #1388). */
46
40
  export declare const DEFAULT_SHARE_MAX_DAYS = 15;
47
- export type CategorySource = 'site' | 'shipped' | 'repo';
48
41
  /**
49
- * A category entry's source. An entry without a valid one is a site category.
50
- * The one rule every reader of a category's source goes through.
42
+ * Where a system-category's pages are stored on the site (#1503,
43
+ * docs/system-category.md "Where a page's master copy lives"):
44
+ *
45
+ * - `none`: the entry has no `storageLocation`; its pages are never stored on
46
+ * the site (they live in the repository's docs/), so saving one is refused
47
+ * and none is seeded.
48
+ * - `public`: a `storageLocation` without a vault; its pages can never be
49
+ * private.
50
+ * - `vault`: a `storageLocation.privatestore`; its pages may be private.
51
+ */
52
+ export type CategoryStored = 'none' | 'public' | 'vault';
53
+ /**
54
+ * Where an entry's pages are stored. The one rule every reader of "is this
55
+ * system-category stored here, and can it be private" goes through. A
56
+ * system-category's former `source` field is never read (#1503).
51
57
  */
52
- export declare function categorySource(cfg: {
53
- source?: string;
54
- } | null | undefined): CategorySource;
58
+ export declare function categoryStored(cfg: {
59
+ storageLocation?: unknown;
60
+ } | null | undefined): CategoryStored;
55
61
  /**
56
62
  * What is wrong with a `ngdpbase.system-category` map, one line per problem
57
63
  * (#1504). Empty when it is sound. Startup refuses a map with problems
@@ -73,9 +79,13 @@ export interface CategoryConfig {
73
79
  label: string;
74
80
  description?: string;
75
81
  default?: boolean;
76
- source?: CategorySource;
77
82
  enabled?: boolean;
78
83
  key?: string;
84
+ /** Where its pages are stored; see {@link categoryStored}. */
85
+ storageLocation?: {
86
+ defaultstore?: string;
87
+ privatestore?: string;
88
+ };
79
89
  /** `false`: its pages show no Comments tab and take no comment, unless a page says otherwise (#1951). */
80
90
  comments?: boolean;
81
91
  /** `false`: its pages show no Footnotes tab and take no footnote change, unless a page says otherwise (#1951). */
@@ -243,11 +253,12 @@ declare class ValidationManager extends BaseManager {
243
253
  */
244
254
  getCategoryConfig(label: string): CategoryConfig | null;
245
255
  /**
246
- * Where a category's pages come from (#1503)
256
+ * Where a system-category's pages are stored on the site (#1503): see
257
+ * {@link categoryStored}. Null for a system-category the site does not
258
+ * know; no rule refuses a page for that.
247
259
  * @param {string} category - Category label
248
- * @returns {CategorySource} 'site', 'shipped' or 'repo'; an unknown category is 'site'
249
260
  */
250
- getCategorySource(category: string): CategorySource;
261
+ getCategoryStored(category: string): CategoryStored | null;
251
262
  /**
252
263
  * How a new page's Private box starts for this system-category and this
253
264
  * person (#1504): the entry's `defaultPrivate` — `true`, `false`, or
@@ -8,6 +8,37 @@ export type ConsentScope = string;
8
8
  export declare const CONSENT_PATH = "/consent";
9
9
  /** The scope of core's own documents, `ngdpbase.consent.documents`. */
10
10
  export declare const CORE_CONSENT_SCOPE = "core";
11
+ /**
12
+ * The scope of a user's confirmation that they saved their own recovery words
13
+ * (#1928). Not core's terms: it never holds a session. It is asked where the
14
+ * words are shown (the store door, and a new set of words, #1925), and a user
15
+ * with encrypted vaults who has not confirmed the current version for their
16
+ * current words is reminded by a banner.
17
+ *
18
+ * Bound to a subject: the set of words (`recoveryWordsId`), so a new set asks
19
+ * again.
20
+ */
21
+ export declare const RECOVERY_WORDS_CONSENT_SCOPE = "recovery-words";
22
+ /** The page whose `version:` is the version of the recovery-words wording (#1928). */
23
+ export declare const RECOVERY_WORDS_DOCUMENT = "recovery-words";
24
+ /**
25
+ * The scope of an admin's confirmation that they saved the organization's
26
+ * shared-folders recovery words (#2124, epic #1775 decision 4). Like
27
+ * `recovery-words` it never holds a session: it is asked where the words are
28
+ * shown (Admin → Backup), and until the admin who was shown them confirms,
29
+ * holders of `admin-system` see a banner. Bound to a subject: the
30
+ * organization's set of words (`SharedFolderManager.recoveryWordsInfo().wordsId`),
31
+ * so a new set asks again.
32
+ */
33
+ export declare const SHARED_FOLDERS_WORDS_CONSENT_SCOPE = "shared-folders-recovery-words";
34
+ /** The page whose `version:` is the version of the shared-folders recovery-words wording (#2124). */
35
+ export declare const SHARED_FOLDERS_WORDS_DOCUMENT = "shared-folders-recovery-words";
36
+ /** Core's scopes bound to a subject, each with its one document (#1928, #2124). */
37
+ export declare const SUBJECT_CONSENT_DOCUMENTS: Readonly<Record<ConsentScope, string>>;
38
+ /** Scopes whose acceptance is about one thing (a subject), not the account as a whole (#1928). */
39
+ export declare const SUBJECT_CONSENT_SCOPES: readonly ConsentScope[];
40
+ /** Scopes core reserves: an add-on can never register one as its slug. */
41
+ export declare const RESERVED_CONSENT_SCOPES: readonly ConsentScope[];
11
42
  /**
12
43
  * A document a person is asked to accept, as it stands now: a page whose
13
44
  * front matter carries `version:` and `effective:`. Read from the page each
@@ -46,7 +77,18 @@ export interface ConsentRecord {
46
77
  * account that takes the same username never inherits it.
47
78
  */
48
79
  account?: string;
49
- /** Who and how: the account, where from, and the way they were asked (`sign-up`, `sign-in`, `add-on`). */
80
+ /**
81
+ * What the acceptance is about, in a scope bound to a subject (#1928): for
82
+ * the recovery words, which set of words (`recoveryWordsId`). Absent
83
+ * elsewhere. A new subject (a new set of words) asks again.
84
+ */
85
+ subject?: string;
86
+ /**
87
+ * Who and how: the account, where from, and the way they were asked
88
+ * (`sign-up`, `sign-in`, `add-on`; for the recovery words `store-door`,
89
+ * `reminder`, and `new-words` once #1925 makes a new set; for the
90
+ * shared-folders words `setup` and `reminder`).
91
+ */
50
92
  context: {
51
93
  by: string;
52
94
  how: string;
@@ -6,6 +6,40 @@
6
6
  export const CONSENT_PATH = '/consent';
7
7
  /** The scope of core's own documents, `ngdpbase.consent.documents`. */
8
8
  export const CORE_CONSENT_SCOPE = 'core';
9
+ /**
10
+ * The scope of a user's confirmation that they saved their own recovery words
11
+ * (#1928). Not core's terms: it never holds a session. It is asked where the
12
+ * words are shown (the store door, and a new set of words, #1925), and a user
13
+ * with encrypted vaults who has not confirmed the current version for their
14
+ * current words is reminded by a banner.
15
+ *
16
+ * Bound to a subject: the set of words (`recoveryWordsId`), so a new set asks
17
+ * again.
18
+ */
19
+ export const RECOVERY_WORDS_CONSENT_SCOPE = 'recovery-words';
20
+ /** The page whose `version:` is the version of the recovery-words wording (#1928). */
21
+ export const RECOVERY_WORDS_DOCUMENT = 'recovery-words';
22
+ /**
23
+ * The scope of an admin's confirmation that they saved the organization's
24
+ * shared-folders recovery words (#2124, epic #1775 decision 4). Like
25
+ * `recovery-words` it never holds a session: it is asked where the words are
26
+ * shown (Admin → Backup), and until the admin who was shown them confirms,
27
+ * holders of `admin-system` see a banner. Bound to a subject: the
28
+ * organization's set of words (`SharedFolderManager.recoveryWordsInfo().wordsId`),
29
+ * so a new set asks again.
30
+ */
31
+ export const SHARED_FOLDERS_WORDS_CONSENT_SCOPE = 'shared-folders-recovery-words';
32
+ /** The page whose `version:` is the version of the shared-folders recovery-words wording (#2124). */
33
+ export const SHARED_FOLDERS_WORDS_DOCUMENT = 'shared-folders-recovery-words';
34
+ /** Core's scopes bound to a subject, each with its one document (#1928, #2124). */
35
+ export const SUBJECT_CONSENT_DOCUMENTS = {
36
+ [RECOVERY_WORDS_CONSENT_SCOPE]: RECOVERY_WORDS_DOCUMENT,
37
+ [SHARED_FOLDERS_WORDS_CONSENT_SCOPE]: SHARED_FOLDERS_WORDS_DOCUMENT
38
+ };
39
+ /** Scopes whose acceptance is about one thing (a subject), not the account as a whole (#1928). */
40
+ export const SUBJECT_CONSENT_SCOPES = Object.keys(SUBJECT_CONSENT_DOCUMENTS);
41
+ /** Scopes core reserves: an add-on can never register one as its slug. */
42
+ export const RESERVED_CONSENT_SCOPES = [CORE_CONSENT_SCOPE, ...SUBJECT_CONSENT_SCOPES];
9
43
  /**
10
44
  * Thrown when a per-person add-on is turned on before its documents are
11
45
  * accepted (#1603). `documents` names what is still to accept.
@@ -166,6 +166,12 @@ export declare const AUDIT_EVENT: {
166
166
  readonly SHARE_EXTEND: "share-extend";
167
167
  /** Share link revoked; pairs with token-revoke */
168
168
  readonly SHARE_REVOKE: "share-revoke";
169
+ /** A shared folder's own key was made (#2124): which folder and who, never the key. Recorded before the key is saved, which cannot proceed without it */
170
+ readonly SHARED_FOLDER_KEY_CREATE: "shared-folder-key-create";
171
+ /** The organization's 12 recovery words for shared folders were made and saved (#2124): the admin who was shown them, and which set (an id, never the words). Recorded before the words are saved, which cannot proceed without it */
172
+ readonly SHARED_FOLDER_WORDS_CREATE: "shared-folder-words-create";
173
+ /** An administrator discarded the organization's shared-folders recovery words before the admin shown them confirmed saving them (#2124): who, which set (an id) and who it was shown to. Recorded before the words are removed, which cannot proceed without it */
174
+ readonly SHARED_FOLDER_WORDS_DISCARD: "shared-folder-words-discard";
169
175
  /** Sign-in approval: the browser that was waiting gave up the sign-in (#1532) */
170
176
  readonly SIGNINAPPROVAL_ABANDON: "signinapproval-abandon";
171
177
  /** Sign-in approval: the user pressed Approve (#1532) */
@@ -173,6 +173,12 @@ export const AUDIT_EVENT = {
173
173
  SHARE_EXTEND: 'share-extend',
174
174
  /** Share link revoked; pairs with token-revoke */
175
175
  SHARE_REVOKE: 'share-revoke',
176
+ /** A shared folder's own key was made (#2124): which folder and who, never the key. Recorded before the key is saved, which cannot proceed without it */
177
+ SHARED_FOLDER_KEY_CREATE: 'shared-folder-key-create',
178
+ /** The organization's 12 recovery words for shared folders were made and saved (#2124): the admin who was shown them, and which set (an id, never the words). Recorded before the words are saved, which cannot proceed without it */
179
+ SHARED_FOLDER_WORDS_CREATE: 'shared-folder-words-create',
180
+ /** An administrator discarded the organization's shared-folders recovery words before the admin shown them confirmed saving them (#2124): who, which set (an id) and who it was shown to. Recorded before the words are removed, which cannot proceed without it */
181
+ SHARED_FOLDER_WORDS_DISCARD: 'shared-folder-words-discard',
176
182
  /** Sign-in approval: the browser that was waiting gave up the sign-in (#1532) */
177
183
  SIGNINAPPROVAL_ABANDON: 'signinapproval-abandon',
178
184
  /** Sign-in approval: the user pressed Approve (#1532) */
@@ -78,9 +78,9 @@ export declare function sealKekCopy(recipient: LinkPublicKey, kek: Buffer): Prom
78
78
  * their private key from `envelope`, which opens `copy`. Throws when any step
79
79
  * fails; the private key never leaves this function.
80
80
  */
81
- export declare function openKekCopy(envelope: UserKeyEnvelope, ownKek: Buffer, copy: Lockbox): Promise<Buffer>;
81
+ export declare function openKekCopy(envelope: Pick<UserKeyEnvelope, 'personKey'>, ownKek: Buffer, copy: Lockbox): Promise<Buffer>;
82
82
  export declare function unwrapKekWithPassword(envelope: UserKeyEnvelope, password: string): Promise<Buffer>;
83
- export declare function unwrapKekWithMnemonic(envelope: UserKeyEnvelope, mnemonic: string): Buffer;
83
+ export declare function unwrapKekWithMnemonic(envelope: Pick<UserKeyEnvelope, 'recoveryWrap'>, mnemonic: string): Buffer;
84
84
  /**
85
85
  * A fresh set of 12 recovery words for a KEK that has not been committed yet
86
86
  * (#1414): the store door's words screen, where a failed confirmation discards
@@ -91,6 +91,14 @@ export declare function newRecoveryWords(kek: Buffer): {
91
91
  mnemonic: string;
92
92
  recoveryWrap: WrappedBlob;
93
93
  };
94
+ /**
95
+ * Which set of recovery words an envelope holds (#1928), as a short id: a
96
+ * digest of the recovery wrap, which a new set of words replaces and nothing
97
+ * else changes (a password change or a new person key keep it). Reveals
98
+ * nothing about the words or the key; it is what a recorded "I saved my
99
+ * recovery words" is bound to, so a new set asks again.
100
+ */
101
+ export declare function recoveryWordsId(envelope: Pick<UserKeyEnvelope, 'recoveryWrap'>): string;
94
102
  /** True when two phrases are the same 12 words, compared as the recovery wrap reads them. */
95
103
  export declare function sameMnemonic(entered: string, expected: string): boolean;
96
104
  export declare function rewrapPassword(envelope: UserKeyEnvelope, oldPassword: string, newPassword: string, opts?: {
@@ -52,6 +52,8 @@ export declare function unlockPrivateStoresWithMnemonic(args: {
52
52
  }): Promise<boolean>;
53
53
  /** Whether `username` has keys at all, i.e. a store that can be encrypted. */
54
54
  export declare function hasUserKeys(pagesDirectory: string, username: string): Promise<boolean>;
55
+ /** Which set of recovery words `username` has (#1928, `recoveryWordsId`), or null when they have no keys. */
56
+ export declare function recoveryWordsIdOf(pagesDirectory: string, username: string): Promise<string | null>;
55
57
  /**
56
58
  * The public half of `username`'s key pair (#1762 slice 5), or null when they
57
59
  * have no keys or no pair yet. Public: anyone may seal to it.
@@ -11,7 +11,7 @@
11
11
  * recipient's browser opens each lockbox with the private key from the link.
12
12
  *
13
13
  * The algorithm is WebCrypto's own, so the server (Node's `crypto.webcrypto`)
14
- * and the browser (`window.crypto.subtle`, public/js/share-viewer.js) run the
14
+ * and the browser (`window.crypto.subtle`, public/share/_assets/share-viewer.js) run the
15
15
  * same steps: ECDH on P-256 between a fresh ephemeral key and the link's key,
16
16
  * HKDF-SHA-256 over the shared secret, AES-256-GCM over the content.
17
17
  */
@@ -0,0 +1,17 @@
1
+ /** The environment variable holding the shared-folders key. Never a config key: config is readable from the admin screens. */
2
+ export declare const SHARED_FOLDERS_KEY_ENV = "NGDPBASE_SHARED_FOLDERS_KEY";
3
+ /** The shared-folders key as set now, trimmed; '' when there is none. Never logged. */
4
+ export declare function currentSharedFoldersKey(): string;
5
+ /**
6
+ * A short fingerprint of the shared-folders key: the first 16 hex digits of an
7
+ * HMAC of a fixed label under the key, as for the backup key. Shown on Admin →
8
+ * Backup and recorded with the wrapped folder keys, so a different key says so
9
+ * plainly; it never reveals the key.
10
+ */
11
+ export declare function sharedFoldersKeyFingerprint(key: string): string;
12
+ /**
13
+ * The 32-byte key the folder keys are wrapped under, derived from the `.env`
14
+ * value (HKDF-SHA256, one HMAC round: cheap, never a stall). Derived, not
15
+ * decoded, so a value an operator set themselves in any form still works.
16
+ */
17
+ export declare function sharedFoldersWrapKey(key: string): Buffer;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The shared-folders key (#2124, epic #1775 decision 1): the organization's
3
+ * server-held key, `NGDPBASE_SHARED_FOLDERS_KEY`, in the instance `.env`
4
+ * beside the backup, database and credentials keys. Generated on first start
5
+ * (`bootstrap-env.ts`), read at startup by SharedFolderManager, which wraps
6
+ * every shared folder's own key under it. One declaration here, with no
7
+ * dependencies, so the boot can name it without loading the manager, and
8
+ * BackupManager can fingerprint it for the "saved off the server" check.
9
+ */
10
+ import { createHmac, hkdfSync } from 'node:crypto';
11
+ /** The environment variable holding the shared-folders key. Never a config key: config is readable from the admin screens. */
12
+ export const SHARED_FOLDERS_KEY_ENV = 'NGDPBASE_SHARED_FOLDERS_KEY';
13
+ /** The shared-folders key as set now, trimmed; '' when there is none. Never logged. */
14
+ export function currentSharedFoldersKey() {
15
+ return (process.env[SHARED_FOLDERS_KEY_ENV] ?? '').trim();
16
+ }
17
+ /**
18
+ * A short fingerprint of the shared-folders key: the first 16 hex digits of an
19
+ * HMAC of a fixed label under the key, as for the backup key. Shown on Admin →
20
+ * Backup and recorded with the wrapped folder keys, so a different key says so
21
+ * plainly; it never reveals the key.
22
+ */
23
+ export function sharedFoldersKeyFingerprint(key) {
24
+ return createHmac('sha256', key).update('ngdpbase shared folders key fingerprint v1').digest('hex').slice(0, 16);
25
+ }
26
+ /**
27
+ * The 32-byte key the folder keys are wrapped under, derived from the `.env`
28
+ * value (HKDF-SHA256, one HMAC round: cheap, never a stall). Derived, not
29
+ * decoded, so a value an operator set themselves in any form still works.
30
+ */
31
+ export function sharedFoldersWrapKey(key) {
32
+ return Buffer.from(hkdfSync('sha256', key, Buffer.alloc(0), 'ngdpbase shared folders wrap key v1', 32));
33
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilleke/ngdpbase-types",
3
- "version": "5.38.0",
3
+ "version": "5.39.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",
@@ -44,6 +44,7 @@
44
44
  "dotenv": "^18.0.9",
45
45
  "express": "^5.3.0",
46
46
  "express-session": "^1.19.0",
47
+ "fast-xml-parser": "^5.11.2",
47
48
  "fs-extra": "^11.4.1",
48
49
  "ipaddr.js": "^2.5.0",
49
50
  "jose": "^6.2.13",