@terrascale/sdk 0.0.0-stage → 0.0.2

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 (89) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/LICENSE +202 -0
  3. package/README.md +202 -2
  4. package/package.json +152 -4
  5. package/sdk-v1-route-manifest.json +2330 -0
  6. package/src/admin.js +9 -0
  7. package/src/better-auth.js +14 -0
  8. package/src/config.js +121 -0
  9. package/src/database-codec.js +845 -0
  10. package/src/database-types.js +237 -0
  11. package/src/database-view.js +423 -0
  12. package/src/database.js +2 -0
  13. package/src/http.js +898 -0
  14. package/src/index.js +62 -0
  15. package/src/local/authentication.js +47 -0
  16. package/src/local/better-auth.js +517 -0
  17. package/src/local/cli.js +51 -0
  18. package/src/local/context.js +23 -0
  19. package/src/local/environment.js +109 -0
  20. package/src/local/index.js +206 -0
  21. package/src/local/legacy-client.js +411 -0
  22. package/src/local/router.js +999 -0
  23. package/src/local/server.js +664 -0
  24. package/src/local/store.js +530 -0
  25. package/src/local/test-environment.js +74 -0
  26. package/src/management-contracts.js +72 -0
  27. package/src/management.js +12 -0
  28. package/src/native-origin.js +75 -0
  29. package/src/postgres.js +496 -0
  30. package/src/react/core.js +743 -0
  31. package/src/react/index.js +99 -0
  32. package/src/result.js +251 -0
  33. package/src/schema.js +366 -0
  34. package/src/sql.js +996 -0
  35. package/src/svelte/index.js +129 -0
  36. package/src/tanstack/index.js +512 -0
  37. package/src/ts-auth-discovery.js +207 -0
  38. package/src/ts-auth.js +3498 -0
  39. package/src/v1/decode.js +118 -0
  40. package/src/v1/errors.js +185 -0
  41. package/src/v1/filter.js +230 -0
  42. package/src/v1/ids.js +119 -0
  43. package/src/v1/json.js +335 -0
  44. package/src/v1/routes.js +57 -0
  45. package/src/v1/shapes.js +145 -0
  46. package/src/v1/terrabase-v1.js +722 -0
  47. package/src/v1/transport.js +324 -0
  48. package/types/admin.d.ts +6 -0
  49. package/types/better-auth.d.ts +8 -0
  50. package/types/config.d.ts +58 -0
  51. package/types/database-codec.d.ts +111 -0
  52. package/types/database-types.d.ts +213 -0
  53. package/types/database-view.d.ts +184 -0
  54. package/types/database.d.ts +2 -0
  55. package/types/http.d.ts +47 -0
  56. package/types/index.d.ts +43 -0
  57. package/types/local/authentication.d.ts +11 -0
  58. package/types/local/better-auth.d.ts +33 -0
  59. package/types/local/cli.d.ts +2 -0
  60. package/types/local/context.d.ts +14 -0
  61. package/types/local/environment.d.ts +23 -0
  62. package/types/local/index.d.ts +96 -0
  63. package/types/local/legacy-client.d.ts +94 -0
  64. package/types/local/router.d.ts +66 -0
  65. package/types/local/server.d.ts +54 -0
  66. package/types/local/store.d.ts +106 -0
  67. package/types/local/test-environment.d.ts +25 -0
  68. package/types/management-contracts.d.ts +44 -0
  69. package/types/management.d.ts +6 -0
  70. package/types/native-origin.d.ts +23 -0
  71. package/types/postgres.d.ts +124 -0
  72. package/types/react/core.d.ts +366 -0
  73. package/types/react/index.d.ts +54 -0
  74. package/types/result.d.ts +161 -0
  75. package/types/schema.d.ts +145 -0
  76. package/types/sql.d.ts +288 -0
  77. package/types/svelte/index.d.ts +81 -0
  78. package/types/tanstack/index.d.ts +166 -0
  79. package/types/ts-auth-discovery.d.ts +19 -0
  80. package/types/ts-auth.d.ts +1826 -0
  81. package/types/v1/decode.d.ts +119 -0
  82. package/types/v1/errors.d.ts +107 -0
  83. package/types/v1/filter.d.ts +148 -0
  84. package/types/v1/ids.d.ts +44 -0
  85. package/types/v1/json.d.ts +92 -0
  86. package/types/v1/routes.d.ts +26 -0
  87. package/types/v1/shapes.d.ts +78 -0
  88. package/types/v1/terrabase-v1.d.ts +374 -0
  89. package/types/v1/transport.d.ts +117 -0
package/src/http.js ADDED
@@ -0,0 +1,898 @@
1
+ import { resolveAccessToken, resolveTsAuthRuntimeConfig } from "./config.js";
2
+ import { err, ok, sanitizeDiagnostic } from "./result.js";
3
+ import {
4
+ regionalIssuerOrigin,
5
+ TsAuthEndpointDiscovery,
6
+ } from "./ts-auth-discovery.js";
7
+ import { parseDatabaseJson } from "./database-codec.js";
8
+ /** @import { ResolvedTsAuthRuntimeConfig, TsAuthRuntimeConfig, FetchLike } from "./config.js" */
9
+ /** @import { TerraScaleError, TerraScaleErrorCategory, TerraScaleResult, TerraScaleValidationIssue } from "./result.js" */
10
+
11
+ /** @typedef {`tsauth.${string}`} TsAuthRouteName */
12
+ /** @typedef {"DELETE" | "GET" | "PATCH" | "POST" | "PUT"} TsAuthHttpMethod */
13
+ /**
14
+ * @template TBody
15
+ * @typedef {{
16
+ * readonly method: TsAuthHttpMethod;
17
+ * readonly path: string;
18
+ * readonly routeName: TsAuthRouteName;
19
+ * readonly query?: object | undefined;
20
+ * readonly body?: TBody | undefined;
21
+ * readonly formBody?: URLSearchParams | string | undefined;
22
+ * readonly userinfoAccessToken?: string | undefined;
23
+ * readonly resourceIntrospectionBasic?: string | undefined;
24
+ * readonly issuer?: string | undefined; // Behind the Atlas proxy a regional issuer is called on its own host.
25
+ * readonly signal?: AbortSignal | undefined;
26
+ * }} TsAuthRequest
27
+ */
28
+ const retryableStatuses = new Set([429, 502, 503, 504]);
29
+ const maximumResponseBytes = 4 * 1024 * 1024;
30
+
31
+ /** Private transport for the named hosted identity and OIDC operations. */
32
+ export class TsAuthHttp {
33
+ /**
34
+ * @readonly
35
+ * @type {ResolvedTsAuthRuntimeConfig}
36
+ */
37
+ config;
38
+ /** @readonly @type {FetchLike} */
39
+ #fetch;
40
+ /** @readonly @type {TsAuthRuntimeConfig["accessToken"]} */
41
+ #accessToken;
42
+ /** @readonly @type {TsAuthEndpointDiscovery | undefined} */
43
+ #discovery;
44
+
45
+ /**
46
+ * @param {TsAuthRuntimeConfig} config
47
+ * @param {{ readonly authReverseProxy?: boolean }} [options]
48
+ */
49
+ constructor(config, options) {
50
+ this.config = Object.freeze(resolveTsAuthRuntimeConfig(config));
51
+ const fetchHandler = config.fetch ?? globalThis.fetch?.bind(globalThis);
52
+ if (fetchHandler === undefined)
53
+ throw new TypeError("TsAuthClient requires a fetch implementation.");
54
+ this.#fetch = fetchHandler;
55
+ this.#accessToken = config.accessToken;
56
+ this.#discovery =
57
+ options?.authReverseProxy !== true &&
58
+ this.config.apiBaseUrl === "https://auth.terrascale.tech"
59
+ ? new TsAuthEndpointDiscovery(fetchHandler)
60
+ : undefined;
61
+ }
62
+
63
+ /**
64
+ * @template TResponse
65
+ * @template [TBody=unknown]
66
+ * @param {TsAuthRequest<TBody>} options
67
+ * @returns {Promise<TerraScaleResult<TResponse>>}
68
+ */
69
+ async request(options) {
70
+ const deadline =
71
+ this.config.requestTimeoutMs > 0
72
+ ? AbortSignal.timeout(this.config.requestTimeoutMs)
73
+ : undefined;
74
+ const signals = [options.signal, deadline].filter(
75
+ /** @returns {signal is AbortSignal} */
76
+ (signal) => signal !== undefined,
77
+ );
78
+ const signal =
79
+ signals.length === 0
80
+ ? undefined
81
+ : signals.length === 1
82
+ ? signals[0]
83
+ : AbortSignal.any(signals);
84
+ try {
85
+ signal?.throwIfAborted();
86
+ if (options.body !== undefined && options.formBody !== undefined)
87
+ throw new TypeError("Requests accept either JSON or form data.");
88
+ const body =
89
+ options.body !== undefined
90
+ ? JSON.stringify(options.body)
91
+ : options.formBody?.toString();
92
+ const issuerOrigin =
93
+ this.#discovery === undefined || options.issuer === undefined
94
+ ? undefined
95
+ : regionalIssuerOrigin(options.issuer);
96
+ const endpoints =
97
+ issuerOrigin === undefined
98
+ ? await withCancellation(
99
+ this.#discovery?.endpoints(signal) ?? Promise.resolve(undefined),
100
+ signal,
101
+ )
102
+ : [issuerOrigin];
103
+ let endpointIndex = 0;
104
+ let attempt = 0;
105
+ let refreshed = false;
106
+ /** @type {string | undefined} */
107
+ let refreshedToken;
108
+ while (true) {
109
+ signal?.throwIfAborted();
110
+ let responseReceived = false;
111
+ let fetchStarted = false;
112
+ try {
113
+ const headers = new Headers({ accept: "application/json" });
114
+ const oidc = options.routeName.startsWith("tsauth.oidc.");
115
+ const token = oidc
116
+ ? undefined
117
+ : (refreshedToken ??
118
+ (await withCancellation(
119
+ resolveAccessToken(this.#accessToken),
120
+ signal,
121
+ )));
122
+ if (token !== undefined && token.length > 0)
123
+ headers.set(
124
+ "authorization",
125
+ token.startsWith("Bearer ") ? token : `Bearer ${token}`,
126
+ );
127
+ if (options.userinfoAccessToken !== undefined) {
128
+ if (
129
+ options.routeName !== "tsauth.oidc.userinfo" ||
130
+ options.method !== "GET"
131
+ )
132
+ throw new TypeError(
133
+ "Bearer access tokens belong to named OIDC userinfo only.",
134
+ );
135
+ headers.set(
136
+ "authorization",
137
+ `Bearer ${options.userinfoAccessToken}`,
138
+ );
139
+ }
140
+ if (options.resourceIntrospectionBasic !== undefined) {
141
+ if (
142
+ options.routeName !== "tsauth.oidc.resourceIntrospect" ||
143
+ options.method !== "POST" ||
144
+ !/^[^/]+\/oauth\/introspect$/u.test(options.path) ||
145
+ options.formBody === undefined ||
146
+ options.userinfoAccessToken !== undefined ||
147
+ !/^Basic [A-Za-z0-9+/]+={0,2}$/u.test(
148
+ options.resourceIntrospectionBasic,
149
+ )
150
+ )
151
+ throw new TypeError(
152
+ "Resource Basic credentials belong to named resource introspection only.",
153
+ );
154
+ headers.set("authorization", options.resourceIntrospectionBasic);
155
+ }
156
+ if (body !== undefined)
157
+ headers.set(
158
+ "content-type",
159
+ options.formBody === undefined
160
+ ? "application/json"
161
+ : "application/x-www-form-urlencoded",
162
+ );
163
+ const request = new Request(
164
+ buildUrl(
165
+ endpoints?.[endpointIndex] ?? this.config.apiBaseUrl,
166
+ options.path,
167
+ options.query,
168
+ ),
169
+ {
170
+ method: options.method,
171
+ headers,
172
+ redirect: "manual",
173
+ ...(signal === undefined ? {} : { signal }),
174
+ ...(body === undefined ? {} : { body }),
175
+ },
176
+ );
177
+ signal?.throwIfAborted();
178
+ fetchStarted = true;
179
+ const response = await withCancellation(
180
+ this.#fetch(request),
181
+ signal,
182
+ (response) => {
183
+ void response.body?.cancel().catch(() => undefined);
184
+ },
185
+ );
186
+ if (signal?.aborted) {
187
+ void response.body?.cancel().catch(() => undefined);
188
+ signal.throwIfAborted();
189
+ }
190
+ responseReceived = true;
191
+ if (
192
+ options.method === "GET" &&
193
+ endpoints !== undefined &&
194
+ endpointIndex < endpoints.length - 1 &&
195
+ [502, 503, 504].includes(response.status)
196
+ ) {
197
+ void response.body?.cancel().catch(() => undefined);
198
+ endpointIndex += 1;
199
+ continue;
200
+ }
201
+ if (
202
+ response.type === "opaqueredirect" ||
203
+ (response.status >= 300 && response.status < 400)
204
+ ) {
205
+ void response.body?.cancel().catch(() => undefined);
206
+ throw new TypeError(
207
+ "ts-auth returned an unsupported HTTP redirect.",
208
+ );
209
+ }
210
+ const parsed = await readResponseBody(
211
+ response,
212
+ options.routeName,
213
+ signal,
214
+ );
215
+ signal?.throwIfAborted();
216
+ if (response.ok) {
217
+ if (!parsed.ok)
218
+ return err(
219
+ options.routeName,
220
+ {
221
+ kind: "invalid-response",
222
+ code: "invalid_response",
223
+ message: parsed.errorMessage,
224
+ status: response.status,
225
+ requestId: requestIdFrom(response.headers),
226
+ },
227
+ response.status,
228
+ response.headers,
229
+ );
230
+ return ok(
231
+ options.routeName,
232
+ response.status,
233
+ response.headers,
234
+ /** @type {TResponse} */ (parsed.value),
235
+ );
236
+ }
237
+ if (
238
+ response.status === 401 &&
239
+ !oidc &&
240
+ !refreshed &&
241
+ typeof this.#accessToken === "function"
242
+ ) {
243
+ const next = await withCancellation(
244
+ resolveAccessToken(this.#accessToken),
245
+ signal,
246
+ );
247
+ if (next !== undefined && next.length > 0 && next !== token) {
248
+ refreshed = true;
249
+ refreshedToken = next;
250
+ continue;
251
+ }
252
+ }
253
+ if (
254
+ options.method === "GET" &&
255
+ attempt < this.config.maxRetryAttempts &&
256
+ retryableStatuses.has(response.status)
257
+ ) {
258
+ const delay = retryDelay(
259
+ response,
260
+ this.config.retryDelayMs,
261
+ attempt,
262
+ );
263
+ if (delay <= 30_000) {
264
+ await delayForRetry(delay, signal);
265
+ attempt += 1;
266
+ continue;
267
+ }
268
+ }
269
+ const errorBody = sanitizeHttpDiagnostic(
270
+ parsed.ok ? parsed.value : undefined,
271
+ );
272
+ return err(
273
+ options.routeName,
274
+ errorFromResponse(response, errorBody, options.routeName),
275
+ response.status,
276
+ response.headers,
277
+ errorBody,
278
+ );
279
+ } catch (error) {
280
+ if (
281
+ fetchStarted &&
282
+ !responseReceived &&
283
+ !signal?.aborted &&
284
+ options.method === "GET"
285
+ ) {
286
+ if (
287
+ endpoints !== undefined &&
288
+ endpointIndex < endpoints.length - 1
289
+ ) {
290
+ endpointIndex += 1;
291
+ continue;
292
+ }
293
+ if (attempt < this.config.maxRetryAttempts) {
294
+ await delayForRetry(
295
+ retryDelay(undefined, this.config.retryDelayMs, attempt),
296
+ signal,
297
+ );
298
+ attempt += 1;
299
+ continue;
300
+ }
301
+ }
302
+ throw error;
303
+ }
304
+ }
305
+ } catch (error) {
306
+ return err(options.routeName, {
307
+ kind: "transport",
308
+ code: "transport_error",
309
+ message:
310
+ error instanceof Error
311
+ ? String(sanitizeHttpDiagnostic(error.message))
312
+ : "The request failed before a response was received.",
313
+ details: sanitizeHttpDiagnostic(error),
314
+ });
315
+ }
316
+ }
317
+ }
318
+
319
+ /**
320
+ * @param {string} origin
321
+ * @param {string} path
322
+ * @param {object} [query]
323
+ * @returns {URL}
324
+ */
325
+ function buildUrl(origin, path, query) {
326
+ const normalized = path.replace(/^\/+/, "");
327
+ if (
328
+ !/^v1\//u.test(normalized) &&
329
+ !/^[^/]+\/(?:\.well-known|oauth)\//u.test(normalized)
330
+ ) {
331
+ throw new TypeError(
332
+ "ts-auth requests require named management or instance OIDC routes.",
333
+ );
334
+ }
335
+ const url = new URL(normalized, `${origin}/`);
336
+ if (url.origin !== new URL(origin).origin)
337
+ throw new TypeError("ts-auth paths cannot escape their origin.");
338
+ for (const [name, value] of Object.entries(query ?? {})) {
339
+ if (value !== null && value !== undefined)
340
+ url.searchParams.set(
341
+ name,
342
+ value instanceof Date ? value.toISOString() : String(value),
343
+ );
344
+ }
345
+ return url;
346
+ }
347
+
348
+ /**
349
+ * @typedef {{ readonly ok: true; readonly value: unknown }
350
+ * | { readonly ok: false; readonly errorMessage: string }} ParsedBody
351
+ */
352
+
353
+ /**
354
+ * @param {TsAuthRouteName} routeName
355
+ * @returns {boolean}
356
+ */
357
+ function strictOauthRoute(routeName) {
358
+ return (
359
+ routeName.startsWith("tsauth.oauthClients.") ||
360
+ routeName.startsWith("tsauth.oauthResources.") ||
361
+ [
362
+ "tsauth.oidc.token",
363
+ "tsauth.oidc.introspect",
364
+ "tsauth.oidc.resourceIntrospect",
365
+ "tsauth.oidc.deviceAuthorization",
366
+ ].includes(routeName)
367
+ );
368
+ }
369
+
370
+ /**
371
+ * @param {Response} response
372
+ * @param {TsAuthRouteName} routeName
373
+ * @param {AbortSignal} [signal]
374
+ * @returns {Promise<ParsedBody>}
375
+ */
376
+ async function readResponseBody(response, routeName, signal) {
377
+ const reader = response.body?.getReader();
378
+ /** @type {Uint8Array[]} */
379
+ const chunks = [];
380
+ let length = 0;
381
+ try {
382
+ if (reader !== undefined) {
383
+ while (true) {
384
+ const item = await withCancellation(reader.read(), signal);
385
+ if (item.done) break;
386
+ length += item.value.byteLength;
387
+ if (length > maximumResponseBytes)
388
+ throw new TypeError("ts-auth response exceeds the body limit.");
389
+ chunks.push(item.value);
390
+ }
391
+ }
392
+ } catch (error) {
393
+ void reader?.cancel().catch(() => undefined);
394
+ throw error;
395
+ } finally {
396
+ reader?.releaseLock();
397
+ }
398
+ const bytes = new Uint8Array(length);
399
+ let offset = 0;
400
+ for (const chunk of chunks) {
401
+ bytes.set(chunk, offset);
402
+ offset += chunk.byteLength;
403
+ }
404
+ const raw = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
405
+ const jsonRequired =
406
+ response.ok &&
407
+ response.status !== 204 &&
408
+ routeName !== "tsauth.oidc.revoke";
409
+ if (raw.length === 0)
410
+ return jsonRequired
411
+ ? {
412
+ ok: false,
413
+ errorMessage: "ts-auth returned an empty response body.",
414
+ }
415
+ : { ok: true, value: undefined };
416
+ const contentType = response.headers.get("content-type") ?? "";
417
+ if (!contentType.toLowerCase().includes("json"))
418
+ return jsonRequired
419
+ ? {
420
+ ok: false,
421
+ errorMessage: "ts-auth returned a non-JSON response body.",
422
+ }
423
+ : { ok: true, value: undefined };
424
+ try {
425
+ const strictOauth = strictOauthRoute(routeName);
426
+ if (strictOauth) {
427
+ // Reuse the bounded duplicate-member lexer. Native domain decoding is not
428
+ // involved; this is the package's private strict JSON syntax primitive.
429
+ parseDatabaseJson(bytes);
430
+ }
431
+ /** @type {unknown} */
432
+ const value = strictOauth
433
+ ? JSON.parse(
434
+ raw,
435
+ /**
436
+ * @param {string} key
437
+ * @param {unknown} value
438
+ * @param {{ readonly source?: string }} [context]
439
+ * @returns {unknown}
440
+ */
441
+ (key, value, context) => {
442
+ if (
443
+ typeof value === "number" &&
444
+ [
445
+ "auth_epoch",
446
+ "created_at",
447
+ "updated_at",
448
+ "exp",
449
+ "iat",
450
+ "expires_in",
451
+ "interval",
452
+ ].includes(key)
453
+ ) {
454
+ if (
455
+ !Number.isSafeInteger(value) ||
456
+ context?.source === undefined ||
457
+ !/^(0|[1-9][0-9]*)$/u.test(context.source) ||
458
+ BigInt(context.source) !== BigInt(value)
459
+ )
460
+ throw new TypeError(
461
+ "OAuth integer exceeds the exact JSON contract.",
462
+ );
463
+ }
464
+ return value;
465
+ },
466
+ )
467
+ : JSON.parse(raw);
468
+ return { ok: true, value };
469
+ } catch {
470
+ // JSON parser messages may quote secret-bearing response bytes. Only
471
+ // decoded JSON can enter diagnostics, where sensitive fields are redacted.
472
+ return {
473
+ ok: false,
474
+ errorMessage: "ts-auth returned an invalid JSON response body.",
475
+ };
476
+ }
477
+ }
478
+
479
+ /**
480
+ * @param {Response | undefined} response
481
+ * @returns {number | undefined}
482
+ */
483
+ function retryAfterMs(response) {
484
+ const retryAfter = response?.headers.get("retry-after");
485
+ if (retryAfter === null || retryAfter === undefined) {
486
+ return undefined;
487
+ }
488
+
489
+ const seconds = Number(retryAfter);
490
+ if (Number.isFinite(seconds) && seconds >= 0) {
491
+ return seconds * 1_000;
492
+ }
493
+
494
+ const date = Date.parse(retryAfter);
495
+ return Number.isFinite(date) ? Math.max(0, date - Date.now()) : undefined;
496
+ }
497
+
498
+ /**
499
+ * @param {Response | undefined} response
500
+ * @param {number} baseDelayMs
501
+ * @param {number} attempt
502
+ * @returns {number}
503
+ */
504
+ function retryDelay(response, baseDelayMs, attempt) {
505
+ const retryAfter = retryAfterMs(response);
506
+ if (retryAfter !== undefined) {
507
+ return retryAfter;
508
+ }
509
+
510
+ // Computed backoff carries jitter (ADR 0050 §5.9) so concurrent clients
511
+ // that fail together do not retry in lockstep; server-provided
512
+ // Retry-After timing is honoured exactly and never jittered.
513
+ const exponential = Math.min(baseDelayMs * 2 ** attempt, 30_000);
514
+ const jitterRatio = 0.8 + Math.random() * 0.4;
515
+ return Math.round(Math.min(exponential * jitterRatio, 30_000));
516
+ }
517
+
518
+ /**
519
+ * @template T
520
+ * @param {Promise<T>} operation
521
+ * @param {AbortSignal} [signal]
522
+ * @param {(value: T) => void} [disposeLateValue]
523
+ * @returns {Promise<T>}
524
+ */
525
+ function withCancellation(operation, signal, disposeLateValue) {
526
+ if (signal === undefined) return operation;
527
+ return new Promise((resolve, reject) => {
528
+ /** @returns {void} */
529
+ const onAbort = () =>
530
+ reject(
531
+ signal.reason ??
532
+ new DOMException("The operation was aborted", "AbortError"),
533
+ );
534
+ if (signal.aborted) onAbort();
535
+ else signal.addEventListener("abort", onAbort, { once: true });
536
+ void operation
537
+ .then((value) => {
538
+ if (signal.aborted) {
539
+ try {
540
+ disposeLateValue?.(value);
541
+ } catch {
542
+ // Cleanup failure cannot change a terminal cancellation.
543
+ }
544
+ onAbort();
545
+ } else resolve(value);
546
+ }, reject)
547
+ .finally(() => signal.removeEventListener("abort", onAbort));
548
+ });
549
+ }
550
+
551
+ /**
552
+ * @param {number} delayMs
553
+ * @param {AbortSignal} [signal]
554
+ * @returns {Promise<void>}
555
+ */
556
+ function delayForRetry(delayMs, signal) {
557
+ if (signal?.aborted)
558
+ return Promise.reject(
559
+ signal.reason ??
560
+ new DOMException("The operation was aborted", "AbortError"),
561
+ );
562
+ if (delayMs === 0) {
563
+ return Promise.resolve();
564
+ }
565
+
566
+ return new Promise((resolve, reject) => {
567
+ const timer = setTimeout(() => {
568
+ signal?.removeEventListener("abort", onAbort);
569
+ resolve();
570
+ }, delayMs);
571
+ /** @returns {void} */
572
+ const onAbort = () => {
573
+ clearTimeout(timer);
574
+ reject(
575
+ signal?.reason ??
576
+ new DOMException("The operation was aborted", "AbortError"),
577
+ );
578
+ };
579
+ signal?.addEventListener("abort", onAbort, { once: true });
580
+ if (signal?.aborted) onAbort();
581
+ });
582
+ }
583
+
584
+ /**
585
+ * @param {Response} response
586
+ * @param {unknown} body
587
+ * @param {TsAuthRouteName} routeName
588
+ * @returns {TerraScaleError}
589
+ */
590
+ function errorFromResponse(response, body, routeName) {
591
+ // Error responses are producer-controlled. Sanitize before extracting any
592
+ // published fields so hostile values cannot enter `message`, `fix`, or
593
+ // validation metadata and rely on a later result-level pass to clean them.
594
+ const safeBody = sanitizeHttpDiagnostic(body);
595
+ const source = unwrapErrorBody(safeBody);
596
+ const category = errorCategory(source);
597
+ const traceId = stringProperty(source, "traceId");
598
+ // RFC 6749 flat OAuth failures carry the code in `error` and the message in
599
+ // `error_description`. On ts-auth routes a bare `error` is already the
600
+ // published OAuth code (RFC 6749 allows omitting the description); on every
601
+ // other surface the pair only maps when both are present, so the legacy
602
+ // flat shape (a bare human message in `error`) keeps its meaning.
603
+ const flatError = stringProperty(source, "error");
604
+ const flatOAuthPair =
605
+ flatError !== undefined && typeof source.error_description === "string";
606
+ const oauthCode = routeName.startsWith("tsauth.")
607
+ ? flatError
608
+ : flatOAuthPair
609
+ ? flatError
610
+ : undefined;
611
+ const code =
612
+ stringProperty(source, "code") ??
613
+ stringProperty(source, "errorCode") ??
614
+ oauthCode ??
615
+ defaultCode(response.status);
616
+ const validation = validationIssues(source);
617
+ const fix = stringProperty(source, "fix");
618
+ const retryAfter = retryAfterMs(response);
619
+
620
+ return {
621
+ kind: kindFromStatus(response.status, validation, code),
622
+ code,
623
+ message:
624
+ stringProperty(source, "message") ??
625
+ (flatOAuthPair
626
+ ? stringProperty(source, "error_description")
627
+ : undefined) ??
628
+ stringProperty(source, "error") ??
629
+ stringProperty(source, "detail") ??
630
+ stringProperty(source, "title") ??
631
+ (response.statusText || "The TerraScale API request failed."),
632
+ ...(fix === undefined ? {} : { fix }),
633
+ ...(retryAfter === undefined ? {} : { retryAfter }),
634
+ category,
635
+ detail: stringProperty(source, "detail"),
636
+ suggestedAction: stringProperty(source, "suggestedAction"),
637
+ docsUrl: stringProperty(source, "docsUrl"),
638
+ traceId,
639
+ status: response.status,
640
+ target: stringProperty(source, "target"),
641
+ requestId:
642
+ stringProperty(source, "requestId") ??
643
+ requestIdFrom(response.headers) ??
644
+ (category === undefined ? traceId : undefined),
645
+ validation,
646
+ details: safeBody,
647
+ };
648
+ }
649
+
650
+ const diagnosticTokenKey = /(?:^|[._-])(?:token|jwt|signature)(?:$|[._-])/iu;
651
+ const diagnosticUrl = /https?:\/\/[^\s<>"']+/giu;
652
+
653
+ /**
654
+ * Apply HTTP-specific rules before error fields are extracted.
655
+ * @param {unknown} value
656
+ * @returns {unknown}
657
+ */
658
+ function sanitizeHttpDiagnostic(value) {
659
+ return sanitizeHttpDiagnosticValue(
660
+ sanitizeDiagnostic(value),
661
+ "",
662
+ new WeakSet(),
663
+ 0,
664
+ );
665
+ }
666
+
667
+ /**
668
+ * @param {unknown} value
669
+ * @param {string} key
670
+ * @param {WeakSet<object>} seen
671
+ * @param {number} depth
672
+ * @returns {unknown}
673
+ */
674
+ function sanitizeHttpDiagnosticValue(value, key, seen, depth) {
675
+ if (diagnosticTokenKey.test(key)) return "[redacted]";
676
+ if (typeof value === "string") {
677
+ return value.replace(diagnosticUrl, (candidate) => {
678
+ try {
679
+ const url = new URL(candidate.replace(/[).,;]+$/u, ""));
680
+ return url.search !== "" || url.hash !== ""
681
+ ? "[redacted URL]"
682
+ : candidate;
683
+ } catch {
684
+ return candidate;
685
+ }
686
+ });
687
+ }
688
+ if (value === null || typeof value !== "object") return value;
689
+ if (depth >= 12 || seen.has(value)) return "[redacted]";
690
+ seen.add(value);
691
+ if (Array.isArray(value))
692
+ return value.map((entry) =>
693
+ sanitizeHttpDiagnosticValue(entry, "", seen, depth + 1),
694
+ );
695
+ /** @type {Record<string, unknown>} */
696
+ const output = {};
697
+ for (const [entryKey, entryValue] of Object.entries(value)) {
698
+ output[entryKey] = sanitizeHttpDiagnosticValue(
699
+ entryValue,
700
+ entryKey,
701
+ seen,
702
+ depth + 1,
703
+ );
704
+ }
705
+ return output;
706
+ }
707
+
708
+ /**
709
+ * @param {Record<string, unknown>} source
710
+ * @returns {TerraScaleErrorCategory | undefined}
711
+ */
712
+ function errorCategory(source) {
713
+ const value = stringProperty(source, "category");
714
+ switch (value) {
715
+ case "Validation":
716
+ case "Authentication":
717
+ case "Authorization":
718
+ case "NotFound":
719
+ case "Conflict":
720
+ case "RateLimit":
721
+ case "Schema":
722
+ case "Query":
723
+ case "Placement":
724
+ case "AutoFix":
725
+ case "AutoIndex":
726
+ case "Sandbox":
727
+ case "System":
728
+ return value;
729
+ default:
730
+ return undefined;
731
+ }
732
+ }
733
+
734
+ /**
735
+ * @param {unknown} body
736
+ * @returns {Record<string, unknown>}
737
+ */
738
+ function unwrapErrorBody(body) {
739
+ if (!isRecord(body)) {
740
+ return {};
741
+ }
742
+
743
+ const error = body.error;
744
+ return isRecord(error) ? error : body;
745
+ }
746
+
747
+ /**
748
+ * @param {Record<string, unknown>} source
749
+ * @param {string} key
750
+ * @returns {string | undefined}
751
+ */
752
+ function stringProperty(source, key) {
753
+ const value = source[key];
754
+ return typeof value === "string" && value.length > 0 ? value : undefined;
755
+ }
756
+
757
+ /**
758
+ * @param {Record<string, unknown>} source
759
+ * @returns {readonly TerraScaleValidationIssue[] | undefined}
760
+ */
761
+ function validationIssues(source) {
762
+ const raw = source.fieldErrors ?? source.validationErrors ?? source.errors;
763
+
764
+ if (Array.isArray(raw)) {
765
+ return raw
766
+ .map((item) => {
767
+ if (typeof item === "string") {
768
+ return { message: item };
769
+ }
770
+
771
+ if (!isRecord(item)) {
772
+ return undefined;
773
+ }
774
+
775
+ const message =
776
+ stringProperty(item, "message") ??
777
+ stringProperty(item, "errorMessage");
778
+
779
+ if (message === undefined) {
780
+ return undefined;
781
+ }
782
+
783
+ return {
784
+ path: stringProperty(item, "path") ?? stringProperty(item, "field"),
785
+ message,
786
+ code:
787
+ stringProperty(item, "code") ?? stringProperty(item, "constraint"),
788
+ };
789
+ })
790
+ .filter(
791
+ /** @returns {item is TerraScaleValidationIssue} */
792
+ (item) => item !== undefined,
793
+ );
794
+ }
795
+
796
+ if (isRecord(raw)) {
797
+ return Object.entries(raw).flatMap(([path, value]) => {
798
+ const messages = Array.isArray(value) ? value : [value];
799
+ return messages
800
+ .filter(
801
+ /** @returns {message is string} */
802
+ (message) => typeof message === "string",
803
+ )
804
+ .map((message) => ({ path, message }));
805
+ });
806
+ }
807
+
808
+ return undefined;
809
+ }
810
+
811
+ /**
812
+ * @param {number} status
813
+ * @param {readonly TerraScaleValidationIssue[] | undefined} validation
814
+ * @param {string} code
815
+ */
816
+ function kindFromStatus(status, validation, code) {
817
+ if (validation !== undefined && validation.length > 0) {
818
+ return "validation";
819
+ }
820
+
821
+ if (status === 401) {
822
+ return "authentication";
823
+ }
824
+
825
+ if (status === 403) {
826
+ return "authorization";
827
+ }
828
+
829
+ if (status === 404) {
830
+ return "not-found";
831
+ }
832
+
833
+ if (status === 409) {
834
+ return "conflict";
835
+ }
836
+
837
+ if (status === 429) {
838
+ return "rate-limit";
839
+ }
840
+
841
+ if (status === 503 && code === "project_region_unavailable") {
842
+ return "unavailable";
843
+ }
844
+
845
+ if (status >= 500) {
846
+ return "server";
847
+ }
848
+
849
+ return "domain";
850
+ }
851
+
852
+ /**
853
+ * @param {number} status
854
+ * @returns {string}
855
+ */
856
+ function defaultCode(status) {
857
+ if (status === 400) {
858
+ return "bad_request";
859
+ }
860
+
861
+ if (status === 401) {
862
+ return "unauthenticated";
863
+ }
864
+
865
+ if (status === 403) {
866
+ return "forbidden";
867
+ }
868
+
869
+ if (status === 404) {
870
+ return "not_found";
871
+ }
872
+
873
+ if (status === 409) {
874
+ return "conflict";
875
+ }
876
+
877
+ if (status === 429) {
878
+ return "rate_limited";
879
+ }
880
+
881
+ return status >= 500 ? "server_error" : "domain_error";
882
+ }
883
+
884
+ /**
885
+ * @param {Headers} headers
886
+ * @returns {string | undefined}
887
+ */
888
+ function requestIdFrom(headers) {
889
+ return headers.get("x-request-id") ?? headers.get("traceparent") ?? undefined;
890
+ }
891
+
892
+ /**
893
+ * @param {unknown} value
894
+ * @returns {value is Record<string, unknown>}
895
+ */
896
+ function isRecord(value) {
897
+ return typeof value === "object" && value !== null && !Array.isArray(value);
898
+ }