@maschinenlesbar.org/marktstammdatenregister-cli 0.0.8 → 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.
Files changed (64) hide show
  1. package/README.md +19 -8
  2. package/dist/src/cli/commands/units.d.ts +0 -1
  3. package/dist/src/cli/commands/units.js +30 -24
  4. package/dist/src/cli/index.d.ts +0 -1
  5. package/dist/src/cli/index.js +3 -1
  6. package/dist/src/cli/io.d.ts +18 -1
  7. package/dist/src/cli/io.js +26 -1
  8. package/dist/src/cli/program.d.ts +0 -1
  9. package/dist/src/cli/program.js +7 -7
  10. package/dist/src/cli/run.d.ts +10 -1
  11. package/dist/src/cli/run.js +27 -2
  12. package/dist/src/cli/shared.d.ts +33 -18
  13. package/dist/src/cli/shared.js +49 -58
  14. package/dist/src/client/client.d.ts +46 -7
  15. package/dist/src/client/client.js +143 -14
  16. package/dist/src/client/engine.d.ts +95 -14
  17. package/dist/src/client/engine.js +382 -56
  18. package/dist/src/client/errors.d.ts +18 -2
  19. package/dist/src/client/errors.js +54 -4
  20. package/dist/src/client/filter.d.ts +47 -3
  21. package/dist/src/client/filter.js +184 -5
  22. package/dist/src/client/http.d.ts +13 -1
  23. package/dist/src/client/http.js +58 -33
  24. package/dist/src/client/index.d.ts +6 -5
  25. package/dist/src/client/index.js +5 -5
  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 +4 -2
  29. package/dist/src/client/types.js +0 -1
  30. package/dist/src/client/validate.d.ts +65 -0
  31. package/dist/src/client/validate.js +143 -0
  32. package/dist/src/index.d.ts +0 -1
  33. package/dist/src/index.js +0 -1
  34. package/package.json +4 -3
  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/filter.d.ts.map +0 -1
  54. package/dist/src/client/filter.js.map +0 -1
  55. package/dist/src/client/http.d.ts.map +0 -1
  56. package/dist/src/client/http.js.map +0 -1
  57. package/dist/src/client/index.d.ts.map +0 -1
  58. package/dist/src/client/index.js.map +0 -1
  59. package/dist/src/client/query.d.ts.map +0 -1
  60. package/dist/src/client/query.js.map +0 -1
  61. package/dist/src/client/types.d.ts.map +0 -1
  62. package/dist/src/client/types.js.map +0 -1
  63. package/dist/src/index.d.ts.map +0 -1
  64. package/dist/src/index.js.map +0 -1
@@ -2,12 +2,23 @@
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 { TextDecoder } from "node:util";
6
+ import { MAX_TIMEOUT_MS, nodeHttpTransport, sizeLimitMessage, } from "./http.js";
6
7
  import { buildQueryString } from "./query.js";
7
- import { MastrApiError, MastrNetworkError, MastrParseError, redactUrl } from "./errors.js";
8
+ import { MastrApiError, MastrError, MastrNetworkError, MastrParseError, MastrValidationError, credentialsIn, redactCredentials, redactUrl, } from "./errors.js";
9
+ import { assertValid, baseUrlProblem, headerNameProblem, headerValueProblem, intRangeProblem, } from "./validate.js";
8
10
  export const DEFAULT_BASE_URL = "https://www.marktstammdatenregister.de/MaStR";
9
11
  const DEFAULT_USER_AGENT = "marktstammdatenregister-cli";
10
12
  const DEFAULT_MAX_RESPONSE_BYTES = 100 * 1024 * 1024;
13
+ /** Most retries `maxRetries` may ask for (each may wait up to `MAX_RETRY_AFTER_MS`). */
14
+ export const MAX_RETRIES = 10;
15
+ /**
16
+ * A numeric engine option: `fallback` when undefined, else an integer in 0..max,
17
+ * or a MastrValidationError (`Invalid <name>: expected an integer …`).
18
+ */
19
+ function intOption(name, value, max, fallback) {
20
+ return value === undefined ? fallback : assertValid(name, value, intRangeProblem(0, max));
21
+ }
11
22
  /**
12
23
  * Longest `Retry-After` the engine waits out before retrying a 429/503. When the
13
24
  * server asks for longer, the engine does not retry at all and surfaces the error at
@@ -80,17 +91,28 @@ export function sanitizeServerText(text) {
80
91
  }
81
92
  return out.replace(/\s+/g, " ").trim();
82
93
  }
94
+ /**
95
+ * Longest server text (in characters) an error message keeps: a hostile or buggy body
96
+ * must not flood stderr or a CI log with one huge line. `MastrApiError.body` keeps the
97
+ * full text.
98
+ */
99
+ export const MAX_DETAIL_LENGTH = 500;
100
+ /** `text` cut at MAX_DETAIL_LENGTH characters, ending in "…" when cut. */
101
+ function cutDetail(text) {
102
+ return text.length > MAX_DETAIL_LENGTH ? `${text.slice(0, MAX_DETAIL_LENGTH)}…` : text;
103
+ }
83
104
  /**
84
105
  * Describe a Kendo `Errors` value for an error message: a string as is; otherwise
85
106
  * (a ModelState object such as `{"": {"errors": ["Invalid filter"]}}`, or an array)
86
107
  * every string found in it, sanitised, blanks and repeats dropped, joined "; ".
87
- * Returns `undefined` when nothing readable is left.
108
+ * Returns `undefined` when nothing readable is left. `clean` runs on each raw string
109
+ * first (the engine passes its credential scrubber).
88
110
  */
89
- export function describeMastrErrors(errors) {
111
+ export function describeMastrErrors(errors, clean = (text) => text) {
90
112
  const found = [];
91
113
  const walk = (value, depth) => {
92
114
  if (typeof value === "string") {
93
- const text = sanitizeServerText(value);
115
+ const text = sanitizeServerText(clean(value));
94
116
  if (text !== "" && !found.includes(text))
95
117
  found.push(text);
96
118
  }
@@ -100,70 +122,305 @@ export function describeMastrErrors(errors) {
100
122
  }
101
123
  };
102
124
  walk(errors, 0);
103
- return found.length > 0 ? found.join("; ") : undefined;
125
+ return found.length > 0 ? cutDetail(found.join("; ")) : undefined;
104
126
  }
105
127
  /**
106
- * Reject a base URL whose scheme is not http(s), or that has a query or fragment.
107
- * The default transport already gates the scheme per hop, but the engine is
108
- * exported as a library and may be handed a custom transport that does no such
109
- * check, so gate the configured base URL here too (a `file:`/`ftp:` base URL fails
110
- * fast with a typed error). Request paths are appended to the base URL as a string,
111
- * so a `?` or `#` in it would swallow every path: `http://h/?x=1` requests
112
- * `/?x=1/Einheit/...` and `http://h/#f` requests `/`.
128
+ * Check a base URL against every rule of {@link baseUrlProblem} — blank, whitespace
129
+ * or control characters, not an absolute URL, a scheme other than `http:`/`https:`,
130
+ * a query or fragment — and return it with trailing slashes stripped. A bad value
131
+ * throws a MastrValidationError (`Invalid baseUrl: <reason>`): it is a configuration
132
+ * error, not a transport failure. The default transport still gates the scheme per
133
+ * request, but the engine may be handed a custom transport that does no such check,
134
+ * so the configured value is checked here, on the raw string.
113
135
  */
114
- function assertHttpScheme(baseUrl) {
115
- let url;
116
- try {
117
- url = new URL(baseUrl);
136
+ export function validateBaseUrl(raw) {
137
+ return assertValid("baseUrl", raw, baseUrlProblem).replace(/\/+$/, "");
138
+ }
139
+ /**
140
+ * Check a value bound for an HTTP header (`headerValueProblem`) and return it, or
141
+ * throw a MastrValidationError (`Invalid <name>: <reason>`). The engine runs it on
142
+ * `userAgent` and every `defaultHeaders` value before any request.
143
+ */
144
+ export function assertHeaderValue(name, value) {
145
+ return assertValid(name, value, headerValueProblem);
146
+ }
147
+ /** Check every `defaultHeaders` name (a token) and value; returns a copy. */
148
+ function checkedHeaders(headers) {
149
+ if (typeof headers !== "object" || headers === null || Array.isArray(headers)) {
150
+ throw new MastrValidationError(`Invalid defaultHeaders: expected an object of header names and values, got ${describeType(headers)}.`);
118
151
  }
119
- catch {
120
- throw new MastrNetworkError(`Invalid base URL: ${redactUrl(baseUrl)}`);
152
+ const out = {};
153
+ for (const [name, value] of Object.entries(headers)) {
154
+ assertValid("defaultHeaders name", name, headerNameProblem);
155
+ out[name] = assertHeaderValue(`defaultHeaders["${name}"]`, value);
156
+ }
157
+ return out;
158
+ }
159
+ /** Why `value` is not a usable HttpResponse, or undefined when it is. */
160
+ function responseProblem(value) {
161
+ if (typeof value !== "object" || value === null)
162
+ return "not an object";
163
+ const r = value;
164
+ if (typeof r.status !== "number" || !Number.isInteger(r.status) || r.status < 100 || r.status > 599) {
165
+ return "status is not an HTTP status code";
166
+ }
167
+ if (typeof r.headers !== "object" || r.headers === null || Array.isArray(r.headers))
168
+ return "headers is not an object";
169
+ if (bodyBytes(r.body) === undefined)
170
+ return "body is not a Buffer, Uint8Array, other ArrayBuffer view or ArrayBuffer";
171
+ return undefined;
172
+ }
173
+ /**
174
+ * The response body as a Buffer (a view, no copy): a Buffer, any ArrayBuffer view (a
175
+ * Uint8Array from fetch, a DataView) or an ArrayBuffer/SharedArrayBuffer — checked by internal
176
+ * slot, not `instanceof`, so a value from another realm (a vm context, a Jest test) counts.
177
+ * Undefined for anything else.
178
+ */
179
+ function bodyBytes(value) {
180
+ if (Buffer.isBuffer(value))
181
+ return value;
182
+ if (ArrayBuffer.isView(value))
183
+ return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
184
+ const tag = Object.prototype.toString.call(value);
185
+ if (tag === "[object ArrayBuffer]" || tag === "[object SharedArrayBuffer]")
186
+ return Buffer.from(value);
187
+ return undefined;
188
+ }
189
+ /**
190
+ * The response headers as a plain record with lower-case names. A transport built on
191
+ * `fetch` returns its `Headers` object, which has no plain properties (the engine then saw
192
+ * no Retry-After and no Content-Type at all); such an object, or a `Map` (anything with
193
+ * `get` and `forEach`), is copied into a record. A custom transport may also not
194
+ * lower-case the names ("Retry-After").
195
+ */
196
+ function plainHeaders(headers) {
197
+ const h = headers;
198
+ const record = {};
199
+ if (typeof h.get === "function" && typeof h.forEach === "function") {
200
+ h.forEach.call(headers, (value, name) => {
201
+ record[String(name).toLowerCase()] = value;
202
+ });
203
+ return record;
204
+ }
205
+ for (const [name, value] of Object.entries(headers)) {
206
+ record[name.toLowerCase()] = value;
207
+ }
208
+ return record;
209
+ }
210
+ /**
211
+ * Error codes of a connection that broke off mid-request: Node's (`socket hang up` is
212
+ * ECONNRESET) and undici's (`fetch failed` with cause UND_ERR_SOCKET, "other side closed").
213
+ */
214
+ const TRANSIENT_NETWORK_CODES = new Set(["ECONNRESET", "EPIPE", "ECONNABORTED", "UND_ERR_SOCKET"]);
215
+ /** True when `err` or an error in its `cause` chain has a transient connection code. */
216
+ function hasTransientCode(err, depth = 0) {
217
+ if (typeof err !== "object" || err === null || depth > 4)
218
+ return false;
219
+ const code = err.code;
220
+ if (typeof code === "string" && TRANSIENT_NETWORK_CODES.has(code))
221
+ return true;
222
+ return hasTransientCode(err.cause, depth + 1);
223
+ }
224
+ /**
225
+ * True for a failure caused by a reset or aborted connection, which the engine retries —
226
+ * whichever transport raised it (a Node error, fetch's TypeError with an undici cause), the
227
+ * code anywhere in the `cause` chain. A refused connection, a DNS failure or a timeout is
228
+ * not transient in that sense and is not retried.
229
+ */
230
+ export function isTransientNetworkError(err) {
231
+ return hasTransientCode(err);
232
+ }
233
+ /** A value's type for a validation message: "null", "an array", "a string", … */
234
+ function describeType(value) {
235
+ if (value === null)
236
+ return "null";
237
+ if (Array.isArray(value))
238
+ return "an array";
239
+ return `a ${typeof value}`;
240
+ }
241
+ /** Every EngineOptions key. */
242
+ const OPTION_NAMES = [
243
+ "baseUrl",
244
+ "transport",
245
+ "userAgent",
246
+ "defaultHeaders",
247
+ "timeoutMs",
248
+ "maxRetries",
249
+ "retryDelayMs",
250
+ "maxResponseBytes",
251
+ "sleep",
252
+ ];
253
+ /**
254
+ * Throw for a key that is not an option name. A JavaScript caller's typo (`timeout` for
255
+ * `timeoutMs`) was ignored silently and the default applied; TypeScript catches it at
256
+ * compile time, JavaScript does not. `extra` names options a wrapper (the client) adds.
257
+ */
258
+ export function assertKnownOptions(options, extra = []) {
259
+ const names = [...OPTION_NAMES, ...extra];
260
+ for (const [key, value] of Object.entries(options)) {
261
+ // An unset key (`proxy: undefined` from a spread config) changes nothing: skip it.
262
+ if (value === undefined || names.includes(key))
263
+ continue;
264
+ const lower = key.toLowerCase();
265
+ const hint = names.find((name) => name.toLowerCase().includes(lower) || lower.includes(name.toLowerCase()));
266
+ throw new MastrValidationError(`Unknown option ${JSON.stringify(key)}` +
267
+ (hint === undefined ? `; the options are ${names.join(", ")}.` : ` (did you mean ${hint}?).`));
121
268
  }
122
- if (url.protocol !== "http:" && url.protocol !== "https:") {
123
- throw new MastrNetworkError(`Unsupported protocol "${url.protocol}" in base URL: ${redactUrl(baseUrl)}`);
269
+ }
270
+ /**
271
+ * Read a function option: `undefined` gives the default; anything else that is not a
272
+ * function throws. A string `transport` used to fail at the first request, and a bad
273
+ * `sleep` as a raw TypeError on the first retry.
274
+ */
275
+ function functionOption(name, value, fallback) {
276
+ if (value === undefined)
277
+ return fallback;
278
+ if (typeof value !== "function") {
279
+ throw new MastrValidationError(`Invalid ${name}: expected a function, got ${describeType(value)}.`);
124
280
  }
125
- if (/[?#]/.test(baseUrl)) {
126
- throw new MastrNetworkError(`Base URL must not contain a query or fragment: ${redactUrl(baseUrl)}`);
281
+ return value;
282
+ }
283
+ /** Engine options as given, or a MastrValidationError for a non-object (null counts as none). */
284
+ export function optionsObject(options) {
285
+ if (options === undefined || options === null)
286
+ return {};
287
+ if (typeof options !== "object" || Array.isArray(options)) {
288
+ throw new MastrValidationError(`Invalid options: expected an object, got ${describeType(options)}.`);
127
289
  }
290
+ return options;
128
291
  }
129
292
  const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
130
293
  export class RequestEngine {
131
- baseUrl;
294
+ // Real private fields (not TypeScript's `private`): util.inspect, console.log and
295
+ // JSON.stringify of a client never show them, so a password in the base URL (or a
296
+ // credential in a default header) can't be logged by accident.
297
+ #baseUrl;
298
+ /** The base URL's userinfo, raw and percent-decoded, for scrubbing server and transport text. */
299
+ #credentials;
132
300
  transport;
133
301
  userAgent;
134
- defaultHeaders;
302
+ #defaultHeaders;
135
303
  timeoutMs;
136
304
  maxRetries;
137
305
  retryDelayMs;
138
306
  maxResponseBytes;
139
307
  sleep;
140
308
  constructor(options = {}) {
141
- this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
142
- assertHttpScheme(this.baseUrl);
143
- this.transport = options.transport ?? nodeHttpTransport;
144
- this.userAgent = options.userAgent ?? DEFAULT_USER_AGENT;
145
- this.defaultHeaders = options.defaultHeaders ?? {};
146
- this.timeoutMs = options.timeoutMs ?? 30_000;
147
- this.maxRetries = options.maxRetries ?? 2;
148
- this.retryDelayMs = options.retryDelayMs ?? 200;
149
- this.maxResponseBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
150
- this.sleep = options.sleep ?? realSleep;
309
+ // A JavaScript caller may pass null for "no options"; anything else must be an object
310
+ // with known keys.
311
+ options = optionsObject(options);
312
+ assertKnownOptions(options);
313
+ // The raw value is checked before the trailing-slash strip, so "https://h/ "
314
+ // cannot slip past it; only an omitted baseUrl selects the default.
315
+ const baseUrl = options.baseUrl === undefined ? DEFAULT_BASE_URL : options.baseUrl;
316
+ this.#baseUrl = validateBaseUrl(baseUrl);
317
+ this.#credentials = credentialsIn(baseUrl).flatMap((raw) => {
318
+ try {
319
+ return [raw, decodeURIComponent(raw)];
320
+ }
321
+ catch {
322
+ return [raw];
323
+ }
324
+ });
325
+ this.transport = functionOption("transport", options.transport, nodeHttpTransport);
326
+ // Header values are checked up front: a blank one would be sent as is, and a
327
+ // CR/LF or a character above U+00FF would reach a custom transport raw or make
328
+ // Node's HTTP layer throw an untyped ERR_INVALID_CHAR. Only an omitted
329
+ // userAgent selects the default.
330
+ this.userAgent =
331
+ options.userAgent === undefined ? DEFAULT_USER_AGENT : assertHeaderValue("userAgent", options.userAgent);
332
+ this.#defaultHeaders = checkedHeaders(options.defaultHeaders ?? {});
333
+ // Range-check the numeric options: a negative, NaN or fractional value would
334
+ // otherwise silently disable the timeout or the size cap, and an unbounded
335
+ // maxRetries would keep retrying against the production register.
336
+ this.timeoutMs = intOption("timeoutMs", options.timeoutMs, MAX_TIMEOUT_MS, 30_000);
337
+ this.maxRetries = intOption("maxRetries", options.maxRetries, MAX_RETRIES, 2);
338
+ // Bounded like a Retry-After wait: a larger value would stall the CLI, and one above
339
+ // 2^31 - 1 ms would overflow Node's timer and retry after 1 ms.
340
+ this.retryDelayMs = intOption("retryDelayMs", options.retryDelayMs, MAX_RETRY_AFTER_MS, 200);
341
+ this.maxResponseBytes = intOption("maxResponseBytes", options.maxResponseBytes, Number.MAX_SAFE_INTEGER, DEFAULT_MAX_RESPONSE_BYTES);
342
+ this.sleep = functionOption("sleep", options.sleep, realSleep);
343
+ }
344
+ /**
345
+ * `text` without the base URL's credentials: server text (an error body that echoes the
346
+ * request URL) and transport text (fetch's "Failed to fetch <url>") can carry them. The
347
+ * client runs it on the `Errors` envelopes it turns into errors.
348
+ */
349
+ scrub(text) {
350
+ return this.#credentials.length === 0 ? text : redactCredentials(text, this.#credentials);
351
+ }
352
+ /**
353
+ * A transport failure as the `cause` of the error the engine raises: the original when its
354
+ * text carries no credentials, otherwise a copy with them scrubbed (message, `code` and the
355
+ * cause chain kept), so logging the error with its causes can't reveal the base URL's
356
+ * password.
357
+ */
358
+ scrubCause(cause, depth = 0) {
359
+ if (this.#credentials.length === 0 || depth > 5)
360
+ return cause;
361
+ if (typeof cause === "string")
362
+ return this.scrub(cause);
363
+ if (!(cause instanceof Error))
364
+ return cause;
365
+ const inner = this.scrubCause(cause.cause, depth + 1);
366
+ const message = this.scrub(cause.message);
367
+ if (message === cause.message && inner === cause.cause && !this.scrub(cause.stack ?? "").includes("***@")) {
368
+ return cause;
369
+ }
370
+ const copy = new Error(message, inner === undefined ? undefined : { cause: inner });
371
+ copy.name = cause.name;
372
+ const code = cause.code;
373
+ if (code !== undefined)
374
+ Object.assign(copy, { code });
375
+ return copy;
151
376
  }
152
377
  /** Build a fully-qualified URL from a path and optional query parameters. */
153
378
  buildUrl(path, query) {
154
379
  const normalizedPath = path.startsWith("/") ? path : `/${path}`;
155
380
  const qs = query ? buildQueryString(query) : "";
156
- return `${this.baseUrl}${normalizedPath}${qs ? `?${qs}` : ""}`;
381
+ return `${this.#baseUrl}${normalizedPath}${qs ? `?${qs}` : ""}`;
382
+ }
383
+ /**
384
+ * Call the transport under the overall deadline (`timeoutMs`): the request gets an
385
+ * AbortSignal that fires at the deadline, and the call rejects then whether the transport
386
+ * stops or not — a custom transport (fetch, a node:http wrapper) that ignores `timeoutMs`
387
+ * can't hang the caller. A synchronous throw becomes a rejection.
388
+ */
389
+ async callTransport(request) {
390
+ const call = (signal) => Promise.resolve().then(() => this.transport(signal === undefined ? request : { ...request, signal }));
391
+ if (this.timeoutMs === 0)
392
+ return call();
393
+ const controller = new AbortController();
394
+ let timer;
395
+ const deadline = new Promise((_, reject) => {
396
+ timer = setTimeout(() => {
397
+ const err = new MastrNetworkError(`Request timed out after ${this.timeoutMs}ms`);
398
+ controller.abort(err);
399
+ reject(err);
400
+ }, this.timeoutMs);
401
+ });
402
+ try {
403
+ return await Promise.race([call(controller.signal), deadline]);
404
+ }
405
+ finally {
406
+ clearTimeout(timer);
407
+ }
157
408
  }
158
409
  /**
159
410
  * Perform a GET with Accept negotiation and transient-error retries. Redirects
160
411
  * are NOT followed — the canonical host answers directly, so a 3xx (e.g. a bad
161
412
  * base URL bouncing to a portal page) surfaces as an error.
413
+ *
414
+ * The engine enforces the transport contract itself, so it holds for a custom
415
+ * transport too: `timeoutMs` (an AbortSignal deadline), `maxResponseBytes` (checked on
416
+ * the body it gets back), any byte-array body, `Headers`/`Map`/any-case headers. Whatever
417
+ * a transport throws becomes a `MastrNetworkError`, and so does a malformed response; a
418
+ * reset connection (`isTransientNetworkError`) is retried like a 503.
162
419
  */
163
420
  async request(path, query, accept = "application/json") {
164
421
  const url = this.buildUrl(path, query);
165
422
  const headers = {
166
- ...this.defaultHeaders,
423
+ ...this.#defaultHeaders,
167
424
  Accept: accept,
168
425
  "User-Agent": this.userAgent,
169
426
  // The MaStR search backend is a Kendo/DataTables endpoint that expects an
@@ -172,30 +429,80 @@ export class RequestEngine {
172
429
  };
173
430
  let attempt = 0;
174
431
  for (;;) {
175
- const response = await this.transport({
176
- method: "GET",
177
- url,
178
- headers,
179
- timeoutMs: this.timeoutMs,
180
- ...(this.maxResponseBytes > 0 ? { maxResponseBytes: this.maxResponseBytes } : {}),
181
- });
432
+ let response;
433
+ try {
434
+ response = await this.callTransport({
435
+ method: "GET",
436
+ url,
437
+ headers,
438
+ timeoutMs: this.timeoutMs,
439
+ ...(this.maxResponseBytes > 0 ? { maxResponseBytes: this.maxResponseBytes } : {}),
440
+ });
441
+ }
442
+ catch (cause) {
443
+ // A connection the server (or a gateway) reset is the network-level twin of a 503:
444
+ // retry it, whichever transport reported it. Timeouts are not retried.
445
+ if (hasTransientCode(cause) && attempt < this.maxRetries) {
446
+ attempt += 1;
447
+ await this.sleep(this.retryDelayMs * attempt);
448
+ continue;
449
+ }
450
+ // The default transport rejects with MastrNetworkError only; an injected one may
451
+ // throw anything (fetch's TypeError, a string, null). Keep the library's contract:
452
+ // every failure is a MastrError.
453
+ if (cause instanceof MastrNetworkError) {
454
+ // Its text may echo the request URL; re-raise it scrubbed when it does.
455
+ const message = this.scrub(cause.message);
456
+ const inner = this.scrubCause(cause.cause);
457
+ if (message === cause.message && inner === cause.cause)
458
+ throw cause;
459
+ throw new MastrNetworkError(message, inner === undefined ? undefined : { cause: inner });
460
+ }
461
+ if (cause instanceof MastrError)
462
+ throw cause;
463
+ const reason = cause instanceof Error ? cause.message : String(cause);
464
+ throw new MastrNetworkError(`GET ${redactUrl(url)} failed: ${sanitizeServerText(this.scrub(reason))}`, {
465
+ cause: this.scrubCause(cause),
466
+ });
467
+ }
468
+ // An injected transport may resolve with anything; a malformed HttpResponse would
469
+ // otherwise surface as a raw TypeError, or a missing status as a success.
470
+ const invalid = responseProblem(response);
471
+ if (invalid !== undefined) {
472
+ throw new MastrNetworkError(`GET ${redactUrl(url)} failed: the transport returned an invalid response (${invalid}).`);
473
+ }
182
474
  const status = response.status;
475
+ const responseHeaders = plainHeaders(response.headers);
476
+ const body = bodyBytes(response.body);
477
+ // The size cap holds whatever the transport did: the default one aborts early, a
478
+ // custom one may have read everything.
479
+ if (this.maxResponseBytes > 0 && body.byteLength > this.maxResponseBytes) {
480
+ throw new MastrNetworkError(sizeLimitMessage(this.maxResponseBytes));
481
+ }
183
482
  const retryable = status === 429 || status === 503;
483
+ // A Retry-After beyond MAX_RETRY_AFTER_MS is not retried: retrying early would land
484
+ // inside the window the server asked us to wait out, and a hostile value must not stall
485
+ // the CLI. The error then names the requested wait, so a script knows when to try again.
486
+ const retryAfter = retryable ? parseRetryAfter(responseHeaders["retry-after"]) : undefined;
184
487
  if (retryable && attempt < this.maxRetries) {
185
- // Honour Retry-After; without a usable one, back off linearly. A Retry-After
186
- // beyond MAX_RETRY_AFTER_MS is not retried: the error below surfaces at once.
187
- const retryAfter = parseRetryAfter(response.headers["retry-after"]);
188
488
  if (retryAfter === undefined || retryAfter <= MAX_RETRY_AFTER_MS) {
189
489
  attempt += 1;
190
- await this.sleep(retryAfter ?? this.retryDelayMs * attempt);
490
+ // The linear backoff is the floor: a Retry-After can make a wait longer, never
491
+ // shorter. `Retry-After: 0` or a date in the past turned the retries into a
492
+ // zero-delay burst against a register that had just answered 429/503.
493
+ const backoff = this.retryDelayMs * attempt;
494
+ await this.sleep(retryAfter === undefined ? backoff : Math.max(retryAfter, backoff));
191
495
  continue;
192
496
  }
497
+ throw this.toApiError(url, status, body, `the server asked to wait ${Math.ceil(retryAfter / 1000)} s (Retry-After) before trying again, ` +
498
+ `longer than the ${MAX_RETRY_AFTER_MS / 1000} s the client waits, so it was not retried; ` +
499
+ "retrying sooner won't help");
193
500
  }
194
- const contentType = String(response.headers["content-type"] ?? "");
501
+ const contentType = String(responseHeaders["content-type"] ?? "");
195
502
  if (status < 200 || status >= 300) {
196
- throw this.toApiError(url, status, response.body);
503
+ throw this.toApiError(url, status, body);
197
504
  }
198
- return { data: response.body, contentType, status };
505
+ return { data: body, contentType, status };
199
506
  }
200
507
  }
201
508
  /**
@@ -205,7 +512,7 @@ export class RequestEngine {
205
512
  */
206
513
  async getJson(path, query) {
207
514
  const res = await this.request(path, query);
208
- const text = res.data.toString("utf8");
515
+ const text = decodeBody(res.data, res.contentType, path);
209
516
  if (res.status === 204 || text.trim().length === 0) {
210
517
  throw new MastrParseError(`Empty response body from ${path}`);
211
518
  }
@@ -216,8 +523,9 @@ export class RequestEngine {
216
523
  throw new MastrParseError(`Failed to parse JSON response from ${path}`, { cause });
217
524
  }
218
525
  }
219
- toApiError(url, status, body) {
220
- const text = body.toString("utf8");
526
+ /** The MastrApiError for a non-2xx answer; `note` is appended to the detail. */
527
+ toApiError(url, status, body, note) {
528
+ const text = this.scrub(body.toString("utf8"));
221
529
  let detail;
222
530
  try {
223
531
  const parsed = JSON.parse(text);
@@ -240,8 +548,26 @@ export class RequestEngine {
240
548
  // collapse above does not remove ESC, so strip control characters before it can
241
549
  // reach stderr and inject terminal escape sequences.
242
550
  if (detail !== undefined)
243
- detail = sanitizeServerText(detail);
551
+ detail = cutDetail(sanitizeServerText(detail));
552
+ if (note !== undefined)
553
+ detail = detail === undefined || detail === "" ? note : `${detail}; ${note}`;
244
554
  return new MastrApiError({ status, url, method: "GET", body: text, detail });
245
555
  }
246
556
  }
247
- //# sourceMappingURL=engine.js.map
557
+ /**
558
+ * Decode a response body by the charset its Content-Type names (UTF-8 when it names none):
559
+ * a Latin-1 body from a re-encoding proxy or mirror became U+FFFD with `toString("utf8")`.
560
+ * TextDecoder also drops a leading byte order mark, which JSON.parse would reject. An
561
+ * unknown charset label is a MastrParseError.
562
+ */
563
+ function decodeBody(body, contentType, path) {
564
+ const charset = /;\s*charset\s*=\s*"?([^";\s]+)"?/i.exec(contentType)?.[1] ?? "utf-8";
565
+ let decoder;
566
+ try {
567
+ decoder = new TextDecoder(charset);
568
+ }
569
+ catch {
570
+ throw new MastrParseError(`Unsupported response charset "${sanitizeServerText(charset)}" from ${path}.`);
571
+ }
572
+ return decoder.decode(body);
573
+ }
@@ -4,6 +4,21 @@
4
4
  * A URL without userinfo, or one that does not parse, is returned unchanged.
5
5
  */
6
6
  export declare function redactUrl(url: string): string;
7
+ /**
8
+ * The userinfo a URL-like value carries, exactly as written — `["alice:pa#ss"]` for
9
+ * `https://alice:pa#ss@host` — or `[]` when it carries none. It works on values that don't
10
+ * parse as a URL too, and on values with a prefix (`--base-url=https://u:p@h`): the userinfo
11
+ * is everything between `://` and the last `@` before the host. A value without a scheme
12
+ * counts when it reads `user:password@host`. Used to redact those exact strings from text
13
+ * that echoes the value (usage errors, help), whatever characters the password contains.
14
+ */
15
+ export declare function credentialsIn(value: string): string[];
16
+ /**
17
+ * `text` with every occurrence of each credential (as `credentialsIn` returns them) that is
18
+ * followed by `@` replaced by `***`. Matching the exact strings, not a pattern, covers
19
+ * passwords with spaces, quotes, `#`, `?` or `/` that no URL pattern can delimit.
20
+ */
21
+ export declare function redactCredentials(text: string, credentials: readonly string[]): string;
7
22
  /** Base class for every error originating from this client. */
8
23
  export declare class MastrError extends Error {
9
24
  constructor(message: string, options?: {
@@ -41,11 +56,12 @@ export declare class MastrNetworkError extends MastrError {
41
56
  }
42
57
  /**
43
58
  * 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.
59
+ * range, a filter the register would misread — thrown before any request, with the
60
+ * message `Invalid <name>: <reason>` (see `assertValid`). The CLI maps it to the
61
+ * usage exit code 2.
45
62
  */
46
63
  export declare class MastrValidationError extends MastrError {
47
64
  }
48
65
  /** The response body could not be parsed as the expected JSON shape. */
49
66
  export declare class MastrParseError extends MastrError {
50
67
  }
51
- //# sourceMappingURL=errors.d.ts.map
@@ -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) {
@@ -66,11 +115,12 @@ export class MastrNetworkError extends MastrError {
66
115
  }
67
116
  /**
68
117
  * A client-side validation error — an unknown category, a page or pageSize out of
69
- * range, a filter the register would misread — thrown before any request.
118
+ * range, a filter the register would misread — thrown before any request, with the
119
+ * message `Invalid <name>: <reason>` (see `assertValid`). The CLI maps it to the
120
+ * usage exit code 2.
70
121
  */
71
122
  export class MastrValidationError extends MastrError {
72
123
  }
73
124
  /** The response body could not be parsed as the expected JSON shape. */
74
125
  export class MastrParseError extends MastrError {
75
126
  }
76
- //# sourceMappingURL=errors.js.map