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