@maschinenlesbar.org/marktstammdatenregister-cli 0.1.0 → 0.2.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.
@@ -11,14 +11,63 @@ export function redactUrl(url) {
11
11
  parsed = new URL(url);
12
12
  }
13
13
  catch {
14
- return url;
14
+ // A value that doesn't parse (a port typo, an unencoded "#" in the password) can still
15
+ // carry credentials: cut them out by text.
16
+ return redactCredentials(url, credentialsIn(url));
15
17
  }
18
+ // `user:pw@host` without a scheme parses as a URL with the scheme "user:": no userinfo.
16
19
  if (parsed.username === "" && parsed.password === "")
17
- return url;
20
+ return redactCredentials(url, credentialsIn(url));
18
21
  parsed.username = "***";
19
22
  parsed.password = "";
20
23
  return parsed.href;
21
24
  }
25
+ /**
26
+ * The userinfo a URL-like value carries, exactly as written — `["alice:pa#ss"]` for
27
+ * `https://alice:pa#ss@host` — or `[]` when it carries none. It works on values that don't
28
+ * parse as a URL too, and on values with a prefix (`--base-url=https://u:p@h`): the userinfo
29
+ * is everything between `://` and the last `@` before the host. A value without a scheme
30
+ * counts when it reads `user:password@host`. Used to redact those exact strings from text
31
+ * that echoes the value (usage errors, help), whatever characters the password contains.
32
+ */
33
+ export function credentialsIn(value) {
34
+ if (typeof value !== "string")
35
+ return [];
36
+ const schemeAt = value.indexOf("://");
37
+ const rest = schemeAt >= 0 ? value.slice(schemeAt + 3) : value;
38
+ // Without a scheme only the unmistakable `user:password@host` form counts.
39
+ if (schemeAt < 0 && !/^[^\s/@:]+:[^@]*@[^@\s/]/.test(rest))
40
+ return [];
41
+ // The URL itself starts at its scheme (`--base-url=https://…` has a prefix).
42
+ const scheme = schemeAt >= 0 ? /[a-z][a-z0-9+.-]*$/i.exec(value.slice(0, schemeAt)) : null;
43
+ let parses = false;
44
+ try {
45
+ new URL(schemeAt >= 0 ? value.slice(scheme?.index ?? schemeAt) : `http://${rest}`);
46
+ parses = true;
47
+ }
48
+ catch {
49
+ // Doesn't parse: the password may hold "/", "?", "#" or spaces.
50
+ }
51
+ // In a URL that parses, the userinfo ends at the last "@" of the authority (before the
52
+ // first "/", "?" or "#"); in one that doesn't, at the last "@" of the value.
53
+ const authority = parses ? rest.slice(0, rest.search(/[/?#]|$/)) : rest;
54
+ const end = authority.lastIndexOf("@");
55
+ return end > 0 ? [rest.slice(0, end)] : [];
56
+ }
57
+ /**
58
+ * `text` with every occurrence of each credential (as `credentialsIn` returns them) that is
59
+ * followed by `@` replaced by `***`. Matching the exact strings, not a pattern, covers
60
+ * passwords with spaces, quotes, `#`, `?` or `/` that no URL pattern can delimit.
61
+ */
62
+ export function redactCredentials(text, credentials) {
63
+ let out = text;
64
+ for (const secret of credentials) {
65
+ if (secret === "")
66
+ continue;
67
+ out = out.split(`${secret}@`).join("***@");
68
+ }
69
+ return out;
70
+ }
22
71
  /** Base class for every error originating from this client. */
23
72
  export class MastrError extends Error {
24
73
  constructor(message, options) {
@@ -1,3 +1,4 @@
1
+ import type { FilterColumn } from "./types.js";
1
2
  /**
2
3
  * The operators the register's search understands (from its web form, all checked
3
4
  * live). `gt`/`lt` are strict and only for `number`/`date` columns; there is no
@@ -14,7 +15,8 @@ export type FilterOperator = (typeof FILTER_OPERATORS)[number];
14
15
  *
15
16
  * - every condition has a non-blank FilterName, a known lower-case operator and a
16
17
  * value (`''` for `null`/`nn`: `Ort~null` without a value is ignored upstream and
17
- * returns the unfiltered register; a blank value for the other operators);
18
+ * returns the unfiltered register); for the other operators the value is not blank,
19
+ * quoted (`''`, `' '`) or not;
18
20
  * - a value that starts with a single quote ends with one;
19
21
  * - conditions are joined by `and` only, and nothing dangles at the end.
20
22
  *
@@ -27,6 +29,26 @@ export type FilterOperator = (typeof FILTER_OPERATORS)[number];
27
29
  * the unfiltered set); compare with `filterColumns()`.
28
30
  */
29
31
  export declare function filterProblem(spec: string): string | undefined;
32
+ /**
33
+ * A FilterName as the register spells it, as far as that can be told without its column
34
+ * list: Unicode NFC, no surrounding whitespace, inner whitespace runs as one space. The
35
+ * register matches FilterNames exactly and silently ignores one it doesn't know, so
36
+ * `Energieträger` typed with a decomposed "ä" (macOS input: `a` + U+0308) or with a space
37
+ * after `~and~` returned the unfiltered set (live 2026-10-05: 9 562 366 instead of 6 545 851).
38
+ * The register's own names are NFC, never padded and never hold two spaces in a row, so this
39
+ * can't turn a valid name into another one.
40
+ */
41
+ export declare function normalizeFilterName(name: string): string;
42
+ /**
43
+ * The spec with whitespace next to a `~` dropped where it means nothing: every FilterName
44
+ * normalised ({@link normalizeFilterName}), the operators and `and`s trimmed, and the
45
+ * whitespace outside a quoted value dropped (`'Münster' ~and~…`); the inside of a quoted
46
+ * value is left as it is. The spec is split the way the register splits it, on every `~`:
47
+ * names are parts 0, 4, 8, …, operators 1, 5, …, values 2, 6, …, `and`s 3, 7, … A malformed
48
+ * spec comes back normalised too; {@link filterProblem} then reports it. A non-string is
49
+ * returned as is.
50
+ */
51
+ export declare function normalizeFilter(spec: string): string;
30
52
  /** One condition for {@link buildFilter}. */
31
53
  export interface FilterCondition {
32
54
  /** The FilterName, e.g. `"Ort"` or `"Energieträger"` (see `filterColumns()`). */
@@ -52,5 +74,28 @@ export interface FilterCondition {
52
74
  * // → "Ort~eq~'Münster'~and~Energieträger~eq~'2497,2498'"
53
75
  */
54
76
  export declare function buildFilter(conditions: readonly FilterCondition[]): string;
55
- /** Throw a {@link MastrValidationError} if {@link filterProblem} finds a problem. */
77
+ /**
78
+ * Throw a {@link MastrValidationError} if {@link filterProblem} finds a problem. It checks
79
+ * the spec as given; the client checks (and sends) the {@link normalizeFilter}ed spec.
80
+ */
56
81
  export declare function validateFilter(spec: string): void;
82
+ /**
83
+ * Check a filter spec against the category's filter columns (`filterColumns()`) and return
84
+ * it with every FilterName in the register's spelling, or throw a {@link MastrValidationError}.
85
+ * The register silently ignores what it doesn't know, so these would otherwise give a wrong
86
+ * count with exit 0:
87
+ *
88
+ * - a FilterName that is not a column of the category: the unfiltered set (live 2026-10-05:
89
+ * `energieträger~eq~'2495'` gave all 9 562 366 units, `Energieträger` on `gasverbrauch`
90
+ * all 937). A name that differs from a column only in case is written the register's way
91
+ * (the register's names don't collide in case); any other unknown name — `__proto__`
92
+ * included — is rejected with up to three close names;
93
+ * - for `eq`/`neq` on a dropdown column, a value that is not one of its codes: an unknown
94
+ * code gives 0 rows, a label (`'Wind'`) or junk in a comma list `{"Error":true}`. A label
95
+ * is named with its code;
96
+ * - `null`/`nn` on a number, dropdown or boolean column, which the register answers with
97
+ * `{"Error":true}` (they work on text columns).
98
+ *
99
+ * `spec` must already pass {@link filterProblem} (the client normalises and checks it first).
100
+ */
101
+ export declare function resolveFilter(spec: string, columns: readonly FilterColumn[], category: string): string;
@@ -13,6 +13,12 @@ export const FILTER_OPERATORS = ["eq", "neq", "sw", "ct", "nct", "ew", "null", "
13
13
  /** Operators that test for an empty / non-empty column and ignore their value (send `''`). */
14
14
  const UNARY_OPERATORS = new Set(["null", "nn"]);
15
15
  const OPERATORS = new Set(FILTER_OPERATORS);
16
+ /**
17
+ * Column types on which the register refuses `null`/`nn` with `{"Error":true}` (live
18
+ * 2026-10-05: `Bruttoleistung der Einheit`, `Energieträger`, `Bürgerenergie`). They work on
19
+ * text columns; date and MaStR-number columns are untested and left to the register.
20
+ */
21
+ const NO_NULL_TEST_TYPES = new Set(["number", "multidropdown", "boolean"]);
16
22
  const SHAPE = "Expected FilterName~op~'value' (e.g. Energieträger~eq~'2495'), several joined by ~and~.";
17
23
  /**
18
24
  * Describe why the register would misread a filter spec, or return `undefined` when
@@ -22,7 +28,8 @@ const SHAPE = "Expected FilterName~op~'value' (e.g. Energieträger~eq~'2495'), s
22
28
  *
23
29
  * - every condition has a non-blank FilterName, a known lower-case operator and a
24
30
  * value (`''` for `null`/`nn`: `Ort~null` without a value is ignored upstream and
25
- * returns the unfiltered register; a blank value for the other operators);
31
+ * returns the unfiltered register); for the other operators the value is not blank,
32
+ * quoted (`''`, `' '`) or not;
26
33
  * - a value that starts with a single quote ends with one;
27
34
  * - conditions are joined by `and` only, and nothing dangles at the end.
28
35
  *
@@ -35,6 +42,9 @@ const SHAPE = "Expected FilterName~op~'value' (e.g. Energieträger~eq~'2495'), s
35
42
  * the unfiltered set); compare with `filterColumns()`.
36
43
  */
37
44
  export function filterProblem(spec) {
45
+ if (typeof spec !== "string") {
46
+ return `Expected the filter as a string (FilterName~op~'value'), got ${spec === null ? "null" : Array.isArray(spec) ? "an array" : typeof spec}.`;
47
+ }
38
48
  const parts = spec.split("~");
39
49
  let i = 0;
40
50
  for (let n = 1;; n++) {
@@ -57,6 +67,12 @@ export function filterProblem(spec) {
57
67
  if (!UNARY_OPERATORS.has(op) && value.trim() === "") {
58
68
  return `Condition ${n} ("${name}~${op}") has no value. ${SHAPE}`;
59
69
  }
70
+ // A quoted blank value ('' or ' ') is no value either: on a text column it matches
71
+ // nothing, and on a dropdown the live register did not answer within 30 s
72
+ // (2026-10-05). Only null/nn take ''.
73
+ if (!UNARY_OPERATORS.has(op) && /^'\s*'$/.test(value.trim())) {
74
+ return `Condition ${n} ("${name}~${op}~${value.trim()}") has no value: '' is only for null/nn. ${SHAPE}`;
75
+ }
60
76
  if (value.startsWith("'") && (value.length < 2 || !value.endsWith("'"))) {
61
77
  // A later part that closes the quote means the value itself held a "~".
62
78
  const close = parts.findIndex((part, j) => j > i + 2 && part.endsWith("'"));
@@ -86,6 +102,53 @@ export function filterProblem(spec) {
86
102
  }
87
103
  }
88
104
  }
105
+ /**
106
+ * A FilterName as the register spells it, as far as that can be told without its column
107
+ * list: Unicode NFC, no surrounding whitespace, inner whitespace runs as one space. The
108
+ * register matches FilterNames exactly and silently ignores one it doesn't know, so
109
+ * `Energieträger` typed with a decomposed "ä" (macOS input: `a` + U+0308) or with a space
110
+ * after `~and~` returned the unfiltered set (live 2026-10-05: 9 562 366 instead of 6 545 851).
111
+ * The register's own names are NFC, never padded and never hold two spaces in a row, so this
112
+ * can't turn a valid name into another one.
113
+ */
114
+ export function normalizeFilterName(name) {
115
+ return name.normalize("NFC").trim().replace(/\s+/g, " ");
116
+ }
117
+ /**
118
+ * A value with the whitespace outside its quotes dropped: `'Münster' ` (a space before
119
+ * `~and~`) → `'Münster'`. The register would read the space as part of the value, and the
120
+ * shape check took the unclosed-looking value for one that holds a `~`. Inside the quotes
121
+ * nothing changes; a value that isn't quoted is trimmed.
122
+ */
123
+ function normalizeFilterValue(value) {
124
+ const trimmed = value.trim();
125
+ if (!trimmed.startsWith("'"))
126
+ return trimmed;
127
+ return trimmed.length >= 2 && trimmed.endsWith("'") ? trimmed : value;
128
+ }
129
+ /**
130
+ * The spec with whitespace next to a `~` dropped where it means nothing: every FilterName
131
+ * normalised ({@link normalizeFilterName}), the operators and `and`s trimmed, and the
132
+ * whitespace outside a quoted value dropped (`'Münster' ~and~…`); the inside of a quoted
133
+ * value is left as it is. The spec is split the way the register splits it, on every `~`:
134
+ * names are parts 0, 4, 8, …, operators 1, 5, …, values 2, 6, …, `and`s 3, 7, … A malformed
135
+ * spec comes back normalised too; {@link filterProblem} then reports it. A non-string is
136
+ * returned as is.
137
+ */
138
+ export function normalizeFilter(spec) {
139
+ if (typeof spec !== "string")
140
+ return spec;
141
+ return spec
142
+ .split("~")
143
+ .map((part, i) => {
144
+ if (i % 4 === 0)
145
+ return normalizeFilterName(part);
146
+ if (i % 4 === 2)
147
+ return normalizeFilterValue(part);
148
+ return part.trim();
149
+ })
150
+ .join("~");
151
+ }
89
152
  /**
90
153
  * Build a filter spec from conditions joined by `~and~`, quoting each value. Unlike
91
154
  * string interpolation (`Ort~eq~'${input}'`), a value can't add conditions: one with a
@@ -119,7 +182,10 @@ export function buildFilter(conditions) {
119
182
  if ((typeof item !== "string" && typeof item !== "number") || String(item).trim() === "") {
120
183
  throw new MastrValidationError(`Invalid filter: condition ${n} ("${c.name}") needs a non-blank value, got ${JSON.stringify(item)}.`);
121
184
  }
122
- const text = String(item);
185
+ if (typeof item === "number" && !Number.isFinite(item)) {
186
+ throw new MastrValidationError(`Invalid filter: condition ${n} ("${c.name}") needs a finite number, got ${String(item)}.`);
187
+ }
188
+ const text = typeof item === "number" ? plainNumber(item) : item;
123
189
  if (text.includes("~")) {
124
190
  throw new MastrValidationError(`Invalid filter: the value ${JSON.stringify(text)} in condition ${n} contains "~", which the ` +
125
191
  "register reads as a separator (there is no escape).");
@@ -132,13 +198,127 @@ export function buildFilter(conditions) {
132
198
  });
133
199
  return `${c.name}~${c.op}~'${texts.join(",")}'`;
134
200
  });
135
- const spec = parts.join("~and~");
201
+ const spec = normalizeFilter(parts.join("~and~"));
136
202
  validateFilter(spec);
137
203
  return spec;
138
204
  }
139
- /** Throw a {@link MastrValidationError} if {@link filterProblem} finds a problem. */
205
+ /**
206
+ * A number as the register reads it: decimal point, no exponent, no grouping. `String()`
207
+ * writes `1e-7` and `1e+21`, which the register answers with `{"Error":true}`.
208
+ */
209
+ function plainNumber(n) {
210
+ const text = String(n);
211
+ return /e/i.test(text) ? n.toLocaleString("en-US", { useGrouping: false, maximumFractionDigits: 20 }) : text;
212
+ }
213
+ /**
214
+ * Throw a {@link MastrValidationError} if {@link filterProblem} finds a problem. It checks
215
+ * the spec as given; the client checks (and sends) the {@link normalizeFilter}ed spec.
216
+ */
140
217
  export function validateFilter(spec) {
141
218
  const problem = filterProblem(spec);
142
219
  if (problem !== undefined)
143
220
  throw new MastrValidationError(`Invalid filter: ${problem}`);
144
221
  }
222
+ /** Edit distance between two strings (Levenshtein), for "did you mean". */
223
+ function editDistance(a, b) {
224
+ let previous = Array.from({ length: b.length + 1 }, (_, j) => j);
225
+ for (let i = 1; i <= a.length; i++) {
226
+ const current = [i];
227
+ for (let j = 1; j <= b.length; j++) {
228
+ current[j] = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
229
+ }
230
+ previous = current;
231
+ }
232
+ return previous[b.length];
233
+ }
234
+ /** Up to three column names close to `name`, closest first. */
235
+ function closeNames(name, names) {
236
+ const lower = name.toLowerCase();
237
+ const limit = Math.max(2, Math.floor(name.length / 4));
238
+ return names
239
+ .map((candidate) => {
240
+ const other = candidate.toLowerCase();
241
+ const distance = other.includes(lower) || lower.includes(other) ? 0 : editDistance(lower, other);
242
+ return { candidate, distance };
243
+ })
244
+ .filter(({ distance }) => distance <= limit)
245
+ .sort((x, y) => x.distance - y.distance)
246
+ .slice(0, 3)
247
+ .map(({ candidate }) => candidate);
248
+ }
249
+ /** A value without its surrounding single quotes, when it has them. */
250
+ function unquote(value) {
251
+ return value.length >= 2 && value.startsWith("'") && value.endsWith("'") ? value.slice(1, -1) : value;
252
+ }
253
+ /**
254
+ * Check a filter spec against the category's filter columns (`filterColumns()`) and return
255
+ * it with every FilterName in the register's spelling, or throw a {@link MastrValidationError}.
256
+ * The register silently ignores what it doesn't know, so these would otherwise give a wrong
257
+ * count with exit 0:
258
+ *
259
+ * - a FilterName that is not a column of the category: the unfiltered set (live 2026-10-05:
260
+ * `energieträger~eq~'2495'` gave all 9 562 366 units, `Energieträger` on `gasverbrauch`
261
+ * all 937). A name that differs from a column only in case is written the register's way
262
+ * (the register's names don't collide in case); any other unknown name — `__proto__`
263
+ * included — is rejected with up to three close names;
264
+ * - for `eq`/`neq` on a dropdown column, a value that is not one of its codes: an unknown
265
+ * code gives 0 rows, a label (`'Wind'`) or junk in a comma list `{"Error":true}`. A label
266
+ * is named with its code;
267
+ * - `null`/`nn` on a number, dropdown or boolean column, which the register answers with
268
+ * `{"Error":true}` (they work on text columns).
269
+ *
270
+ * `spec` must already pass {@link filterProblem} (the client normalises and checks it first).
271
+ */
272
+ export function resolveFilter(spec, columns, category) {
273
+ const byName = new Map();
274
+ const byLowerName = new Map();
275
+ for (const column of columns) {
276
+ if (typeof column.FilterName !== "string")
277
+ continue;
278
+ byName.set(column.FilterName, column);
279
+ const lower = column.FilterName.toLowerCase();
280
+ byLowerName.set(lower, [...(byLowerName.get(lower) ?? []), column]);
281
+ }
282
+ const parts = spec.split("~");
283
+ for (let i = 0, n = 1; i < parts.length; i += 4, n++) {
284
+ const name = parts[i];
285
+ let column = byName.get(name);
286
+ if (column === undefined) {
287
+ const sameCase = byLowerName.get(name.toLowerCase()) ?? [];
288
+ if (sameCase.length === 1)
289
+ column = sameCase[0];
290
+ }
291
+ if (column === undefined) {
292
+ const near = closeNames(name, [...byName.keys()]);
293
+ throw new MastrValidationError(`Invalid filter: unknown FilterName ${JSON.stringify(name)} in condition ${n}: ${category} has no such ` +
294
+ "column, and the register would ignore it and return the unfiltered set." +
295
+ (near.length > 0 ? ` Did you mean ${near.map((x) => JSON.stringify(x)).join(", ")}?` : "") +
296
+ ` List the names with \`mastr filters ${category}\` (filterColumns() in the library).`);
297
+ }
298
+ parts[i] = column.FilterName;
299
+ const op = parts[i + 1];
300
+ if (UNARY_OPERATORS.has(op) && NO_NULL_TEST_TYPES.has(String(column.Type))) {
301
+ throw new MastrValidationError(`Invalid filter: ${op} (condition ${n}) doesn't work on the ${String(column.Type)} column ` +
302
+ `${JSON.stringify(column.FilterName)}: the register answers null/nn on number, dropdown and ` +
303
+ "boolean columns with an error (they work on text columns). There is no way to ask for " +
304
+ "the units without a value in such a column.");
305
+ }
306
+ const value = unquote(parts[i + 2].trim());
307
+ const codes = (column.ListObject ?? []).filter((o) => typeof o?.Value === "string");
308
+ if (column.Type === "multidropdown" && codes.length > 0 && (op === "eq" || op === "neq")) {
309
+ for (const item of value.split(",").map((x) => x.trim())) {
310
+ if (codes.some((o) => o.Value === item))
311
+ continue;
312
+ const label = codes.find((o) => typeof o.Name === "string" && o.Name.toLowerCase() === item.toLowerCase());
313
+ const shown = codes.slice(0, 8).map((o) => `${o.Value} (${o.Name ?? ""})`).join(", ");
314
+ throw new MastrValidationError(`Invalid filter: ${JSON.stringify(item)} is not a code of the dropdown column ` +
315
+ `${JSON.stringify(column.FilterName)} (condition ${n}). ` +
316
+ (label !== undefined
317
+ ? `It is the label of code ${label.Value}: a dropdown takes its code (${column.FilterName}~${op}~'${label.Value}').`
318
+ : `The register would answer 0 rows or an error. Codes: ${shown}${codes.length > 8 ? ", …" : ""}; ` +
319
+ `all of them with \`mastr filters ${category}\`.`));
320
+ }
321
+ }
322
+ }
323
+ return parts.join("~");
324
+ }
@@ -10,13 +10,26 @@ export interface HttpRequest {
10
10
  timeoutMs?: number;
11
11
  /** Hard cap on the response body size in bytes; the request aborts if exceeded. */
12
12
  maxResponseBytes?: number;
13
+ /**
14
+ * Aborted when the engine's overall deadline (`timeoutMs`) passes. A transport should stop
15
+ * the request then (`fetch(url, { signal })`); the engine rejects at the deadline either way,
16
+ * and enforces `maxResponseBytes` on the body it gets back, so neither limit depends on it.
17
+ */
18
+ signal?: AbortSignal;
13
19
  }
20
+ /**
21
+ * What a transport resolves with. The engine also accepts what a `fetch`-based transport
22
+ * naturally returns: `headers` as a `Headers` object, a `Map` or a record in any case, and
23
+ * `body` as any `ArrayBuffer` view (a `Uint8Array`, from any realm) or an `ArrayBuffer`.
24
+ */
14
25
  export interface HttpResponse {
15
26
  status: number;
16
27
  headers: http.IncomingHttpHeaders;
17
28
  body: Buffer;
18
29
  }
19
30
  export type Transport = (request: HttpRequest) => Promise<HttpResponse>;
31
+ /** The message for a body over the size cap, naming the option on both sides. */
32
+ export declare function sizeLimitMessage(maxBytes: number): string;
20
33
  /**
21
34
  * The longest delay Node's timers support (2^31 - 1 ms, about 24.8 days). A longer one
22
35
  * prints a TimeoutOverflowWarning and fires after 1 ms, so timeouts are capped here.
@@ -8,6 +8,10 @@
8
8
  import http from "node:http";
9
9
  import https from "node:https";
10
10
  import { MastrNetworkError, redactUrl } from "./errors.js";
11
+ /** The message for a body over the size cap, naming the option on both sides. */
12
+ export function sizeLimitMessage(maxBytes) {
13
+ return `Response exceeded the size limit of ${maxBytes} bytes (maxResponseBytes; --max-response-bytes on the CLI)`;
14
+ }
11
15
  /**
12
16
  * The longest delay Node's timers support (2^31 - 1 ms, about 24.8 days). A longer one
13
17
  * prints a TimeoutOverflowWarning and fires after 1 ms, so timeouts are capped here.
@@ -64,7 +68,7 @@ export const nodeHttpTransport = (request) => new Promise((resolve, reject) => {
64
68
  if (maxBytes !== undefined && received > maxBytes) {
65
69
  aborted = true;
66
70
  res.destroy();
67
- fail(new MastrNetworkError(`Response exceeded maxResponseBytes (${maxBytes})`));
71
+ fail(new MastrNetworkError(sizeLimitMessage(maxBytes)));
68
72
  return;
69
73
  }
70
74
  chunks.push(chunk);
@@ -99,6 +103,17 @@ export const nodeHttpTransport = (request) => new Promise((resolve, reject) => {
99
103
  req.destroy(err);
100
104
  }, Math.min(timeoutMs, MAX_TIMEOUT_MS));
101
105
  }
106
+ if (request.signal !== undefined) {
107
+ const abort = () => {
108
+ const err = new MastrNetworkError(`Request timed out after ${request.timeoutMs ?? 0}ms`);
109
+ fail(err);
110
+ req.destroy(err);
111
+ };
112
+ if (request.signal.aborted)
113
+ abort();
114
+ else
115
+ request.signal.addEventListener("abort", abort, { once: true });
116
+ }
102
117
  req.on("error", (err) => {
103
118
  fail(err instanceof MastrNetworkError ? err : new MastrNetworkError(err.message, { cause: err }));
104
119
  });
@@ -1,14 +1,14 @@
1
1
  export { MastrClient, parseMsDate, isoifyDates, MAX_PAGE, MAX_PAGE_SIZE } from "./client.js";
2
2
  export type { MastrClientOptions } from "./client.js";
3
- export { FILTER_OPERATORS, buildFilter, filterProblem, validateFilter } from "./filter.js";
3
+ export { FILTER_OPERATORS, buildFilter, filterProblem, normalizeFilter, normalizeFilterName, resolveFilter, validateFilter, } from "./filter.js";
4
4
  export type { FilterCondition, FilterOperator } from "./filter.js";
5
- export { RequestEngine, assertHeaderValue, validateBaseUrl, DEFAULT_BASE_URL, MAX_RETRIES, MAX_RETRY_AFTER_MS, parseRetryAfter, describeMastrErrors, sanitizeServerText, isBidiControl, } from "./engine.js";
5
+ export { RequestEngine, assertHeaderValue, validateBaseUrl, DEFAULT_BASE_URL, MAX_RETRIES, MAX_RETRY_AFTER_MS, parseRetryAfter, isTransientNetworkError, MAX_DETAIL_LENGTH, describeMastrErrors, sanitizeServerText, isBidiControl, } from "./engine.js";
6
6
  export type { EngineOptions, RawResponse } from "./engine.js";
7
- export { MAX_TIMEOUT_MS, nodeHttpTransport } from "./http.js";
7
+ export { MAX_TIMEOUT_MS, nodeHttpTransport, sizeLimitMessage } from "./http.js";
8
8
  export type { Transport, HttpRequest, HttpResponse } from "./http.js";
9
9
  export { buildQueryString } from "./query.js";
10
10
  export { assertValid, baseUrlProblem, baseUrlWhitespaceProblem, COUNT_IGNORED_KEYS, countQueryProblem, headerNameProblem, headerValueProblem, intRangeProblem, nonBlankProblem, sortProblem, } from "./validate.js";
11
11
  export type { Problem } from "./validate.js";
12
12
  export type { QueryParams, QueryValue } from "./query.js";
13
- export { MastrError, MastrApiError, MastrNetworkError, MastrValidationError, MastrParseError, redactUrl, } from "./errors.js";
13
+ export { MastrError, MastrApiError, MastrNetworkError, MastrValidationError, MastrParseError, redactUrl, credentialsIn, redactCredentials, } from "./errors.js";
14
14
  export * from "./types.js";
@@ -1,9 +1,9 @@
1
1
  // Public entry point for the API client library.
2
2
  export { MastrClient, parseMsDate, isoifyDates, MAX_PAGE, MAX_PAGE_SIZE } from "./client.js";
3
- export { FILTER_OPERATORS, buildFilter, filterProblem, validateFilter } from "./filter.js";
4
- export { RequestEngine, assertHeaderValue, validateBaseUrl, DEFAULT_BASE_URL, MAX_RETRIES, MAX_RETRY_AFTER_MS, parseRetryAfter, describeMastrErrors, sanitizeServerText, isBidiControl, } from "./engine.js";
5
- export { MAX_TIMEOUT_MS, nodeHttpTransport } from "./http.js";
3
+ export { FILTER_OPERATORS, buildFilter, filterProblem, normalizeFilter, normalizeFilterName, resolveFilter, validateFilter, } from "./filter.js";
4
+ export { RequestEngine, assertHeaderValue, validateBaseUrl, DEFAULT_BASE_URL, MAX_RETRIES, MAX_RETRY_AFTER_MS, parseRetryAfter, isTransientNetworkError, MAX_DETAIL_LENGTH, describeMastrErrors, sanitizeServerText, isBidiControl, } from "./engine.js";
5
+ export { MAX_TIMEOUT_MS, nodeHttpTransport, sizeLimitMessage } from "./http.js";
6
6
  export { buildQueryString } from "./query.js";
7
7
  export { assertValid, baseUrlProblem, baseUrlWhitespaceProblem, COUNT_IGNORED_KEYS, countQueryProblem, headerNameProblem, headerValueProblem, intRangeProblem, nonBlankProblem, sortProblem, } from "./validate.js";
8
- export { MastrError, MastrApiError, MastrNetworkError, MastrValidationError, MastrParseError, redactUrl, } from "./errors.js";
8
+ export { MastrError, MastrApiError, MastrNetworkError, MastrValidationError, MastrParseError, redactUrl, credentialsIn, redactCredentials, } from "./errors.js";
9
9
  export * from "./types.js";
@@ -98,7 +98,8 @@ export interface UnitQuery {
98
98
  * no working `~or~` (the register drops everything after it, so the client rejects
99
99
  * it); for several codes of one dropdown column list them in one value:
100
100
  * `"Energieträger~eq~'2497,2498'"`. Discover the `FilterName`s and dropdown codes
101
- * via {@link MastrClient.filterColumns}.
101
+ * via {@link MastrClient.filterColumns}; the client checks them against that list
102
+ * (unless `allowUnknownFilters`), because the register ignores an unknown name.
102
103
  */
103
104
  filter?: string;
104
105
  }
@@ -43,8 +43,10 @@ export declare const baseUrlWhitespaceProblem: Problem<string>;
43
43
  * characters ({@link baseUrlWhitespaceProblem}), an absolute URL, the `http:` or
44
44
  * `https:` scheme, and no query or fragment — request paths are appended to the
45
45
  * base URL as a string, so a `?` or `#` would swallow every path (`http://h/?x=1`
46
- * requests `/?x=1/Einheit/...`, `http://h/#f` requests `/`). Userinfo is allowed
47
- * (error messages redact it). The reasons never echo the URL.
46
+ * requests `/?x=1/Einheit/...`, `http://h/#f` requests `/`), and a `%` in the user name or
47
+ * password that doesn't start a valid escape (Node fails to decode it for the Authorization
48
+ * header at request time; a literal one is `%25`). Userinfo is allowed (error messages
49
+ * redact it). The reasons never echo the URL.
48
50
  */
49
51
  export declare const baseUrlProblem: Problem<string>;
50
52
  /**
@@ -87,8 +87,10 @@ export const baseUrlWhitespaceProblem = (value) => {
87
87
  * characters ({@link baseUrlWhitespaceProblem}), an absolute URL, the `http:` or
88
88
  * `https:` scheme, and no query or fragment — request paths are appended to the
89
89
  * base URL as a string, so a `?` or `#` would swallow every path (`http://h/?x=1`
90
- * requests `/?x=1/Einheit/...`, `http://h/#f` requests `/`). Userinfo is allowed
91
- * (error messages redact it). The reasons never echo the URL.
90
+ * requests `/?x=1/Einheit/...`, `http://h/#f` requests `/`), and a `%` in the user name or
91
+ * password that doesn't start a valid escape (Node fails to decode it for the Authorization
92
+ * header at request time; a literal one is `%25`). Userinfo is allowed (error messages
93
+ * redact it). The reasons never echo the URL.
92
94
  */
93
95
  export const baseUrlProblem = (value) => {
94
96
  const blank = nonBlankProblem(value);
@@ -108,6 +110,16 @@ export const baseUrlProblem = (value) => {
108
110
  return "Only http and https URLs are supported.";
109
111
  if (/[?#]/.test(value))
110
112
  return "A base URL cannot have a query (?) or fragment (#).";
113
+ // Node decodes the userinfo into the Authorization header and throws "URI malformed" for a
114
+ // "%" that isn't an escape — at request time, as a network error. Reject it here.
115
+ for (const part of [url.username, url.password]) {
116
+ try {
117
+ decodeURIComponent(part);
118
+ }
119
+ catch {
120
+ return 'The user name or password has a "%" that is not followed by two hex digits; write a literal "%" as %25.';
121
+ }
122
+ }
111
123
  return undefined;
112
124
  };
113
125
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maschinenlesbar.org/marktstammdatenregister-cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "TypeScript API client and CLI for the Marktstammdatenregister (MaStR) — the Bundesnetzagentur's register of German electricity & gas market units",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,7 +21,7 @@
21
21
  "CONTRIBUTING.md"
22
22
  ],
23
23
  "engines": {
24
- "node": ">=20"
24
+ "node": ">=22.12"
25
25
  },
26
26
  "scripts": {
27
27
  "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
@@ -68,7 +68,7 @@
68
68
  "commander": "15.0.0"
69
69
  },
70
70
  "devDependencies": {
71
- "@types/node": "^26.5.1",
71
+ "@types/node": "^22.20.5",
72
72
  "typescript": "7.0.2"
73
73
  }
74
74
  }