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.
- package/LICENSE +202 -0
- package/README.md +143 -0
- package/package.json +159 -0
- package/sdk-current-contract.json +27 -0
- package/sdk-route-manifest.json +67 -0
- package/src/admin.js +9 -0
- package/src/better-auth.js +14 -0
- package/src/config.js +121 -0
- package/src/database-codec.js +845 -0
- package/src/database-types.js +237 -0
- package/src/database-view.js +422 -0
- package/src/database.js +420 -0
- package/src/discovery.js +374 -0
- package/src/http.js +887 -0
- package/src/index.js +79 -0
- package/src/local/authentication.js +47 -0
- package/src/local/better-auth.js +517 -0
- package/src/local/cli.js +51 -0
- package/src/local/context.js +23 -0
- package/src/local/environment.js +109 -0
- package/src/local/index.js +204 -0
- package/src/local/router.js +999 -0
- package/src/local/server.js +664 -0
- package/src/local/store.js +530 -0
- package/src/local/test-environment.js +74 -0
- package/src/management-contracts.js +72 -0
- package/src/management.js +12 -0
- package/src/native-origin.js +75 -0
- package/src/postgres.js +494 -0
- package/src/react/core.js +743 -0
- package/src/react/index.js +99 -0
- package/src/result.js +251 -0
- package/src/schema.js +366 -0
- package/src/sql.js +996 -0
- package/src/svelte/index.js +129 -0
- package/src/tanstack/index.js +511 -0
- package/src/ts-auth-discovery.js +190 -0
- package/src/ts-auth.js +3497 -0
- package/types/admin.d.ts +6 -0
- package/types/better-auth.d.ts +8 -0
- package/types/config.d.ts +58 -0
- package/types/database-codec.d.ts +111 -0
- package/types/database-types.d.ts +213 -0
- package/types/database-view.d.ts +183 -0
- package/types/database.d.ts +98 -0
- package/types/discovery.d.ts +114 -0
- package/types/http.d.ts +46 -0
- package/types/index.d.ts +52 -0
- package/types/local/authentication.d.ts +11 -0
- package/types/local/better-auth.d.ts +33 -0
- package/types/local/cli.d.ts +2 -0
- package/types/local/context.d.ts +14 -0
- package/types/local/environment.d.ts +23 -0
- package/types/local/index.d.ts +94 -0
- package/types/local/router.d.ts +66 -0
- package/types/local/server.d.ts +54 -0
- package/types/local/store.d.ts +106 -0
- package/types/local/test-environment.d.ts +25 -0
- package/types/management-contracts.d.ts +44 -0
- package/types/management.d.ts +6 -0
- package/types/native-origin.d.ts +23 -0
- package/types/postgres.d.ts +123 -0
- package/types/react/core.d.ts +366 -0
- package/types/react/index.d.ts +54 -0
- package/types/result.d.ts +161 -0
- package/types/schema.d.ts +145 -0
- package/types/sql.d.ts +288 -0
- package/types/svelte/index.d.ts +81 -0
- package/types/tanstack/index.d.ts +165 -0
- package/types/ts-auth-discovery.d.ts +11 -0
- 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
|
+
}
|