@maschinenlesbar.org/marktstammdatenregister-cli 0.0.7 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +24 -4
  2. package/dist/src/cli/commands/units.d.ts +0 -1
  3. package/dist/src/cli/commands/units.js +38 -27
  4. package/dist/src/cli/index.d.ts +0 -1
  5. package/dist/src/cli/index.js +0 -1
  6. package/dist/src/cli/io.d.ts +0 -1
  7. package/dist/src/cli/io.js +0 -1
  8. package/dist/src/cli/program.d.ts +0 -1
  9. package/dist/src/cli/program.js +2 -2
  10. package/dist/src/cli/run.d.ts +0 -1
  11. package/dist/src/cli/run.js +0 -1
  12. package/dist/src/cli/shared.d.ts +29 -19
  13. package/dist/src/cli/shared.js +42 -46
  14. package/dist/src/client/client.d.ts +26 -4
  15. package/dist/src/client/client.js +105 -19
  16. package/dist/src/client/engine.d.ts +96 -18
  17. package/dist/src/client/engine.js +154 -43
  18. package/dist/src/client/errors.d.ts +12 -2
  19. package/dist/src/client/errors.js +29 -4
  20. package/dist/src/client/filter.d.ts +56 -0
  21. package/dist/src/client/filter.js +144 -0
  22. package/dist/src/client/http.d.ts +0 -1
  23. package/dist/src/client/http.js +45 -35
  24. package/dist/src/client/index.d.ts +7 -4
  25. package/dist/src/client/index.js +5 -4
  26. package/dist/src/client/query.d.ts +0 -1
  27. package/dist/src/client/query.js +0 -1
  28. package/dist/src/client/types.d.ts +29 -12
  29. package/dist/src/client/types.js +0 -1
  30. package/dist/src/client/validate.d.ts +63 -0
  31. package/dist/src/client/validate.js +131 -0
  32. package/dist/src/index.d.ts +0 -1
  33. package/dist/src/index.js +0 -1
  34. package/package.json +2 -1
  35. package/dist/src/cli/commands/units.d.ts.map +0 -1
  36. package/dist/src/cli/commands/units.js.map +0 -1
  37. package/dist/src/cli/index.d.ts.map +0 -1
  38. package/dist/src/cli/index.js.map +0 -1
  39. package/dist/src/cli/io.d.ts.map +0 -1
  40. package/dist/src/cli/io.js.map +0 -1
  41. package/dist/src/cli/program.d.ts.map +0 -1
  42. package/dist/src/cli/program.js.map +0 -1
  43. package/dist/src/cli/run.d.ts.map +0 -1
  44. package/dist/src/cli/run.js.map +0 -1
  45. package/dist/src/cli/shared.d.ts.map +0 -1
  46. package/dist/src/cli/shared.js.map +0 -1
  47. package/dist/src/client/client.d.ts.map +0 -1
  48. package/dist/src/client/client.js.map +0 -1
  49. package/dist/src/client/engine.d.ts.map +0 -1
  50. package/dist/src/client/engine.js.map +0 -1
  51. package/dist/src/client/errors.d.ts.map +0 -1
  52. package/dist/src/client/errors.js.map +0 -1
  53. package/dist/src/client/http.d.ts.map +0 -1
  54. package/dist/src/client/http.js.map +0 -1
  55. package/dist/src/client/index.d.ts.map +0 -1
  56. package/dist/src/client/index.js.map +0 -1
  57. package/dist/src/client/query.d.ts.map +0 -1
  58. package/dist/src/client/query.js.map +0 -1
  59. package/dist/src/client/types.d.ts.map +0 -1
  60. package/dist/src/client/types.js.map +0 -1
  61. package/dist/src/index.d.ts.map +0 -1
  62. package/dist/src/index.js.map +0 -1
@@ -9,8 +9,10 @@
9
9
  // const c = new MastrClient();
10
10
  // const page = await c.stromerzeugung({ pageSize: 10, filter: "Energieträger~eq~'2495'" });
11
11
  // page.total; // total solar units
12
- import { RequestEngine, sanitizeServerText } from "./engine.js";
13
- import { MastrApiError } from "./errors.js";
12
+ import { RequestEngine, describeMastrErrors } from "./engine.js";
13
+ import { MastrApiError, MastrParseError, MastrValidationError } from "./errors.js";
14
+ import { validateFilter } from "./filter.js";
15
+ import { assertValid, countQueryProblem, sortProblem } from "./validate.js";
14
16
  const SERVICE = "/Einheit/EinheitJson";
15
17
  /** Map a category to the PascalCase suffix used in the endpoint names. */
16
18
  const CATEGORY_SUFFIX = {
@@ -21,15 +23,37 @@ const CATEGORY_SUFFIX = {
21
23
  };
22
24
  const DEFAULT_PAGE = 1;
23
25
  const DEFAULT_PAGE_SIZE = 25;
26
+ /** Largest `page` the client (and the CLI's `--page`) accepts. */
27
+ export const MAX_PAGE = 1_000_000;
28
+ /** Largest `pageSize` the client (and the CLI's `--page-size`) accepts. */
29
+ export const MAX_PAGE_SIZE = 5000;
30
+ /** The category's endpoint suffix; an unknown category (from plain JS) throws. */
31
+ function categorySuffix(category) {
32
+ if (typeof category !== "string" || !Object.hasOwn(CATEGORY_SUFFIX, category)) {
33
+ throw new MastrValidationError(`Invalid category: expected one of ${Object.keys(CATEGORY_SUFFIX).join(", ")}, got ${JSON.stringify(category)}.`);
34
+ }
35
+ return CATEGORY_SUFFIX[category];
36
+ }
37
+ /** Check an optional integer paging option against 1..max. */
38
+ function checkPaging(name, value, max) {
39
+ if (value === undefined)
40
+ return;
41
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > max) {
42
+ throw new MastrValidationError(`Invalid ${name}: expected an integer from 1 to ${max}, got ${typeof value === "string" ? JSON.stringify(value) : String(value)}.`);
43
+ }
44
+ }
24
45
  /**
25
46
  * Parse a MaStR Microsoft-AJAX date string (`"/Date(1548979200000)/"`) into a Date.
26
- * Returns null if the string is not in that format.
47
+ * The offset form `"/Date(1548979200000+0100)/"` is accepted too; its milliseconds
48
+ * are UTC already, the offset only names the sender's zone. Returns null if the
49
+ * string is not in that format or the value is outside the Date range.
27
50
  */
28
51
  export function parseMsDate(value) {
29
- const m = /^\/Date\((-?\d+)\)\/$/.exec(value);
52
+ const m = /^\/Date\((-?\d+)(?:[+-]\d{4})?\)\/$/.exec(value);
30
53
  if (!m)
31
54
  return null;
32
- return new Date(Number(m[1]));
55
+ const date = new Date(Number(m[1]));
56
+ return Number.isNaN(date.getTime()) ? null : date;
33
57
  }
34
58
  /**
35
59
  * Recursively rewrite every `"/Date(ms)/"` string in a value to an ISO-8601 string,
@@ -37,10 +61,10 @@ export function parseMsDate(value) {
37
61
  */
38
62
  export function isoifyDates(value) {
39
63
  if (typeof value === "string") {
64
+ // parseMsDate returns null for an out-of-range value, so `.toISOString()` never
65
+ // throws; such a string is left as-is.
40
66
  const d = parseMsDate(value);
41
- // An out-of-range milliseconds value yields an Invalid Date (a truthy object);
42
- // guard against it so `.toISOString()` never throws — leave the string as-is.
43
- return (d && !Number.isNaN(d.getTime()) ? d.toISOString() : value);
67
+ return (d ? d.toISOString() : value);
44
68
  }
45
69
  if (Array.isArray(value)) {
46
70
  return value.map((v) => isoifyDates(v));
@@ -58,6 +82,13 @@ export function isoifyDates(value) {
58
82
  }
59
83
  return value;
60
84
  }
85
+ /** True for a JSON object (not null, not an array). */
86
+ function isObject(value) {
87
+ return typeof value === "object" && value !== null && !Array.isArray(value);
88
+ }
89
+ function shapeError(path, expected) {
90
+ return new MastrParseError(`Unexpected response shape from ${path}: expected ${expected}.`);
91
+ }
61
92
  export class MastrClient {
62
93
  engine;
63
94
  constructor(options = {}) {
@@ -67,8 +98,19 @@ export class MastrClient {
67
98
  * Fetch one page of units for a category. Sends the full Kendo param set —
68
99
  * `sort`, `page`, `pageSize`, `group`, `filter` — always, because the server
69
100
  * rejects a request with `group`/`filter` missing ("Die Anfrage ist Null.").
101
+ * An unknown category, a `page` outside 1..`MAX_PAGE`, a `pageSize` outside
102
+ * 1..`MAX_PAGE_SIZE`, a blank `sort` ({@link sortProblem}) or a filter the register
103
+ * would misread (e.g. `~or~`, see {@link validateFilter}) is rejected with a
104
+ * `MastrValidationError` before any request.
70
105
  */
71
106
  async units(category, query = {}) {
107
+ const suffix = categorySuffix(category);
108
+ checkPaging("page", query.page, MAX_PAGE);
109
+ checkPaging("pageSize", query.pageSize, MAX_PAGE_SIZE);
110
+ if (query.sort !== undefined)
111
+ assertValid("sort", query.sort, sortProblem);
112
+ if (query.filter !== undefined)
113
+ validateFilter(query.filter);
72
114
  const params = {
73
115
  sort: query.sort ?? "",
74
116
  page: query.page ?? DEFAULT_PAGE,
@@ -76,20 +118,50 @@ export class MastrClient {
76
118
  group: "",
77
119
  filter: query.filter ?? "",
78
120
  };
79
- const path = `${SERVICE}/GetErweiterteOeffentlicheEinheit${CATEGORY_SUFFIX[category]}`;
121
+ const path = `${SERVICE}/GetErweiterteOeffentlicheEinheit${suffix}`;
80
122
  const res = await this.engine.getJson(path, params);
81
- if (res && typeof res.Errors === "string" && res.Errors.length > 0) {
123
+ if (!isObject(res))
124
+ throw shapeError(path, "a JSON object with Data and Total");
125
+ // MaStR answers HTTP 200 with a logical error in `Errors`: a string such as "Die
126
+ // Anfrage ist Null.", or a Kendo ModelState object. Anything but null is an error
127
+ // (a broken reply must not read as "no matches"). The text is server-controlled
128
+ // and reaches stderr, so describeMastrErrors sanitises it.
129
+ if (res["Errors"] !== undefined && res["Errors"] !== null) {
82
130
  throw new MastrApiError({
83
131
  url: this.engine.buildUrl(path, params),
84
132
  method: "GET",
85
133
  body: JSON.stringify(res),
86
- // MaStR answers HTTP 200 with a logical error string; it is server-
87
- // controlled and reaches stderr, so strip control characters to prevent
88
- // terminal escape-sequence injection.
89
- detail: sanitizeServerText(res.Errors),
134
+ detail: describeMastrErrors(res["Errors"]),
90
135
  });
91
136
  }
92
- return { total: res?.Total ?? 0, data: (res?.Data ?? []) };
137
+ const total = res["Total"];
138
+ if (typeof total !== "number" || !Number.isSafeInteger(total) || total < 0) {
139
+ throw shapeError(path, "a numeric Total");
140
+ }
141
+ const data = res["Data"];
142
+ // A reply with no matches may carry `Data: null`; with a positive Total it must be an array.
143
+ if (data === null && total === 0)
144
+ return { total, data: [] };
145
+ if (!Array.isArray(data))
146
+ throw shapeError(path, "a Data array");
147
+ return { total, data: data };
148
+ }
149
+ /**
150
+ * The number of units in a category that match `filter` (all units without one).
151
+ * Fetches a single row (`page=1`, `pageSize=1`) and returns the envelope's
152
+ * `Total`, which counts every match regardless of the page. `sort` is forwarded:
153
+ * an unknown sort key makes the register answer 0. Paging options are refused
154
+ * (`countQueryProblem`), as are the inputs `units()` refuses, all with a
155
+ * `MastrValidationError` before any request.
156
+ */
157
+ async count(category, query = {}) {
158
+ assertValid("count query", query, countQueryProblem);
159
+ const q = { page: 1, pageSize: 1 };
160
+ if (query.sort !== undefined)
161
+ q.sort = query.sort;
162
+ if (query.filter !== undefined)
163
+ q.filter = query.filter;
164
+ return (await this.units(category, q)).total;
93
165
  }
94
166
  /** Electricity-generation units (`Stromerzeugung`). */
95
167
  stromerzeugung(query) {
@@ -107,11 +179,25 @@ export class MastrClient {
107
179
  gasverbrauch(query) {
108
180
  return this.units("gasverbrauch", query);
109
181
  }
110
- /** The filterable columns (names, types, dropdown codes) for a category. */
182
+ /**
183
+ * The filterable columns (names, types, dropdown codes) for a category. An `Errors`
184
+ * envelope throws `MastrApiError`, any other non-array reply `MastrParseError` —
185
+ * never an empty list, which would read as "this category has no filters".
186
+ */
111
187
  async filterColumns(category) {
112
- const path = `${SERVICE}/GetFilterColumnsErweiterteOeffentlicheEinheit${CATEGORY_SUFFIX[category]}`;
188
+ const path = `${SERVICE}/GetFilterColumnsErweiterteOeffentlicheEinheit${categorySuffix(category)}`;
113
189
  const res = await this.engine.getJson(path);
114
- return Array.isArray(res) ? res : [];
190
+ if (isObject(res) && res["Errors"] !== undefined && res["Errors"] !== null) {
191
+ throw new MastrApiError({
192
+ url: this.engine.buildUrl(path),
193
+ method: "GET",
194
+ body: JSON.stringify(res),
195
+ detail: describeMastrErrors(res["Errors"]),
196
+ });
197
+ }
198
+ if (!Array.isArray(res) || !res.every(isObject)) {
199
+ throw shapeError(path, "a JSON array of filter columns");
200
+ }
201
+ return res;
115
202
  }
116
203
  }
117
- //# sourceMappingURL=client.js.map
@@ -7,43 +7,118 @@ export interface RawResponse {
7
7
  status: number;
8
8
  }
9
9
  export interface EngineOptions {
10
- /** Base URL of the API. Defaults to the canonical marktstammdatenregister.de base. */
10
+ /**
11
+ * Base URL of the API. Defaults to the canonical marktstammdatenregister.de base.
12
+ * A value that breaks a rule of {@link validateBaseUrl} (blank, whitespace or
13
+ * control characters, not an absolute http(s) URL, a query or fragment) throws a
14
+ * MastrValidationError.
15
+ */
11
16
  baseUrl?: string;
12
17
  /** Swappable transport. Defaults to the built-in node http/https transport. */
13
18
  transport?: Transport;
14
- /** Value of the User-Agent header. */
19
+ /**
20
+ * Value of the User-Agent header: not blank, Latin-1 without control characters
21
+ * (tab is fine), else a MastrValidationError.
22
+ */
15
23
  userAgent?: string;
16
- /** Extra headers sent on every request. */
24
+ /** Extra headers sent on every request; names must be tokens, values follow the `userAgent` rule. */
17
25
  defaultHeaders?: Record<string, string>;
18
26
  /**
19
27
  * Time limit per request in milliseconds, covering the whole response body, not
20
- * only idle gaps (0 disables; capped at MAX_TIMEOUT_MS, 2^31 - 1 ms).
28
+ * only idle gaps: an integer 0..`MAX_TIMEOUT_MS` (2^31 - 1 ms); 0 disables.
29
+ * Defaults to 30000.
21
30
  */
22
31
  timeoutMs?: number;
23
- /** Number of automatic retries for transient (429/503) responses. */
32
+ /**
33
+ * Number of automatic retries for transient (429/503) responses, an integer
34
+ * 0..`MAX_RETRIES` (10); defaults to 2. Each waits the response's `Retry-After`
35
+ * (up to `MAX_RETRY_AFTER_MS`; a longer one is not retried), or else
36
+ * `retryDelayMs * attempt`.
37
+ */
24
38
  maxRetries?: number;
25
- /** Base backoff between retries in milliseconds (grows linearly). */
39
+ /**
40
+ * Base backoff between retries in milliseconds (grows linearly), a non-negative
41
+ * integer; used without a Retry-After. Defaults to 200.
42
+ */
26
43
  retryDelayMs?: number;
27
44
  /**
28
45
  * Hard cap on response body size in bytes (defends against memory exhaustion
29
- * from a hostile/buggy endpoint). Defaults to 100 MiB; set to 0 for no limit.
46
+ * from a hostile/buggy endpoint), a non-negative integer. Defaults to 100 MiB;
47
+ * set to 0 for no limit.
30
48
  */
31
49
  maxResponseBytes?: number;
32
50
  /** Injectable sleep, primarily for deterministic tests. */
33
51
  sleep?: (ms: number) => Promise<void>;
34
52
  }
53
+ /** Most retries `maxRetries` may ask for (each may wait up to `MAX_RETRY_AFTER_MS`). */
54
+ export declare const MAX_RETRIES = 10;
55
+ /**
56
+ * Longest `Retry-After` the engine waits out before retrying a 429/503. When the
57
+ * server asks for longer, the engine does not retry at all and surfaces the error at
58
+ * once: retrying early would only land inside the window the server asked us to wait
59
+ * out, and a hostile value must not stall the CLI.
60
+ */
61
+ export declare const MAX_RETRY_AFTER_MS = 30000;
62
+ /**
63
+ * Parse a `Retry-After` header into a delay in milliseconds (RFC 9110 §10.2.3):
64
+ * either delay-seconds (`"120"`) or an HTTP-date (`"Wed, 21 Oct 2026 07:28:00 GMT"`,
65
+ * turned into the time left from `now`; a date in the past gives 0).
66
+ *
67
+ * Returns `undefined` when the header is absent or malformed — negative (`"-1"`),
68
+ * fractional (`"1.5"`), padded inside, any other date format — so the caller falls
69
+ * back to its own backoff. The strict patterns matter: `Date.parse` alone would
70
+ * read `"1.5"` as a date in 2001 and retry at once.
71
+ */
72
+ export declare function parseRetryAfter(header: string | string[] | undefined, now?: number): number | undefined;
73
+ /**
74
+ * True for the Unicode bidirectional formatting characters: ALM (U+061C), LRM/RLM
75
+ * (U+200E/U+200F), the embeddings and overrides U+202A–U+202E and the isolates
76
+ * U+2066–U+2069. A terminal applies them to the text that follows, so an override
77
+ * in server text can reorder what the user sees ("Trojan Source" spoofing).
78
+ */
79
+ export declare function isBidiControl(code: number): boolean;
35
80
  /**
36
- * Strip control characters (C0 except tab/newline, DEL, and C1) out of a string
37
- * that originates in an attacker-controlled response — the error `detail` and the
38
- * echoed Content-Type. `JSON.parse` decodes an escaped ESC in an error body into a
39
- * real ESC byte, so without this a hostile/MITM'd endpoint could drive ANSI/OSC
40
- * escape sequences into the user's terminal when the message is printed to stderr.
41
- * This only needs to cover text that flows into an error message: the CLI's JSON
42
- * output is escaped separately (`escapeControlChars` in cli/shared.ts), as
43
- * `JSON.stringify` alone leaves DEL and the C1 range raw. Implemented as a code-point
44
- * filter so no raw control byte ever appears in this source file.
81
+ * Make a string that originates in an attacker-controlled response — the error
82
+ * `detail` (a JSON field, an `Errors` value or a text snippet) and the echoed
83
+ * Content-Type — safe to print into an error message on stderr:
84
+ *
85
+ * - C0 and C1 controls and DEL are dropped. `JSON.parse` decodes an escaped ESC into
86
+ * a real ESC byte; printed raw, a hostile or MITM'd endpoint could drive ANSI/OSC
87
+ * sequences into the terminal.
88
+ * - Bidi formatting characters (isBidiControl) are dropped, so server text cannot
89
+ * reorder the visible message.
90
+ * - Every run of whitespace — newlines, tabs, U+2028/U+2029 included — becomes one
91
+ * space and the ends are trimmed, so the text stays on one line and a server
92
+ * cannot forge an `Error:` line of its own.
93
+ *
94
+ * The CLI's JSON output is escaped separately (`escapeControlChars` in
95
+ * cli/shared.ts): `JSON.stringify` alone leaves DEL, C1 and bidi characters raw.
96
+ * Written as a code-point filter so no raw control byte appears in this source.
45
97
  */
46
98
  export declare function sanitizeServerText(text: string): string;
99
+ /**
100
+ * Describe a Kendo `Errors` value for an error message: a string as is; otherwise
101
+ * (a ModelState object such as `{"": {"errors": ["Invalid filter"]}}`, or an array)
102
+ * every string found in it, sanitised, blanks and repeats dropped, joined "; ".
103
+ * Returns `undefined` when nothing readable is left.
104
+ */
105
+ export declare function describeMastrErrors(errors: unknown): string | undefined;
106
+ /**
107
+ * Check a base URL against every rule of {@link baseUrlProblem} — blank, whitespace
108
+ * or control characters, not an absolute URL, a scheme other than `http:`/`https:`,
109
+ * a query or fragment — and return it with trailing slashes stripped. A bad value
110
+ * throws a MastrValidationError (`Invalid baseUrl: <reason>`): it is a configuration
111
+ * error, not a transport failure. The default transport still gates the scheme per
112
+ * request, but the engine may be handed a custom transport that does no such check,
113
+ * so the configured value is checked here, on the raw string.
114
+ */
115
+ export declare function validateBaseUrl(raw: string): string;
116
+ /**
117
+ * Check a value bound for an HTTP header (`headerValueProblem`) and return it, or
118
+ * throw a MastrValidationError (`Invalid <name>: <reason>`). The engine runs it on
119
+ * `userAgent` and every `defaultHeaders` value before any request.
120
+ */
121
+ export declare function assertHeaderValue(name: string, value: string): string;
47
122
  export declare class RequestEngine {
48
123
  private readonly baseUrl;
49
124
  private readonly transport;
@@ -63,8 +138,11 @@ export declare class RequestEngine {
63
138
  * base URL bouncing to a portal page) surfaces as an error.
64
139
  */
65
140
  request(path: string, query?: QueryParams, accept?: string): Promise<RawResponse>;
66
- /** GET a path with query params and parse the JSON reply into `T`. */
141
+ /**
142
+ * GET a path with query params and parse the JSON reply into `T`. Every MaStR
143
+ * endpoint answers with a JSON document, so an empty body or a 204 is a
144
+ * `MastrParseError`, never a silent `null`.
145
+ */
67
146
  getJson<T>(path: string, query?: QueryParams): Promise<T>;
68
147
  private toApiError;
69
148
  }
70
- //# sourceMappingURL=engine.d.ts.map
@@ -2,50 +2,144 @@
2
2
  // a Transport, applies retry/backoff for transient statuses (429, 503), and decodes
3
3
  // JSON responses. MaStR's public search backend is an unauthenticated GET API whose
4
4
  // parameters travel in the query string.
5
- import { nodeHttpTransport } from "./http.js";
5
+ import { MAX_TIMEOUT_MS, nodeHttpTransport } from "./http.js";
6
6
  import { buildQueryString } from "./query.js";
7
- import { MastrApiError, MastrNetworkError, MastrParseError } from "./errors.js";
7
+ import { MastrApiError, MastrParseError } from "./errors.js";
8
+ import { assertValid, baseUrlProblem, headerNameProblem, headerValueProblem, intRangeProblem, } from "./validate.js";
8
9
  export const DEFAULT_BASE_URL = "https://www.marktstammdatenregister.de/MaStR";
9
10
  const DEFAULT_USER_AGENT = "marktstammdatenregister-cli";
10
11
  const DEFAULT_MAX_RESPONSE_BYTES = 100 * 1024 * 1024;
12
+ /** Most retries `maxRetries` may ask for (each may wait up to `MAX_RETRY_AFTER_MS`). */
13
+ export const MAX_RETRIES = 10;
11
14
  /**
12
- * Strip control characters (C0 except tab/newline, DEL, and C1) out of a string
13
- * that originates in an attacker-controlled response — the error `detail` and the
14
- * echoed Content-Type. `JSON.parse` decodes an escaped ESC in an error body into a
15
- * real ESC byte, so without this a hostile/MITM'd endpoint could drive ANSI/OSC
16
- * escape sequences into the user's terminal when the message is printed to stderr.
17
- * This only needs to cover text that flows into an error message: the CLI's JSON
18
- * output is escaped separately (`escapeControlChars` in cli/shared.ts), as
19
- * `JSON.stringify` alone leaves DEL and the C1 range raw. Implemented as a code-point
20
- * filter so no raw control byte ever appears in this source file.
15
+ * A numeric engine option: `fallback` when undefined, else an integer in 0..max,
16
+ * or a MastrValidationError (`Invalid <name>: expected an integer …`).
17
+ */
18
+ function intOption(name, value, max, fallback) {
19
+ return value === undefined ? fallback : assertValid(name, value, intRangeProblem(0, max));
20
+ }
21
+ /**
22
+ * Longest `Retry-After` the engine waits out before retrying a 429/503. When the
23
+ * server asks for longer, the engine does not retry at all and surfaces the error at
24
+ * once: retrying early would only land inside the window the server asked us to wait
25
+ * out, and a hostile value must not stall the CLI.
26
+ */
27
+ export const MAX_RETRY_AFTER_MS = 30_000;
28
+ /** An IMF-fixdate (RFC 9110 §5.6.7), the one HTTP-date form senders must generate. */
29
+ const IMF_FIXDATE = /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} GMT$/;
30
+ /**
31
+ * Parse a `Retry-After` header into a delay in milliseconds (RFC 9110 §10.2.3):
32
+ * either delay-seconds (`"120"`) or an HTTP-date (`"Wed, 21 Oct 2026 07:28:00 GMT"`,
33
+ * turned into the time left from `now`; a date in the past gives 0).
34
+ *
35
+ * Returns `undefined` when the header is absent or malformed — negative (`"-1"`),
36
+ * fractional (`"1.5"`), padded inside, any other date format — so the caller falls
37
+ * back to its own backoff. The strict patterns matter: `Date.parse` alone would
38
+ * read `"1.5"` as a date in 2001 and retry at once.
39
+ */
40
+ export function parseRetryAfter(header, now = Date.now()) {
41
+ const value = (Array.isArray(header) ? header[0] : header)?.trim();
42
+ if (value === undefined || value === "")
43
+ return undefined;
44
+ if (/^\d+$/.test(value))
45
+ return Number(value) * 1000;
46
+ if (!IMF_FIXDATE.test(value))
47
+ return undefined;
48
+ const when = Date.parse(value);
49
+ return Number.isNaN(when) ? undefined : Math.max(0, when - now);
50
+ }
51
+ /**
52
+ * True for the Unicode bidirectional formatting characters: ALM (U+061C), LRM/RLM
53
+ * (U+200E/U+200F), the embeddings and overrides U+202A–U+202E and the isolates
54
+ * U+2066–U+2069. A terminal applies them to the text that follows, so an override
55
+ * in server text can reorder what the user sees ("Trojan Source" spoofing).
56
+ */
57
+ export function isBidiControl(code) {
58
+ return (code === 0x061c ||
59
+ code === 0x200e ||
60
+ code === 0x200f ||
61
+ (code >= 0x202a && code <= 0x202e) ||
62
+ (code >= 0x2066 && code <= 0x2069));
63
+ }
64
+ /**
65
+ * Make a string that originates in an attacker-controlled response — the error
66
+ * `detail` (a JSON field, an `Errors` value or a text snippet) and the echoed
67
+ * Content-Type — safe to print into an error message on stderr:
68
+ *
69
+ * - C0 and C1 controls and DEL are dropped. `JSON.parse` decodes an escaped ESC into
70
+ * a real ESC byte; printed raw, a hostile or MITM'd endpoint could drive ANSI/OSC
71
+ * sequences into the terminal.
72
+ * - Bidi formatting characters (isBidiControl) are dropped, so server text cannot
73
+ * reorder the visible message.
74
+ * - Every run of whitespace — newlines, tabs, U+2028/U+2029 included — becomes one
75
+ * space and the ends are trimmed, so the text stays on one line and a server
76
+ * cannot forge an `Error:` line of its own.
77
+ *
78
+ * The CLI's JSON output is escaped separately (`escapeControlChars` in
79
+ * cli/shared.ts): `JSON.stringify` alone leaves DEL, C1 and bidi characters raw.
80
+ * Written as a code-point filter so no raw control byte appears in this source.
21
81
  */
22
82
  export function sanitizeServerText(text) {
23
83
  let out = "";
24
84
  for (const ch of text) {
25
85
  const n = ch.codePointAt(0) ?? 0;
26
- if (n <= 8 || (n >= 0x0b && n <= 0x1f) || (n >= 0x7f && n <= 0x9f))
86
+ const whitespaceControl = n >= 0x09 && n <= 0x0d;
87
+ if (!whitespaceControl && (n <= 0x1f || (n >= 0x7f && n <= 0x9f) || isBidiControl(n)))
27
88
  continue;
28
89
  out += ch;
29
90
  }
30
- return out;
91
+ return out.replace(/\s+/g, " ").trim();
31
92
  }
32
93
  /**
33
- * Reject a base URL whose scheme is not http(s). The default transport already
34
- * gates this per hop, but the engine is exported as a library and may be handed a
35
- * custom transport that does no such check, so gate the configured base URL here
36
- * too (a `file:`/`ftp:` base URL fails fast with a typed error).
94
+ * Describe a Kendo `Errors` value for an error message: a string as is; otherwise
95
+ * (a ModelState object such as `{"": {"errors": ["Invalid filter"]}}`, or an array)
96
+ * every string found in it, sanitised, blanks and repeats dropped, joined "; ".
97
+ * Returns `undefined` when nothing readable is left.
37
98
  */
38
- function assertHttpScheme(baseUrl) {
39
- let url;
40
- try {
41
- url = new URL(baseUrl);
42
- }
43
- catch {
44
- throw new MastrNetworkError(`Invalid base URL: ${baseUrl}`);
45
- }
46
- if (url.protocol !== "http:" && url.protocol !== "https:") {
47
- throw new MastrNetworkError(`Unsupported protocol "${url.protocol}" in base URL: ${baseUrl}`);
99
+ export function describeMastrErrors(errors) {
100
+ const found = [];
101
+ const walk = (value, depth) => {
102
+ if (typeof value === "string") {
103
+ const text = sanitizeServerText(value);
104
+ if (text !== "" && !found.includes(text))
105
+ found.push(text);
106
+ }
107
+ else if (value !== null && typeof value === "object" && depth < 5) {
108
+ for (const v of Object.values(value))
109
+ walk(v, depth + 1);
110
+ }
111
+ };
112
+ walk(errors, 0);
113
+ return found.length > 0 ? found.join("; ") : undefined;
114
+ }
115
+ /**
116
+ * Check a base URL against every rule of {@link baseUrlProblem} — blank, whitespace
117
+ * or control characters, not an absolute URL, a scheme other than `http:`/`https:`,
118
+ * a query or fragment — and return it with trailing slashes stripped. A bad value
119
+ * throws a MastrValidationError (`Invalid baseUrl: <reason>`): it is a configuration
120
+ * error, not a transport failure. The default transport still gates the scheme per
121
+ * request, but the engine may be handed a custom transport that does no such check,
122
+ * so the configured value is checked here, on the raw string.
123
+ */
124
+ export function validateBaseUrl(raw) {
125
+ return assertValid("baseUrl", raw, baseUrlProblem).replace(/\/+$/, "");
126
+ }
127
+ /**
128
+ * Check a value bound for an HTTP header (`headerValueProblem`) and return it, or
129
+ * throw a MastrValidationError (`Invalid <name>: <reason>`). The engine runs it on
130
+ * `userAgent` and every `defaultHeaders` value before any request.
131
+ */
132
+ export function assertHeaderValue(name, value) {
133
+ return assertValid(name, value, headerValueProblem);
134
+ }
135
+ /** Check every `defaultHeaders` name (a token) and value; returns a copy. */
136
+ function checkedHeaders(headers) {
137
+ const out = {};
138
+ for (const [name, value] of Object.entries(headers)) {
139
+ assertValid("defaultHeaders name", name, headerNameProblem);
140
+ out[name] = assertHeaderValue(`defaultHeaders["${name}"]`, value);
48
141
  }
142
+ return out;
49
143
  }
50
144
  const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
51
145
  export class RequestEngine {
@@ -59,15 +153,24 @@ export class RequestEngine {
59
153
  maxResponseBytes;
60
154
  sleep;
61
155
  constructor(options = {}) {
62
- this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
63
- assertHttpScheme(this.baseUrl);
156
+ // The raw value is checked before the trailing-slash strip, so "https://h/ "
157
+ // cannot slip past it; only an omitted baseUrl selects the default.
158
+ this.baseUrl = validateBaseUrl(options.baseUrl === undefined ? DEFAULT_BASE_URL : options.baseUrl);
64
159
  this.transport = options.transport ?? nodeHttpTransport;
65
- this.userAgent = options.userAgent ?? DEFAULT_USER_AGENT;
66
- this.defaultHeaders = options.defaultHeaders ?? {};
67
- this.timeoutMs = options.timeoutMs ?? 30_000;
68
- this.maxRetries = options.maxRetries ?? 2;
69
- this.retryDelayMs = options.retryDelayMs ?? 200;
70
- this.maxResponseBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
160
+ // Header values are checked up front: a blank one would be sent as is, and a
161
+ // CR/LF or a character above U+00FF would reach a custom transport raw or make
162
+ // Node's HTTP layer throw an untyped ERR_INVALID_CHAR. Only an omitted
163
+ // userAgent selects the default.
164
+ this.userAgent =
165
+ options.userAgent === undefined ? DEFAULT_USER_AGENT : assertHeaderValue("userAgent", options.userAgent);
166
+ this.defaultHeaders = checkedHeaders(options.defaultHeaders ?? {});
167
+ // Range-check the numeric options: a negative, NaN or fractional value would
168
+ // otherwise silently disable the timeout or the size cap, and an unbounded
169
+ // maxRetries would keep retrying against the production register.
170
+ this.timeoutMs = intOption("timeoutMs", options.timeoutMs, MAX_TIMEOUT_MS, 30_000);
171
+ this.maxRetries = intOption("maxRetries", options.maxRetries, MAX_RETRIES, 2);
172
+ this.retryDelayMs = intOption("retryDelayMs", options.retryDelayMs, Number.MAX_SAFE_INTEGER, 200);
173
+ this.maxResponseBytes = intOption("maxResponseBytes", options.maxResponseBytes, Number.MAX_SAFE_INTEGER, DEFAULT_MAX_RESPONSE_BYTES);
71
174
  this.sleep = options.sleep ?? realSleep;
72
175
  }
73
176
  /** Build a fully-qualified URL from a path and optional query parameters. */
@@ -103,9 +206,14 @@ export class RequestEngine {
103
206
  const status = response.status;
104
207
  const retryable = status === 429 || status === 503;
105
208
  if (retryable && attempt < this.maxRetries) {
106
- attempt += 1;
107
- await this.sleep(this.retryDelayMs * attempt);
108
- continue;
209
+ // Honour Retry-After; without a usable one, back off linearly. A Retry-After
210
+ // beyond MAX_RETRY_AFTER_MS is not retried: the error below surfaces at once.
211
+ const retryAfter = parseRetryAfter(response.headers["retry-after"]);
212
+ if (retryAfter === undefined || retryAfter <= MAX_RETRY_AFTER_MS) {
213
+ attempt += 1;
214
+ await this.sleep(retryAfter ?? this.retryDelayMs * attempt);
215
+ continue;
216
+ }
109
217
  }
110
218
  const contentType = String(response.headers["content-type"] ?? "");
111
219
  if (status < 200 || status >= 300) {
@@ -114,12 +222,16 @@ export class RequestEngine {
114
222
  return { data: response.body, contentType, status };
115
223
  }
116
224
  }
117
- /** GET a path with query params and parse the JSON reply into `T`. */
225
+ /**
226
+ * GET a path with query params and parse the JSON reply into `T`. Every MaStR
227
+ * endpoint answers with a JSON document, so an empty body or a 204 is a
228
+ * `MastrParseError`, never a silent `null`.
229
+ */
118
230
  async getJson(path, query) {
119
231
  const res = await this.request(path, query);
120
232
  const text = res.data.toString("utf8");
121
233
  if (res.status === 204 || text.trim().length === 0) {
122
- return null;
234
+ throw new MastrParseError(`Empty response body from ${path}`);
123
235
  }
124
236
  try {
125
237
  return JSON.parse(text);
@@ -133,8 +245,8 @@ export class RequestEngine {
133
245
  let detail;
134
246
  try {
135
247
  const parsed = JSON.parse(text);
136
- if (typeof parsed?.Errors === "string")
137
- detail = parsed.Errors;
248
+ if (parsed?.Errors !== undefined && parsed.Errors !== null)
249
+ detail = describeMastrErrors(parsed.Errors);
138
250
  else if (typeof parsed?.message === "string")
139
251
  detail = parsed.message;
140
252
  else if (typeof parsed?.detail === "string")
@@ -156,4 +268,3 @@ export class RequestEngine {
156
268
  return new MastrApiError({ status, url, method: "GET", body: text, detail });
157
269
  }
158
270
  }
159
- //# sourceMappingURL=engine.js.map
@@ -1,3 +1,9 @@
1
+ /**
2
+ * Replace the userinfo of a URL (`https://user:secret@host/...`) with `***`, so a
3
+ * credential in a base URL never reaches an error message, a log or CI output.
4
+ * A URL without userinfo, or one that does not parse, is returned unchanged.
5
+ */
6
+ export declare function redactUrl(url: string): string;
1
7
  /** Base class for every error originating from this client. */
2
8
  export declare class MastrError extends Error {
3
9
  constructor(message: string, options?: {
@@ -33,10 +39,14 @@ export declare class MastrApiError extends MastrError {
33
39
  /** A transport-level failure (DNS, connection reset, timeout, ...). */
34
40
  export declare class MastrNetworkError extends MastrError {
35
41
  }
36
- /** A client-side validation error (e.g. a bad category) — no request made. */
42
+ /**
43
+ * A client-side validation error — an unknown category, a page or pageSize out of
44
+ * range, a filter the register would misread — thrown before any request, with the
45
+ * message `Invalid <name>: <reason>` (see `assertValid`). The CLI maps it to the
46
+ * usage exit code 2.
47
+ */
37
48
  export declare class MastrValidationError extends MastrError {
38
49
  }
39
50
  /** The response body could not be parsed as the expected JSON shape. */
40
51
  export declare class MastrParseError extends MastrError {
41
52
  }
42
- //# sourceMappingURL=errors.d.ts.map