@layervai/qurl 0.3.0 → 0.4.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/README.md +340 -23
- package/dist/cjs/client.d.ts +180 -13
- package/dist/cjs/client.js +1126 -96
- package/dist/cjs/errors.d.ts +15 -11
- package/dist/cjs/errors.js +30 -21
- package/dist/cjs/index.d.ts +3 -3
- package/dist/cjs/index.js +4 -1
- package/dist/cjs/internal.d.ts +4 -0
- package/dist/cjs/internal.js +16 -0
- package/dist/cjs/node/deployment.d.ts +44 -0
- package/dist/cjs/node/deployment.js +167 -0
- package/dist/cjs/node/native-udp.d.ts +37 -0
- package/dist/cjs/node/native-udp.js +294 -0
- package/dist/cjs/node/nhp-wire.d.ts +35 -0
- package/dist/cjs/node/nhp-wire.js +261 -0
- package/dist/cjs/node/portal-opener.d.ts +83 -0
- package/dist/cjs/node/portal-opener.js +767 -0
- package/dist/cjs/node/qv2.d.ts +38 -0
- package/dist/cjs/node/qv2.js +292 -0
- package/dist/cjs/node/strict-json.d.ts +12 -0
- package/dist/cjs/node/strict-json.js +174 -0
- package/dist/cjs/node.d.ts +3 -0
- package/dist/cjs/node.js +11 -0
- package/dist/cjs/types.d.ts +90 -18
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/client.d.ts +180 -13
- package/dist/esm/client.d.ts.map +1 -1
- package/dist/esm/client.js +1125 -96
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/errors.d.ts +15 -11
- package/dist/esm/errors.d.ts.map +1 -1
- package/dist/esm/errors.js +29 -20
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/index.d.ts +3 -3
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +2 -2
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/internal.d.ts +5 -0
- package/dist/esm/internal.d.ts.map +1 -0
- package/dist/esm/internal.js +14 -0
- package/dist/esm/internal.js.map +1 -0
- package/dist/esm/node/deployment.d.ts +45 -0
- package/dist/esm/node/deployment.d.ts.map +1 -0
- package/dist/esm/node/deployment.js +163 -0
- package/dist/esm/node/deployment.js.map +1 -0
- package/dist/esm/node/native-udp.d.ts +38 -0
- package/dist/esm/node/native-udp.d.ts.map +1 -0
- package/dist/esm/node/native-udp.js +291 -0
- package/dist/esm/node/native-udp.js.map +1 -0
- package/dist/esm/node/nhp-wire.d.ts +36 -0
- package/dist/esm/node/nhp-wire.d.ts.map +1 -0
- package/dist/esm/node/nhp-wire.js +257 -0
- package/dist/esm/node/nhp-wire.js.map +1 -0
- package/dist/esm/node/portal-opener.d.ts +84 -0
- package/dist/esm/node/portal-opener.d.ts.map +1 -0
- package/dist/esm/node/portal-opener.js +757 -0
- package/dist/esm/node/portal-opener.js.map +1 -0
- package/dist/esm/node/qv2.d.ts +39 -0
- package/dist/esm/node/qv2.d.ts.map +1 -0
- package/dist/esm/node/qv2.js +286 -0
- package/dist/esm/node/qv2.js.map +1 -0
- package/dist/esm/node/strict-json.d.ts +13 -0
- package/dist/esm/node/strict-json.d.ts.map +1 -0
- package/dist/esm/node/strict-json.js +171 -0
- package/dist/esm/node/strict-json.js.map +1 -0
- package/dist/esm/node.d.ts +4 -0
- package/dist/esm/node.d.ts.map +1 -0
- package/dist/esm/node.js +2 -0
- package/dist/esm/node.js.map +1 -0
- package/dist/esm/types.d.ts +90 -18
- package/dist/esm/types.d.ts.map +1 -1
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +25 -6
package/dist/esm/client.js
CHANGED
|
@@ -1,8 +1,35 @@
|
|
|
1
|
-
import { createError, ERROR_CODE_CLIENT_VALIDATION, ERROR_CODE_UNEXPECTED_RESPONSE, ERROR_CODE_UNKNOWN, NetworkError, QURLError, RuntimeError, TimeoutError, ValidationError, } from "./errors.js";
|
|
1
|
+
import { createError, ERROR_CODE_AMBIGUOUS_RESOURCE, ERROR_CODE_CLIENT_VALIDATION, ERROR_CODE_RESOURCE_NOT_FOUND, ERROR_CODE_UNEXPECTED_RESPONSE, ERROR_CODE_UNKNOWN, NetworkError, NotFoundError, QURLError, RuntimeError, TimeoutError, ValidationError, } from "./errors.js";
|
|
2
|
+
import { stripTrailingSlashes } from "./internal.js";
|
|
2
3
|
import { VERSION } from "./version.js";
|
|
3
4
|
const DEFAULT_BASE_URL = "https://api.layerv.ai";
|
|
4
5
|
const DEFAULT_MAX_RETRIES = 3;
|
|
5
6
|
const DEFAULT_TIMEOUT = 30_000;
|
|
7
|
+
// Match qurl-go c4f2f98's exact API response cap. Current qurl-service list
|
|
8
|
+
// routes cap pages at 100 items. A caller can still exceed this cap with a
|
|
9
|
+
// large, heavily escaped webhook-delivery page, so list callers must request a
|
|
10
|
+
// smaller page if the service reports the body-limit error. This cap is fixed:
|
|
11
|
+
// do not add an override or widen one SDK independently from the other.
|
|
12
|
+
// The limit is enforced from Content-Length when usable and again while
|
|
13
|
+
// consuming the response stream.
|
|
14
|
+
const MAX_RESPONSE_BODY_BYTES = 1 << 20;
|
|
15
|
+
const MAX_ERROR_SNIPPET_BYTES = 512;
|
|
16
|
+
// UTF-8 uses at least one byte per UTF-16 code unit. Reading more source units
|
|
17
|
+
// cannot add a byte that fits in a 512-byte retained diagnostic.
|
|
18
|
+
const MAX_ERROR_SNIPPET_SOURCE_CODE_UNITS = MAX_ERROR_SNIPPET_BYTES;
|
|
19
|
+
const MAX_INVALID_FIELD_ENTRIES = 100;
|
|
20
|
+
const MAX_DEBUG_BODY_KEYS = 100;
|
|
21
|
+
// Bound each retained diagnostic collection as well as each string. Without
|
|
22
|
+
// this aggregate cap, 100 valid 512-byte keys and values could turn a small
|
|
23
|
+
// server error into a large caller-visible object. This is a diagnostic budget,
|
|
24
|
+
// not part of the API wire contract.
|
|
25
|
+
const MAX_DIAGNOSTIC_COLLECTION_BYTES = 8 << 10;
|
|
26
|
+
const TEXT_ENCODER = new TextEncoder();
|
|
27
|
+
const ERROR_SNIPPET_ELLIPSIS = "...";
|
|
28
|
+
const ERROR_SNIPPET_ELLIPSIS_BYTES = 3;
|
|
29
|
+
// RFC 8259 JSON exchanged between systems must be valid UTF-8. A non-fatal
|
|
30
|
+
// decoder would replace malformed bytes with U+FFFD before JSON.parse and hide
|
|
31
|
+
// the producer's exact contract violation.
|
|
32
|
+
const TEXT_DECODER = new TextDecoder("utf-8", { fatal: true });
|
|
6
33
|
const RETRY_BASE_DELAY_MS = 500;
|
|
7
34
|
// Bounds local exponential backoff (NOT server-asserted Retry-After —
|
|
8
35
|
// see `RETRY_AFTER_HARD_CAP_MS` for that).
|
|
@@ -15,13 +42,33 @@ const RETRY_AFTER_PARSE_LIMIT_S = RETRY_AFTER_HARD_CAP_MS / 1000;
|
|
|
15
42
|
const RETRYABLE_STATUS = new Set([429, 502, 503, 504]);
|
|
16
43
|
// Keep mutating status retries 429-only until the service-side idempotency
|
|
17
44
|
// cache contract is proven across 5xx, TTL, and replay-window edge cases.
|
|
18
|
-
//
|
|
45
|
+
// Fetch-level POST/PATCH retries still reuse Idempotency-Key below.
|
|
19
46
|
const RETRYABLE_STATUS_MUTATING = new Set([429]);
|
|
47
|
+
const REDIRECT_RESPONSE_STATUSES = new Set([300, 301, 302, 303, 305, 307, 308]);
|
|
48
|
+
const NO_RETRYABLE_STATUSES = new Set();
|
|
20
49
|
const IDEMPOTENCY_KEY_METHODS = new Set(["POST", "PATCH"]);
|
|
21
50
|
const MUTATING_RETRY_METHODS = new Set(["POST", "PATCH"]);
|
|
22
51
|
const MAX_IDEMPOTENCY_KEY = 256;
|
|
23
52
|
const IDEMPOTENCY_KEY_VALUE_RE = /^[\x21-\x7e](?:[\x20-\x7e]*[\x21-\x7e])?$/;
|
|
24
53
|
const UUID_HEX = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, "0"));
|
|
54
|
+
class ResponseBodyTooLargeError extends Error {
|
|
55
|
+
constructor() {
|
|
56
|
+
super(`Response body exceeds ${MAX_RESPONSE_BODY_BYTES}-byte limit`);
|
|
57
|
+
this.name = "ResponseBodyTooLargeError";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
class InvalidResponseEncodingError extends Error {
|
|
61
|
+
constructor() {
|
|
62
|
+
super("Response body is not valid UTF-8");
|
|
63
|
+
this.name = "InvalidResponseEncodingError";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
class ResponseBodyMaterializationError extends Error {
|
|
67
|
+
constructor(message = "Response-like fetch returned a value that cannot be serialized as JSON") {
|
|
68
|
+
super(message);
|
|
69
|
+
this.name = "ResponseBodyMaterializationError";
|
|
70
|
+
}
|
|
71
|
+
}
|
|
25
72
|
const NO_PASSTHROUGH_STATUSES = [];
|
|
26
73
|
const BATCH_PASSTHROUGH_STATUSES = [400];
|
|
27
74
|
/** Allowlist of known query-param keys for the `list()` endpoint. */
|
|
@@ -127,6 +174,27 @@ const CREATE_QURL_FOR_RESOURCE_FIELD_KEYS = [
|
|
|
127
174
|
"target_path",
|
|
128
175
|
];
|
|
129
176
|
assertExhaustive(true);
|
|
177
|
+
/**
|
|
178
|
+
* Options accepted by the portal-verb `protectUrl` / `createPortal`
|
|
179
|
+
* surface. These are camelCase caller-facing options (the portal verbs are
|
|
180
|
+
* the idiomatic layer); the build helpers map them onto the same wire
|
|
181
|
+
* fields the REST-shaped methods send.
|
|
182
|
+
*/
|
|
183
|
+
const PROTECT_URL_OPTION_KEYS = [
|
|
184
|
+
"description",
|
|
185
|
+
"tags",
|
|
186
|
+
"customDomain",
|
|
187
|
+
"alias",
|
|
188
|
+
];
|
|
189
|
+
assertExhaustive(true);
|
|
190
|
+
const CREATE_PORTAL_OPTION_KEYS = [
|
|
191
|
+
"validFor",
|
|
192
|
+
"label",
|
|
193
|
+
"oneTimeUse",
|
|
194
|
+
"maxSessions",
|
|
195
|
+
"sessionDuration",
|
|
196
|
+
];
|
|
197
|
+
assertExhaustive(true);
|
|
130
198
|
const UPDATE_RESOURCE_QURL_FIELD_KEYS = [
|
|
131
199
|
"extend_by",
|
|
132
200
|
"expires_at",
|
|
@@ -136,13 +204,6 @@ const UPDATE_RESOURCE_QURL_FIELD_KEYS = [
|
|
|
136
204
|
"session_duration",
|
|
137
205
|
];
|
|
138
206
|
assertExhaustive(true);
|
|
139
|
-
const AGENT_BOOTSTRAP_FIELD_KEYS = [
|
|
140
|
-
"public_key",
|
|
141
|
-
"agent_id",
|
|
142
|
-
"hostname",
|
|
143
|
-
"version",
|
|
144
|
-
];
|
|
145
|
-
assertExhaustive(true);
|
|
146
207
|
const UPDATE_CUSTOMER_FIELD_KEYS = [
|
|
147
208
|
"spending_cap_cents",
|
|
148
209
|
];
|
|
@@ -318,22 +379,329 @@ function fillRandomBytes(bytes) {
|
|
|
318
379
|
* Distinct from {@link clientValidationError} so callers can `.code`-branch
|
|
319
380
|
* between "I passed bad input locally" (`"client_validation"`) and "the
|
|
320
381
|
* server returned a body I can't interpret" (`"unexpected_response"`).
|
|
321
|
-
* Uses `status: 0` because
|
|
322
|
-
* the
|
|
382
|
+
* Uses `status: 0` by default because a logical shape mismatch is not tied to
|
|
383
|
+
* the HTTP classification. Callers that enforce an observed HTTP contract
|
|
384
|
+
* pass that status explicitly.
|
|
323
385
|
*
|
|
324
386
|
* Threads `request_id` through to {@link QURLError.requestId} when the
|
|
325
387
|
* server-side correlation ID is available — operators debugging
|
|
326
388
|
* "unexpected response" tickets need it on the *error* path too, not
|
|
327
389
|
* just on success/passthrough returns.
|
|
328
390
|
*/
|
|
329
|
-
function unexpectedResponseError(detail,
|
|
391
|
+
function unexpectedResponseError(detail, options = {}) {
|
|
392
|
+
// Every detail passed here is an SDK-authored contract message whose dynamic
|
|
393
|
+
// inputs were validated by the caller-side shape guards. Do not pass raw
|
|
394
|
+
// server strings to this helper. Server diagnostics use boundedErrorSnippet.
|
|
395
|
+
const safeRequestId = boundedErrorIdentifier(options.requestId);
|
|
330
396
|
return new ValidationError({
|
|
331
|
-
status: 0,
|
|
397
|
+
status: options.status ?? 0,
|
|
332
398
|
code: ERROR_CODE_UNEXPECTED_RESPONSE,
|
|
333
399
|
title: "Unexpected Response",
|
|
334
400
|
detail,
|
|
335
|
-
request_id,
|
|
401
|
+
request_id: safeRequestId,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
function withErrorCause(error, cause) {
|
|
405
|
+
// Match the native Error `cause` descriptor. In particular, do not leak a
|
|
406
|
+
// transport object through Object.keys(), object spread, or JSON logging.
|
|
407
|
+
Object.defineProperty(error, "cause", {
|
|
408
|
+
value: cause,
|
|
409
|
+
writable: true,
|
|
410
|
+
configurable: true,
|
|
336
411
|
});
|
|
412
|
+
return error;
|
|
413
|
+
}
|
|
414
|
+
function isIndependentAbort(error, requestSignal) {
|
|
415
|
+
return (error instanceof Error &&
|
|
416
|
+
(error.name === "AbortError" || error.name === "TimeoutError") &&
|
|
417
|
+
!requestSignal.aborted);
|
|
418
|
+
}
|
|
419
|
+
function isSdkTimeout(error, requestSignal) {
|
|
420
|
+
return (error instanceof Error &&
|
|
421
|
+
requestSignal.aborted &&
|
|
422
|
+
(error.name === "TimeoutError" || error.name === "AbortError"));
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Normalize a server-provided problem title/detail into a single-line,
|
|
426
|
+
* UTF-8-safe snippet. Error messages must stay bounded even when a proxy or
|
|
427
|
+
* compromised upstream returns very large structured problem fields.
|
|
428
|
+
*/
|
|
429
|
+
function normalizedErrorSnippet(value) {
|
|
430
|
+
if (typeof value !== "string")
|
|
431
|
+
return undefined;
|
|
432
|
+
// Bound source work before the Unicode regular expressions allocate
|
|
433
|
+
// normalized copies. The response body cap is much larger than one retained
|
|
434
|
+
// diagnostic, so processing the full server string would waste memory.
|
|
435
|
+
const normalized = value
|
|
436
|
+
.slice(0, MAX_ERROR_SNIPPET_SOURCE_CODE_UNITS)
|
|
437
|
+
// Strip control characters plus bidi override/isolate controls that can
|
|
438
|
+
// visually reorder diagnostics. Preserve other formatting characters
|
|
439
|
+
// such as ZWJ/ZWNJ, which are required by legitimate scripts and emoji.
|
|
440
|
+
.replace(/[\p{Cc}\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/gu, " ")
|
|
441
|
+
.trim()
|
|
442
|
+
.replace(/\s+/g, " ");
|
|
443
|
+
return normalized === "" ? undefined : normalized;
|
|
444
|
+
}
|
|
445
|
+
function boundedErrorSnippet(value) {
|
|
446
|
+
const sourceTruncated = typeof value === "string" && value.length > MAX_ERROR_SNIPPET_SOURCE_CODE_UNITS;
|
|
447
|
+
const normalized = normalizedErrorSnippet(value);
|
|
448
|
+
if (normalized === undefined)
|
|
449
|
+
return undefined;
|
|
450
|
+
const contentLimit = sourceTruncated
|
|
451
|
+
? MAX_ERROR_SNIPPET_BYTES - ERROR_SNIPPET_ELLIPSIS_BYTES
|
|
452
|
+
: MAX_ERROR_SNIPPET_BYTES;
|
|
453
|
+
// UTF-8 uses at most three bytes per UTF-16 code unit. Most diagnostics are
|
|
454
|
+
// short ASCII, so avoid an allocation only to prove they fit.
|
|
455
|
+
if (normalized.length * 3 <= contentLimit) {
|
|
456
|
+
return sourceTruncated ? `${normalized}${ERROR_SNIPPET_ELLIPSIS}` : normalized;
|
|
457
|
+
}
|
|
458
|
+
const encoded = TEXT_ENCODER.encode(normalized);
|
|
459
|
+
if (encoded.byteLength <= contentLimit) {
|
|
460
|
+
return sourceTruncated ? `${normalized}${ERROR_SNIPPET_ELLIPSIS}` : normalized;
|
|
461
|
+
}
|
|
462
|
+
let end = MAX_ERROR_SNIPPET_BYTES - ERROR_SNIPPET_ELLIPSIS_BYTES;
|
|
463
|
+
// Do not split a multi-byte UTF-8 sequence at the cap.
|
|
464
|
+
while (end > 0 && (encoded[end] & 0xc0) === 0x80)
|
|
465
|
+
end--;
|
|
466
|
+
return `${TEXT_DECODER.decode(encoded.subarray(0, end))}${ERROR_SNIPPET_ELLIPSIS}`;
|
|
467
|
+
}
|
|
468
|
+
/** Keep server identifiers useful as exact machine-readable discriminants. */
|
|
469
|
+
function boundedErrorIdentifier(value) {
|
|
470
|
+
if (typeof value !== "string" || value.length > MAX_ERROR_SNIPPET_SOURCE_CODE_UNITS) {
|
|
471
|
+
return undefined;
|
|
472
|
+
}
|
|
473
|
+
const normalized = normalizedErrorSnippet(value);
|
|
474
|
+
if (normalized === undefined ||
|
|
475
|
+
normalized !== value ||
|
|
476
|
+
TEXT_ENCODER.encode(value).byteLength > MAX_ERROR_SNIPPET_BYTES) {
|
|
477
|
+
return undefined;
|
|
478
|
+
}
|
|
479
|
+
return value;
|
|
480
|
+
}
|
|
481
|
+
/** Collect at most the first N enumerable own string keys without a full key-array copy. */
|
|
482
|
+
function firstEnumerableOwnKeys(value, limit) {
|
|
483
|
+
const keys = [];
|
|
484
|
+
for (const key in value) {
|
|
485
|
+
if (!Object.prototype.hasOwnProperty.call(value, key))
|
|
486
|
+
continue;
|
|
487
|
+
keys.push(key);
|
|
488
|
+
if (keys.length === limit)
|
|
489
|
+
break;
|
|
490
|
+
}
|
|
491
|
+
return keys;
|
|
492
|
+
}
|
|
493
|
+
/** Bound the structured per-field diagnostics exposed on ValidationError. */
|
|
494
|
+
function boundedInvalidFields(value) {
|
|
495
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
496
|
+
return undefined;
|
|
497
|
+
const entries = [];
|
|
498
|
+
const retainedKeys = new Set();
|
|
499
|
+
let retainedBytes = 0;
|
|
500
|
+
// Bound inspected entries as well as retained entries. A hostile envelope
|
|
501
|
+
// whose values all have the wrong type must not make diagnostics scan every
|
|
502
|
+
// key in the response body.
|
|
503
|
+
for (const rawKey of firstEnumerableOwnKeys(value, MAX_INVALID_FIELD_ENTRIES)) {
|
|
504
|
+
const rawValue = value[rawKey];
|
|
505
|
+
const key = boundedErrorSnippet(rawKey);
|
|
506
|
+
const fieldDetail = boundedErrorSnippet(rawValue);
|
|
507
|
+
if (key !== undefined && fieldDetail !== undefined && !retainedKeys.has(key)) {
|
|
508
|
+
const entryBytes = TEXT_ENCODER.encode(key).byteLength + TEXT_ENCODER.encode(fieldDetail).byteLength;
|
|
509
|
+
// A later, smaller entry can still fit even when this one cannot.
|
|
510
|
+
if (retainedBytes + entryBytes > MAX_DIAGNOSTIC_COLLECTION_BYTES)
|
|
511
|
+
continue;
|
|
512
|
+
retainedKeys.add(key);
|
|
513
|
+
entries.push([key, fieldDetail]);
|
|
514
|
+
retainedBytes += entryBytes;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
|
518
|
+
}
|
|
519
|
+
/** Bound server-controlled object keys before forwarding them to a debug sink. */
|
|
520
|
+
function boundedObjectKeys(value) {
|
|
521
|
+
// Stop before normalization so a hostile envelope cannot force work over
|
|
522
|
+
// every enumerable own key. Some early keys may normalize away; bounded
|
|
523
|
+
// diagnostics are more important here than filling every available slot.
|
|
524
|
+
const retained = [];
|
|
525
|
+
let retainedBytes = 0;
|
|
526
|
+
for (const rawKey of firstEnumerableOwnKeys(value, MAX_DEBUG_BODY_KEYS)) {
|
|
527
|
+
const key = boundedErrorSnippet(rawKey);
|
|
528
|
+
if (key === undefined)
|
|
529
|
+
continue;
|
|
530
|
+
const keyBytes = TEXT_ENCODER.encode(key).byteLength;
|
|
531
|
+
// A later, smaller key can still fit even when this one cannot.
|
|
532
|
+
if (retainedBytes + keyBytes > MAX_DIAGNOSTIC_COLLECTION_BYTES)
|
|
533
|
+
continue;
|
|
534
|
+
retained.push(key);
|
|
535
|
+
retainedBytes += keyBytes;
|
|
536
|
+
}
|
|
537
|
+
return retained;
|
|
538
|
+
}
|
|
539
|
+
async function cancelResponseBody(body) {
|
|
540
|
+
if (!body)
|
|
541
|
+
return;
|
|
542
|
+
try {
|
|
543
|
+
await body.cancel();
|
|
544
|
+
}
|
|
545
|
+
catch {
|
|
546
|
+
// Best-effort resource cleanup must not replace the deterministic SDK error.
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
function materializedResponseBodyExceedsLimit(value) {
|
|
550
|
+
if (value.length > MAX_RESPONSE_BODY_BYTES)
|
|
551
|
+
return true;
|
|
552
|
+
// Encoding creates another buffer. UTF-8 uses at most three bytes per
|
|
553
|
+
// UTF-16 code unit, so shorter strings are provably safe without that copy.
|
|
554
|
+
return (value.length * 3 > MAX_RESPONSE_BODY_BYTES &&
|
|
555
|
+
TEXT_ENCODER.encode(value).byteLength > MAX_RESPONSE_BODY_BYTES);
|
|
556
|
+
}
|
|
557
|
+
function contentLengthExceedsLimit(response) {
|
|
558
|
+
// 204/205 never carry content, and a 304 Content-Length describes the
|
|
559
|
+
// selected representation rather than a response body. Do not reject those
|
|
560
|
+
// statuses for metadata about bytes that fetch will not deliver.
|
|
561
|
+
if (response.status === 204 || response.status === 205 || response.status === 304)
|
|
562
|
+
return false;
|
|
563
|
+
const value = response.headers.get("content-length");
|
|
564
|
+
if (value === null || !/^\d+$/.test(value.trim()))
|
|
565
|
+
return false;
|
|
566
|
+
return Number(value) > MAX_RESPONSE_BODY_BYTES;
|
|
567
|
+
}
|
|
568
|
+
/** Detect a followed redirect from fetch implementations that expose only the final URL. */
|
|
569
|
+
function responseUrlState(responseUrl, requestUrl) {
|
|
570
|
+
if (typeof responseUrl !== "string" || responseUrl === "")
|
|
571
|
+
return "same";
|
|
572
|
+
if (responseUrl === requestUrl)
|
|
573
|
+
return "same";
|
|
574
|
+
try {
|
|
575
|
+
// URL serialization normalizes equivalent spellings such as host case and
|
|
576
|
+
// an explicit default port, so they do not create false redirect reports.
|
|
577
|
+
return new URL(responseUrl).href === new URL(requestUrl).href ? "same" : "changed";
|
|
578
|
+
}
|
|
579
|
+
catch {
|
|
580
|
+
// A non-empty invalid response URL cannot prove that credentials stayed on
|
|
581
|
+
// the requested endpoint. Fail closed without reflecting it in diagnostics.
|
|
582
|
+
return "invalid";
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Read a response body while retaining accepted chunks up to the documented
|
|
587
|
+
* cap. A fetch implementation can still hand us one arbitrarily large,
|
|
588
|
+
* already-materialized chunk before we can reject it. For a compliant body,
|
|
589
|
+
* final assembly transiently holds the chunks plus the combined output buffer
|
|
590
|
+
* (roughly twice the cap) before decoding. Content-Length is an early-rejection
|
|
591
|
+
* optimization only; streaming byte accounting remains authoritative because
|
|
592
|
+
* that header may be absent or false.
|
|
593
|
+
*/
|
|
594
|
+
async function readBoundedResponseBody(response) {
|
|
595
|
+
// `headers.get` is part of the documented injected-fetch contract. Detect a
|
|
596
|
+
// malformed local shim before it can be mistaken for a retryable body-stream
|
|
597
|
+
// failure and replay an otherwise deterministic GET programming error.
|
|
598
|
+
if (typeof response.headers?.get !== "function") {
|
|
599
|
+
throw new ResponseBodyMaterializationError("Response-like fetch must provide headers.get(name)");
|
|
600
|
+
}
|
|
601
|
+
if (contentLengthExceedsLimit(response)) {
|
|
602
|
+
await cancelResponseBody(response.body);
|
|
603
|
+
throw new ResponseBodyTooLargeError();
|
|
604
|
+
}
|
|
605
|
+
const body = response.body;
|
|
606
|
+
if (body === null)
|
|
607
|
+
return "";
|
|
608
|
+
// Standards-compliant fetch implementations always expose a ReadableStream
|
|
609
|
+
// for a non-empty body. Retain compatibility with Response-like test/custom
|
|
610
|
+
// fetch implementations while still checking their materialized text before
|
|
611
|
+
// this SDK parses it as JSON.
|
|
612
|
+
if (body === undefined || typeof body.getReader !== "function") {
|
|
613
|
+
let materializedText = "";
|
|
614
|
+
if (typeof response.text === "function") {
|
|
615
|
+
try {
|
|
616
|
+
materializedText = await response.text();
|
|
617
|
+
}
|
|
618
|
+
catch {
|
|
619
|
+
// Native fetch exposes a body stream above. A failing text() here is a
|
|
620
|
+
// deterministic custom Response contract error, not a retryable reset.
|
|
621
|
+
throw new ResponseBodyMaterializationError("Response-like fetch text() failed");
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
const text = typeof materializedText === "string" ? materializedText : "";
|
|
625
|
+
if (materializedResponseBodyExceedsLimit(text)) {
|
|
626
|
+
throw new ResponseBodyTooLargeError();
|
|
627
|
+
}
|
|
628
|
+
if (text !== "" ||
|
|
629
|
+
response.status === 204 ||
|
|
630
|
+
response.status === 205 ||
|
|
631
|
+
response.status === 304) {
|
|
632
|
+
return text;
|
|
633
|
+
}
|
|
634
|
+
// Older injected Response-like objects may implement json() but return an
|
|
635
|
+
// empty placeholder from text(). This path is never used by native fetch.
|
|
636
|
+
let parsed;
|
|
637
|
+
try {
|
|
638
|
+
parsed = await response.json();
|
|
639
|
+
}
|
|
640
|
+
catch {
|
|
641
|
+
// A Response-like shim may expose json() but reject on an empty 204
|
|
642
|
+
// body. Native fetch represents that body as null and returns above.
|
|
643
|
+
return "";
|
|
644
|
+
}
|
|
645
|
+
let serialized;
|
|
646
|
+
try {
|
|
647
|
+
serialized = JSON.stringify(parsed);
|
|
648
|
+
}
|
|
649
|
+
catch {
|
|
650
|
+
throw new ResponseBodyMaterializationError();
|
|
651
|
+
}
|
|
652
|
+
if (serialized === undefined)
|
|
653
|
+
return "";
|
|
654
|
+
if (materializedResponseBodyExceedsLimit(serialized)) {
|
|
655
|
+
throw new ResponseBodyTooLargeError();
|
|
656
|
+
}
|
|
657
|
+
return serialized;
|
|
658
|
+
}
|
|
659
|
+
const reader = body.getReader();
|
|
660
|
+
const chunks = [];
|
|
661
|
+
let total = 0;
|
|
662
|
+
try {
|
|
663
|
+
while (true) {
|
|
664
|
+
const { done, value } = await reader.read();
|
|
665
|
+
if (done)
|
|
666
|
+
break;
|
|
667
|
+
if (!(value instanceof Uint8Array)) {
|
|
668
|
+
throw new ResponseBodyMaterializationError("Response-like fetch body stream must yield Uint8Array chunks");
|
|
669
|
+
}
|
|
670
|
+
const nextTotal = total + value.byteLength;
|
|
671
|
+
if (nextTotal > MAX_RESPONSE_BODY_BYTES) {
|
|
672
|
+
try {
|
|
673
|
+
await reader.cancel();
|
|
674
|
+
}
|
|
675
|
+
catch {
|
|
676
|
+
// Preserve the body-limit failure even if stream cancellation fails.
|
|
677
|
+
}
|
|
678
|
+
throw new ResponseBodyTooLargeError();
|
|
679
|
+
}
|
|
680
|
+
chunks.push(value);
|
|
681
|
+
total = nextTotal;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
finally {
|
|
685
|
+
reader.releaseLock();
|
|
686
|
+
}
|
|
687
|
+
let bytes;
|
|
688
|
+
if (chunks.length === 1) {
|
|
689
|
+
bytes = chunks[0];
|
|
690
|
+
}
|
|
691
|
+
else {
|
|
692
|
+
bytes = new Uint8Array(total);
|
|
693
|
+
let offset = 0;
|
|
694
|
+
for (const chunk of chunks) {
|
|
695
|
+
bytes.set(chunk, offset);
|
|
696
|
+
offset += chunk.byteLength;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
try {
|
|
700
|
+
return TEXT_DECODER.decode(bytes);
|
|
701
|
+
}
|
|
702
|
+
catch {
|
|
703
|
+
throw new InvalidResponseEncodingError();
|
|
704
|
+
}
|
|
337
705
|
}
|
|
338
706
|
// ---- Spec-derived validation helpers ------------------------------------
|
|
339
707
|
// These mirror constraints documented on each request schema in
|
|
@@ -376,11 +744,11 @@ function requireNonEmptyIfPresent(value, field) {
|
|
|
376
744
|
throw clientValidationError(`${field}: must not be an empty string`);
|
|
377
745
|
}
|
|
378
746
|
}
|
|
379
|
-
function requireMaxSessionsInRange(value) {
|
|
747
|
+
function requireMaxSessionsInRange(value, field = "max_sessions") {
|
|
380
748
|
if (value === undefined)
|
|
381
749
|
return;
|
|
382
750
|
if (!Number.isInteger(value) || value < 0 || value > MAX_MAX_SESSIONS) {
|
|
383
|
-
throw clientValidationError(
|
|
751
|
+
throw clientValidationError(`${field}: must be an integer between 0 and ${MAX_MAX_SESSIONS} (got ${value})`);
|
|
384
752
|
}
|
|
385
753
|
}
|
|
386
754
|
function requireBooleanIfPresent(value, field) {
|
|
@@ -887,6 +1255,199 @@ function batchItemResultValidationReason(entry) {
|
|
|
887
1255
|
}
|
|
888
1256
|
return "missing/invalid 'success' discriminant (expected boolean)";
|
|
889
1257
|
}
|
|
1258
|
+
// ---- Portal-verb helpers (mirrors qurl-go) -------------------------------
|
|
1259
|
+
// Client-side guardrails for the portal flow (protectUrl → createPortal →
|
|
1260
|
+
// enterPortal), kept in lockstep with qurl-go's option validation and
|
|
1261
|
+
// qurl-python's port of it.
|
|
1262
|
+
/**
|
|
1263
|
+
* Client-side floor for portal lifetimes, mirroring qurl-go's `ValidFor`.
|
|
1264
|
+
* The LayerV API remains the source of truth for account limits; there is
|
|
1265
|
+
* intentionally no SDK-side maximum because an account may allow
|
|
1266
|
+
* longer-lived portals.
|
|
1267
|
+
*/
|
|
1268
|
+
const MIN_PORTAL_VALID_FOR_SECONDS = 60;
|
|
1269
|
+
/**
|
|
1270
|
+
* Client-side floor for session durations, mirroring qurl-go's
|
|
1271
|
+
* `WithSessionDuration` (at least one second; zero is rejected rather than
|
|
1272
|
+
* treated as the server default).
|
|
1273
|
+
*/
|
|
1274
|
+
const MIN_SESSION_DURATION_SECONDS = 1;
|
|
1275
|
+
/**
|
|
1276
|
+
* Shape of qurl-go's offline signed-fragment links: three or more
|
|
1277
|
+
* dot-separated base64url parts (<version>.<claims>.<secret>.<sig>).
|
|
1278
|
+
* Detected only to give those links a precise error in extractAccessToken.
|
|
1279
|
+
*
|
|
1280
|
+
* @internal Exported for in-package tests; not re-exported from index.ts.
|
|
1281
|
+
*/
|
|
1282
|
+
export const SIGNED_FRAGMENT_RE = /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+){2,}$/;
|
|
1283
|
+
// Access-token grammar accepted by enterPortal (base64url charset).
|
|
1284
|
+
const ACCESS_TOKEN_RE = /^[A-Za-z0-9_-]+$/;
|
|
1285
|
+
/**
|
|
1286
|
+
* Serialize a duration option to the API's h/m/s duration grammar.
|
|
1287
|
+
*
|
|
1288
|
+
* Strings pass through unvalidated — the server is authoritative, same as
|
|
1289
|
+
* the REST-shaped `expires_in` fields. Numbers are milliseconds and get
|
|
1290
|
+
* qurl-go's client-side guardrails: whole seconds only and at least
|
|
1291
|
+
* `minimumSeconds` (checked in that order so e.g. 30.5s reports "at least
|
|
1292
|
+
* 60s" in every SDK). Serialized with hours as the largest unit so all API
|
|
1293
|
+
* duration fields use the same grammar across SDKs.
|
|
1294
|
+
*/
|
|
1295
|
+
function serializeApiDuration(value, field, minimumSeconds) {
|
|
1296
|
+
if (value === undefined)
|
|
1297
|
+
return undefined;
|
|
1298
|
+
if (typeof value === "string") {
|
|
1299
|
+
if (value.trim() === "") {
|
|
1300
|
+
throw clientValidationError(`${field}: must be a non-empty duration string`);
|
|
1301
|
+
}
|
|
1302
|
+
return value;
|
|
1303
|
+
}
|
|
1304
|
+
if (typeof value === "number") {
|
|
1305
|
+
// NaN fails no comparison, so an explicit finite check keeps the
|
|
1306
|
+
// error actionable instead of a misleading "whole seconds" report.
|
|
1307
|
+
if (!Number.isFinite(value)) {
|
|
1308
|
+
throw clientValidationError(`${field}: must be a finite number of milliseconds`);
|
|
1309
|
+
}
|
|
1310
|
+
if (value < minimumSeconds * 1000) {
|
|
1311
|
+
throw clientValidationError(`${field}: must be at least ${minimumSeconds}s`);
|
|
1312
|
+
}
|
|
1313
|
+
if (value % 1000 !== 0) {
|
|
1314
|
+
throw clientValidationError(`${field}: must be whole seconds`);
|
|
1315
|
+
}
|
|
1316
|
+
const seconds = value / 1000;
|
|
1317
|
+
if (seconds % 3600 === 0)
|
|
1318
|
+
return `${seconds / 3600}h`;
|
|
1319
|
+
if (seconds % 60 === 0)
|
|
1320
|
+
return `${seconds / 60}m`;
|
|
1321
|
+
return `${seconds}s`;
|
|
1322
|
+
}
|
|
1323
|
+
throw clientValidationError(`${field}: must be a duration string (e.g. "5m") or a number of milliseconds`);
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* Portal-verb target URL validation (qurl-go `ProtectURL` parity). On top of
|
|
1327
|
+
* the shared scheme/length check, the URL must carry a host and must parse,
|
|
1328
|
+
* and embedded credentials (userinfo) are rejected. The added checks never
|
|
1329
|
+
* echo the URL — a userinfo URL contains credentials that must stay out of
|
|
1330
|
+
* logs and error messages. The REST-shaped compatibility layer keeps its
|
|
1331
|
+
* original prefix-only check so its accepted inputs don't change.
|
|
1332
|
+
*/
|
|
1333
|
+
function requirePortalTargetUrl(targetUrl) {
|
|
1334
|
+
requireValidTargetUrl(targetUrl);
|
|
1335
|
+
const url = targetUrl; // requireValidTargetUrl only returns for http(s) strings
|
|
1336
|
+
// WHATWG URL normalizes away an empty authority ("https:///x" parses
|
|
1337
|
+
// with host "x"), so the host-presence check runs on the raw string,
|
|
1338
|
+
// mirroring qurl-go's url.Parse which rejects authority-less URLs.
|
|
1339
|
+
const afterScheme = url.slice(url.indexOf("://") + 3);
|
|
1340
|
+
if (afterScheme === "" || "/?#".includes(afterScheme[0])) {
|
|
1341
|
+
throw clientValidationError("target_url: must include a host");
|
|
1342
|
+
}
|
|
1343
|
+
let parsed;
|
|
1344
|
+
try {
|
|
1345
|
+
parsed = new URL(url);
|
|
1346
|
+
}
|
|
1347
|
+
catch {
|
|
1348
|
+
throw clientValidationError("target_url: malformed URL");
|
|
1349
|
+
}
|
|
1350
|
+
if (parsed.username !== "" || parsed.password !== "") {
|
|
1351
|
+
throw clientValidationError("target_url: must not include embedded credentials (userinfo)");
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
/**
|
|
1355
|
+
* Extract the access token from a qURL link, or pass a bare token through.
|
|
1356
|
+
*
|
|
1357
|
+
* Platform-issued links carry the access token in the URL fragment
|
|
1358
|
+
* (`https://qurl.link/#at_...`). Error messages deliberately never echo the
|
|
1359
|
+
* input — qURL links are credentials and must stay out of logs.
|
|
1360
|
+
*/
|
|
1361
|
+
function extractAccessToken(qurlLink) {
|
|
1362
|
+
if (typeof qurlLink !== "string" || qurlLink.trim() === "") {
|
|
1363
|
+
throw clientValidationError("qurlLink: must be a non-empty string");
|
|
1364
|
+
}
|
|
1365
|
+
const hashIndex = qurlLink.indexOf("#");
|
|
1366
|
+
const token = hashIndex >= 0 ? qurlLink.slice(hashIndex + 1) : qurlLink;
|
|
1367
|
+
if (SIGNED_FRAGMENT_RE.test(token)) {
|
|
1368
|
+
throw clientValidationError("qurlLink: this is a signed qURL link (offline fragment format), which cannot be " +
|
|
1369
|
+
"opened through the API resolver — enterPortal only accepts platform links " +
|
|
1370
|
+
"(https://qurl.link/#at_...) or bare access tokens");
|
|
1371
|
+
}
|
|
1372
|
+
if (token === "" || !ACCESS_TOKEN_RE.test(token)) {
|
|
1373
|
+
throw clientValidationError("qurlLink: no access token found — pass a platform qURL link " +
|
|
1374
|
+
"(https://qurl.link/#at_...) or a bare access token");
|
|
1375
|
+
}
|
|
1376
|
+
return token;
|
|
1377
|
+
}
|
|
1378
|
+
/**
|
|
1379
|
+
* Parse an RFC 3339 timestamp from the API into a `Date`, or `undefined`
|
|
1380
|
+
* when absent or unparseable. The portal surface exposes `Date` (matching
|
|
1381
|
+
* qurl-go's `time.Time` and qurl-python's `datetime`); the REST-shaped
|
|
1382
|
+
* layer keeps raw wire strings.
|
|
1383
|
+
*/
|
|
1384
|
+
function parseApiDate(value) {
|
|
1385
|
+
if (typeof value !== "string" || value === "")
|
|
1386
|
+
return undefined;
|
|
1387
|
+
const parsed = new Date(value);
|
|
1388
|
+
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
|
|
1389
|
+
}
|
|
1390
|
+
/**
|
|
1391
|
+
* Parse a {@link Portal} from a create-portal API response. `resource_id`
|
|
1392
|
+
* and `qurl_link` are required, mirroring qurl-go's fail-closed response
|
|
1393
|
+
* check; everything else is optional because the two mint endpoints differ
|
|
1394
|
+
* in which ancillary fields they return.
|
|
1395
|
+
*/
|
|
1396
|
+
function parsePortal(data, method) {
|
|
1397
|
+
if (typeof data?.resource_id !== "string" || data.resource_id.trim() === "") {
|
|
1398
|
+
throw unexpectedResponseError(`${method}: response is missing resource_id`);
|
|
1399
|
+
}
|
|
1400
|
+
if (typeof data.qurl_link !== "string" || data.qurl_link.trim() === "") {
|
|
1401
|
+
throw unexpectedResponseError(`${method}: response is missing qurl_link`);
|
|
1402
|
+
}
|
|
1403
|
+
return {
|
|
1404
|
+
resourceId: data.resource_id,
|
|
1405
|
+
link: data.qurl_link,
|
|
1406
|
+
site: data.qurl_site,
|
|
1407
|
+
expiresAt: parseApiDate(data.expires_at),
|
|
1408
|
+
// Same empty-string → absent normalization the REST-shaped layer's
|
|
1409
|
+
// consumers apply to qurl_id.
|
|
1410
|
+
qurlId: data.qurl_id || undefined,
|
|
1411
|
+
label: data.label,
|
|
1412
|
+
};
|
|
1413
|
+
}
|
|
1414
|
+
/**
|
|
1415
|
+
* Validate portal options and build the create-portal wire body, or
|
|
1416
|
+
* `undefined` when every option is omitted so no request body is sent and
|
|
1417
|
+
* the server applies its default lifetime (matching mintLink /
|
|
1418
|
+
* createQurlForResource).
|
|
1419
|
+
*/
|
|
1420
|
+
function buildCreatePortalBody(opts, method) {
|
|
1421
|
+
requireObjectInput(opts, method);
|
|
1422
|
+
requireNoUnknownFields(opts, CREATE_PORTAL_OPTION_KEYS, method);
|
|
1423
|
+
// null → omitted, via the SDK-wide normalizePatchFields convention.
|
|
1424
|
+
const { validFor, label, oneTimeUse, maxSessions, sessionDuration } = normalizePatchFields(opts, CREATE_PORTAL_OPTION_KEYS);
|
|
1425
|
+
if (label !== undefined) {
|
|
1426
|
+
requireMaxLength(label, "label", MAX_LABEL);
|
|
1427
|
+
// qurl-go's WithLabel rejects blank labels; the REST-shaped mintLink
|
|
1428
|
+
// keeps its permissive whitespace behavior, so the check lives here.
|
|
1429
|
+
if (label.trim() === "") {
|
|
1430
|
+
throw clientValidationError("label: must not be empty");
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
requireBooleanIfPresent(oneTimeUse, "oneTimeUse");
|
|
1434
|
+
requireMaxSessionsInRange(maxSessions, "maxSessions");
|
|
1435
|
+
const body = {};
|
|
1436
|
+
const expiresIn = serializeApiDuration(validFor, "validFor", MIN_PORTAL_VALID_FOR_SECONDS);
|
|
1437
|
+
if (expiresIn !== undefined)
|
|
1438
|
+
body.expires_in = expiresIn;
|
|
1439
|
+
if (label !== undefined)
|
|
1440
|
+
body.label = label;
|
|
1441
|
+
if (oneTimeUse !== undefined)
|
|
1442
|
+
body.one_time_use = oneTimeUse;
|
|
1443
|
+
// Explicit 0 means unlimited and must survive body construction.
|
|
1444
|
+
if (maxSessions !== undefined)
|
|
1445
|
+
body.max_sessions = maxSessions;
|
|
1446
|
+
const wireSessionDuration = serializeApiDuration(sessionDuration, "sessionDuration", MIN_SESSION_DURATION_SECONDS);
|
|
1447
|
+
if (wireSessionDuration !== undefined)
|
|
1448
|
+
body.session_duration = wireSessionDuration;
|
|
1449
|
+
return Object.keys(body).length > 0 ? body : undefined;
|
|
1450
|
+
}
|
|
890
1451
|
/** qURL API client. */
|
|
891
1452
|
export class QURLClient {
|
|
892
1453
|
baseUrl;
|
|
@@ -907,7 +1468,7 @@ export class QURLClient {
|
|
|
907
1468
|
throw clientValidationError("apiKey: must not contain CR/LF characters");
|
|
908
1469
|
}
|
|
909
1470
|
this.apiKey = options.apiKey;
|
|
910
|
-
const rawBaseUrl = (options.baseUrl ?? DEFAULT_BASE_URL)
|
|
1471
|
+
const rawBaseUrl = stripTrailingSlashes(options.baseUrl ?? DEFAULT_BASE_URL);
|
|
911
1472
|
// Parse the URL so the loopback exemption matches on the hostname,
|
|
912
1473
|
// not a string prefix. `startsWith("http://localhost")` would
|
|
913
1474
|
// accept `http://localhost.attacker.com` and silently send the
|
|
@@ -1062,7 +1623,7 @@ export class QURLClient {
|
|
|
1062
1623
|
// .requestId property AND the message string so a stack trace
|
|
1063
1624
|
// pasted into a support ticket carries the correlation handle
|
|
1064
1625
|
// without a follow-up round-trip.
|
|
1065
|
-
const requestId = envelope.meta?.request_id;
|
|
1626
|
+
const requestId = boundedErrorIdentifier(envelope.meta?.request_id);
|
|
1066
1627
|
const requestIdSuffix = requestId !== undefined ? ` [request_id=${requestId}]` : "";
|
|
1067
1628
|
// `Number.isInteger` rejects NaN, Infinity, and floats. Combined
|
|
1068
1629
|
// with `>= 0`, this rules out pathological proxy bodies (e.g.
|
|
@@ -1075,12 +1636,12 @@ export class QURLClient {
|
|
|
1075
1636
|
!Number.isInteger(result.failed) ||
|
|
1076
1637
|
result.failed < 0 ||
|
|
1077
1638
|
!Array.isArray(result.results)) {
|
|
1078
|
-
throw unexpectedResponseError(`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}${requestIdSuffix}`, requestId);
|
|
1639
|
+
throw unexpectedResponseError(`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}${requestIdSuffix}`, { requestId });
|
|
1079
1640
|
}
|
|
1080
1641
|
if (result.succeeded + result.failed !== result.results.length) {
|
|
1081
1642
|
throw unexpectedResponseError(`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}: ` +
|
|
1082
1643
|
`counts/results length mismatch (succeeded=${result.succeeded}, ` +
|
|
1083
|
-
`failed=${result.failed}, results.length=${result.results.length})${requestIdSuffix}`, requestId);
|
|
1644
|
+
`failed=${result.failed}, results.length=${result.results.length})${requestIdSuffix}`, { requestId });
|
|
1084
1645
|
}
|
|
1085
1646
|
// OpenAPI contract: one result per input item. Without this check, a
|
|
1086
1647
|
// server that drops items (e.g. sends 2 results for 3 inputs with
|
|
@@ -1091,7 +1652,7 @@ export class QURLClient {
|
|
|
1091
1652
|
// exactly [0, requestItemCount).
|
|
1092
1653
|
if (result.results.length !== requestItemCount) {
|
|
1093
1654
|
throw unexpectedResponseError(`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}: ` +
|
|
1094
|
-
`results.length (${result.results.length}) does not match request item count (${requestItemCount})${requestIdSuffix}`, requestId);
|
|
1655
|
+
`results.length (${result.results.length}) does not match request item count (${requestItemCount})${requestIdSuffix}`, { requestId });
|
|
1095
1656
|
}
|
|
1096
1657
|
// Per-entry discriminated-union contract. Reason strings report
|
|
1097
1658
|
// field NAMES only, never values — safe for observability pipelines.
|
|
@@ -1103,14 +1664,14 @@ export class QURLClient {
|
|
|
1103
1664
|
for (let i = 0; i < result.results.length; i++) {
|
|
1104
1665
|
const reason = batchItemResultValidationReason(result.results[i]);
|
|
1105
1666
|
if (reason !== null) {
|
|
1106
|
-
throw unexpectedResponseError(`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}: results[${i}] ${reason}${requestIdSuffix}`, requestId);
|
|
1667
|
+
throw unexpectedResponseError(`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}: results[${i}] ${reason}${requestIdSuffix}`, { requestId });
|
|
1107
1668
|
}
|
|
1108
1669
|
const entry = result.results[i];
|
|
1109
1670
|
if (entry.index >= requestItemCount) {
|
|
1110
|
-
throw unexpectedResponseError(`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}: results[${i}] index out of range (sent ${requestItemCount} items)${requestIdSuffix}`, requestId);
|
|
1671
|
+
throw unexpectedResponseError(`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}: results[${i}] index out of range (sent ${requestItemCount} items)${requestIdSuffix}`, { requestId });
|
|
1111
1672
|
}
|
|
1112
1673
|
if (seenIndices.has(entry.index)) {
|
|
1113
|
-
throw unexpectedResponseError(`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}: results[${i}] duplicate index${requestIdSuffix}`, requestId);
|
|
1674
|
+
throw unexpectedResponseError(`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}: results[${i}] duplicate index${requestIdSuffix}`, { requestId });
|
|
1114
1675
|
}
|
|
1115
1676
|
seenIndices.add(entry.index);
|
|
1116
1677
|
}
|
|
@@ -1136,16 +1697,222 @@ export class QURLClient {
|
|
|
1136
1697
|
break;
|
|
1137
1698
|
}
|
|
1138
1699
|
if (seenCursors.has(cursor)) {
|
|
1139
|
-
throw unexpectedResponseError(`${methodName}: server returned repeated cursor after ${pageCount} auto-pagination pages`, page.request_id);
|
|
1700
|
+
throw unexpectedResponseError(`${methodName}: server returned repeated cursor after ${pageCount} auto-pagination pages`, { requestId: page.request_id });
|
|
1140
1701
|
}
|
|
1141
1702
|
seenCursors.add(cursor);
|
|
1142
1703
|
if (pageCount >= MAX_AUTO_PAGINATION_PAGES) {
|
|
1143
|
-
throw unexpectedResponseError(`${methodName}: exceeded ${MAX_AUTO_PAGINATION_PAGES} auto-pagination pages without termination`, page.request_id);
|
|
1704
|
+
throw unexpectedResponseError(`${methodName}: exceeded ${MAX_AUTO_PAGINATION_PAGES} auto-pagination pages without termination`, { requestId: page.request_id });
|
|
1144
1705
|
}
|
|
1145
1706
|
} while (cursor);
|
|
1146
1707
|
}
|
|
1708
|
+
// --- Portal API (mirrors qurl-go: ProtectURL → CreatePortal → EnterPortal) ---
|
|
1709
|
+
/**
|
|
1710
|
+
* Protect a private URL — create or reuse its LayerV resource.
|
|
1711
|
+
*
|
|
1712
|
+
* The first step of the portal flow (qurl-go: `Client.ProtectURL`).
|
|
1713
|
+
* Protect the URL once, then mint short-lived links for it with
|
|
1714
|
+
* {@link ProtectedResource.createPortal}:
|
|
1715
|
+
*
|
|
1716
|
+
* ```ts
|
|
1717
|
+
* const resource = await client.protectUrl("https://internal.example.com/dashboard");
|
|
1718
|
+
* const portal = await resource.createPortal({ validFor: "5m" });
|
|
1719
|
+
* share(portal.link);
|
|
1720
|
+
* ```
|
|
1721
|
+
*
|
|
1722
|
+
* Protecting the same target URL again returns the existing resource.
|
|
1723
|
+
* Malformed URLs and URLs with embedded credentials (userinfo) are
|
|
1724
|
+
* rejected before any request, mirroring qurl-go. REST-shaped equivalent:
|
|
1725
|
+
* {@link createResource}.
|
|
1726
|
+
*/
|
|
1727
|
+
async protectUrl(targetUrl, opts = {}, requestOptions) {
|
|
1728
|
+
requirePortalTargetUrl(targetUrl);
|
|
1729
|
+
requireObjectInput(opts, "protectUrl");
|
|
1730
|
+
requireNoUnknownFields(opts, PROTECT_URL_OPTION_KEYS, "protectUrl");
|
|
1731
|
+
// camelCase options → wire fields; null is treated as omitted, via the
|
|
1732
|
+
// SDK-wide normalizePatchFields convention.
|
|
1733
|
+
const normalized = normalizePatchFields(opts, PROTECT_URL_OPTION_KEYS);
|
|
1734
|
+
const body = { target_url: targetUrl };
|
|
1735
|
+
if (normalized.description !== undefined)
|
|
1736
|
+
body.description = normalized.description;
|
|
1737
|
+
if (normalized.tags !== undefined)
|
|
1738
|
+
body.tags = normalized.tags;
|
|
1739
|
+
if (normalized.customDomain !== undefined)
|
|
1740
|
+
body.custom_domain = normalized.customDomain;
|
|
1741
|
+
if (normalized.alias !== undefined)
|
|
1742
|
+
body.alias = normalized.alias;
|
|
1743
|
+
validateResourceWriteFields(body);
|
|
1744
|
+
const resource = await this.request("POST", "/v1/resources", body, requestOptions);
|
|
1745
|
+
if (typeof resource?.resource_id !== "string" || resource.resource_id.trim() === "") {
|
|
1746
|
+
throw unexpectedResponseError("protectUrl: response is missing resource_id");
|
|
1747
|
+
}
|
|
1748
|
+
return new ProtectedResource(this, resource.resource_id,
|
|
1749
|
+
// Some resource types redact target_url in responses; fall back to
|
|
1750
|
+
// the caller-supplied URL so the handle stays useful.
|
|
1751
|
+
resource.target_url || targetUrl, resource);
|
|
1752
|
+
}
|
|
1753
|
+
/**
|
|
1754
|
+
* Return a portal-minting handle for a stored resource id, without an API
|
|
1755
|
+
* call.
|
|
1756
|
+
*
|
|
1757
|
+
* Use when you persisted a LayerV resource id and want to mint more
|
|
1758
|
+
* portals for it (qurl-go: `Client.ResourceByID`):
|
|
1759
|
+
*
|
|
1760
|
+
* ```ts
|
|
1761
|
+
* const resource = client.resourceById("r_demo1234567");
|
|
1762
|
+
* const portal = await resource.createPortal({ validFor: "1h" });
|
|
1763
|
+
* ```
|
|
1764
|
+
*
|
|
1765
|
+
* The handle carries no server metadata; use {@link getResource} when you
|
|
1766
|
+
* need the full resource details.
|
|
1767
|
+
*/
|
|
1768
|
+
resourceById(id) {
|
|
1769
|
+
requireNonEmptyId(id, "resourceById");
|
|
1770
|
+
return new ProtectedResource(this, id);
|
|
1771
|
+
}
|
|
1772
|
+
/**
|
|
1773
|
+
* Return the resource qURL Connector created for `connectorId`.
|
|
1774
|
+
*
|
|
1775
|
+
* Use when qURL Connector already protects the service — do not call
|
|
1776
|
+
* {@link protectUrl} again for the same service. The connector id is the
|
|
1777
|
+
* resource slug LayerV stores for that connector; the LayerV API performs
|
|
1778
|
+
* the slug lookup, and the SDK confirms the returned alias matches
|
|
1779
|
+
* `connectorId` before binding the handle (qurl-go:
|
|
1780
|
+
* `Client.ConnectorResource`).
|
|
1781
|
+
*
|
|
1782
|
+
* Throws {@link NotFoundError} (`status: 0`, `code: "resource_not_found"`)
|
|
1783
|
+
* when no resource exists for the connector id, {@link QURLError}
|
|
1784
|
+
* (`code: "ambiguous_resource"`) when the lookup returns more than one
|
|
1785
|
+
* resource, and {@link ValidationError} (`code: "unexpected_response"`)
|
|
1786
|
+
* when the returned resource's alias is missing or does not match.
|
|
1787
|
+
*/
|
|
1788
|
+
async connectorResource(connectorId) {
|
|
1789
|
+
requireNonEmptyId(connectorId, "connectorResource", "connector id");
|
|
1790
|
+
const { resources } = await this.listResources({ slug: connectorId });
|
|
1791
|
+
if (resources.length === 0) {
|
|
1792
|
+
throw new NotFoundError({
|
|
1793
|
+
status: 0,
|
|
1794
|
+
code: ERROR_CODE_RESOURCE_NOT_FOUND,
|
|
1795
|
+
title: "Resource Not Found",
|
|
1796
|
+
detail: `connectorResource: no resource found for connector "${connectorId}"`,
|
|
1797
|
+
});
|
|
1798
|
+
}
|
|
1799
|
+
if (resources.length > 1) {
|
|
1800
|
+
throw new QURLError({
|
|
1801
|
+
status: 0,
|
|
1802
|
+
code: ERROR_CODE_AMBIGUOUS_RESOURCE,
|
|
1803
|
+
title: "Ambiguous Resource",
|
|
1804
|
+
detail: `connectorResource: connector "${connectorId}" returned ${resources.length} resources`,
|
|
1805
|
+
});
|
|
1806
|
+
}
|
|
1807
|
+
const resource = resources[0];
|
|
1808
|
+
if (resource.alias !== connectorId) {
|
|
1809
|
+
throw unexpectedResponseError(`connectorResource: connector "${connectorId}" returned a resource with a missing or different alias`);
|
|
1810
|
+
}
|
|
1811
|
+
if (typeof resource.resource_id !== "string" || resource.resource_id.trim() === "") {
|
|
1812
|
+
throw unexpectedResponseError("connectorResource: response is missing resource_id");
|
|
1813
|
+
}
|
|
1814
|
+
return new ProtectedResource(this, resource.resource_id, resource.target_url, resource);
|
|
1815
|
+
}
|
|
1816
|
+
/**
|
|
1817
|
+
* Mint a short-lived qURL link (a portal) for a protected resource.
|
|
1818
|
+
*
|
|
1819
|
+
* A portal is cryptographic, just-in-time permission for one actor to
|
|
1820
|
+
* reach one private resource. Recipients open `portal.link` directly and
|
|
1821
|
+
* need no LayerV credentials. Prefer short lifetimes such as
|
|
1822
|
+
* `{ validFor: "5m" }`. Accepts a {@link ProtectedResource} handle or a
|
|
1823
|
+
* resource id (`r_` prefix). REST-shaped equivalents: {@link mintLink} /
|
|
1824
|
+
* {@link createQurlForResource}.
|
|
1825
|
+
*
|
|
1826
|
+
* Duration options take a string (`"5m"`, `"24h"`; server-validated) or a
|
|
1827
|
+
* number of milliseconds with qurl-go's client-side guardrails: whole
|
|
1828
|
+
* seconds only, at least one minute for `validFor` and one second for
|
|
1829
|
+
* `sessionDuration`. `maxSessions: 0` is sent explicitly and means
|
|
1830
|
+
* unlimited; blank labels are rejected.
|
|
1831
|
+
*/
|
|
1832
|
+
async createPortal(resource, opts = {}, requestOptions) {
|
|
1833
|
+
let resourceId;
|
|
1834
|
+
if (typeof resource === "string") {
|
|
1835
|
+
resourceId = resource;
|
|
1836
|
+
}
|
|
1837
|
+
else if (resource instanceof ProtectedResource) {
|
|
1838
|
+
if (!ProtectedResource.isBoundTo(resource, this)) {
|
|
1839
|
+
throw clientValidationError("createPortal: resource is bound to a different client");
|
|
1840
|
+
}
|
|
1841
|
+
resourceId = resource.id;
|
|
1842
|
+
}
|
|
1843
|
+
else {
|
|
1844
|
+
throw clientValidationError(`createPortal: resource must be a ProtectedResource handle or a resource id string (got ${describeShape(resource)})`);
|
|
1845
|
+
}
|
|
1846
|
+
requireNonEmptyId(resourceId, "createPortal", "resource id");
|
|
1847
|
+
const body = buildCreatePortalBody(opts, "createPortal");
|
|
1848
|
+
const data = await this.request("POST", `/v1/resources/${encodeURIComponent(resourceId)}/qurls`, body, requestOptions);
|
|
1849
|
+
return parsePortal(data, "createPortal");
|
|
1850
|
+
}
|
|
1851
|
+
/**
|
|
1852
|
+
* Protect `targetUrl` and mint a portal in one API call.
|
|
1853
|
+
*
|
|
1854
|
+
* Convenience for one-off scripts (qurl-go: `Client.CreatePortalForURL`).
|
|
1855
|
+
* The returned resource handle is reusable for minting more portals but
|
|
1856
|
+
* carries only the resource id and the caller-supplied target URL; use
|
|
1857
|
+
* {@link protectUrl} when you need the full server-populated resource
|
|
1858
|
+
* metadata. Portal options match {@link createPortal}; `targetUrl` gets
|
|
1859
|
+
* the same malformed-URL and embedded-credentials rejection as
|
|
1860
|
+
* {@link protectUrl}. REST-shaped equivalent: {@link create}.
|
|
1861
|
+
*/
|
|
1862
|
+
async createPortalForUrl(targetUrl, opts = {}, requestOptions) {
|
|
1863
|
+
requirePortalTargetUrl(targetUrl);
|
|
1864
|
+
const body = {
|
|
1865
|
+
target_url: targetUrl,
|
|
1866
|
+
...buildCreatePortalBody(opts, "createPortalForUrl"),
|
|
1867
|
+
};
|
|
1868
|
+
const data = await this.request("POST", "/v1/qurls", body, requestOptions);
|
|
1869
|
+
const portal = parsePortal(data, "createPortalForUrl");
|
|
1870
|
+
return {
|
|
1871
|
+
portal,
|
|
1872
|
+
resource: new ProtectedResource(this, portal.resourceId, targetUrl),
|
|
1873
|
+
};
|
|
1874
|
+
}
|
|
1875
|
+
/**
|
|
1876
|
+
* Open a qURL link programmatically and return the reachable resource.
|
|
1877
|
+
*
|
|
1878
|
+
* For services and agents that open received qURL links in code — human
|
|
1879
|
+
* recipients just open the link. Accepts a full platform link
|
|
1880
|
+
* (`https://qurl.link/#at_...`) or a bare access token. Triggers an NHP
|
|
1881
|
+
* knock that grants network access for the caller's IP and returns a
|
|
1882
|
+
* {@link ResourceHandle} with the reachable resource URL and access
|
|
1883
|
+
* lifetime.
|
|
1884
|
+
*
|
|
1885
|
+
* Unlike qurl-go's `EnterPortal` (which verifies links locally and needs
|
|
1886
|
+
* no LayerV credentials), this SDK opens links through the LayerV API:
|
|
1887
|
+
* the client needs an API key with the `qurl:resolve` scope. REST-shaped
|
|
1888
|
+
* equivalent: {@link resolve}.
|
|
1889
|
+
*
|
|
1890
|
+
* Throws {@link ValidationError} (`code: "client_validation"`) when no
|
|
1891
|
+
* access token can be extracted from `qurlLink`, and
|
|
1892
|
+
* (`code: "unexpected_response"`) when access is granted but the API
|
|
1893
|
+
* reports no resource URL — it fails closed rather than returning an
|
|
1894
|
+
* empty handle. Error messages never echo the link; qURL links are
|
|
1895
|
+
* credentials.
|
|
1896
|
+
*/
|
|
1897
|
+
async enterPortal(qurlLink, requestOptions) {
|
|
1898
|
+
const token = extractAccessToken(qurlLink);
|
|
1899
|
+
const resolved = await this.resolve(token, requestOptions);
|
|
1900
|
+
if (typeof resolved?.target_url !== "string" || resolved.target_url === "") {
|
|
1901
|
+
throw unexpectedResponseError("enterPortal: access was granted but the API returned no resource URL");
|
|
1902
|
+
}
|
|
1903
|
+
return {
|
|
1904
|
+
resourceUrl: resolved.target_url,
|
|
1905
|
+
openSeconds: resolved.access_grant?.expires_in ?? 0,
|
|
1906
|
+
resourceId: resolved.resource_id,
|
|
1907
|
+
};
|
|
1908
|
+
}
|
|
1147
1909
|
// --- Public API ---
|
|
1148
|
-
/**
|
|
1910
|
+
/**
|
|
1911
|
+
* Create a new qURL.
|
|
1912
|
+
*
|
|
1913
|
+
* Portal-flow equivalent: {@link createPortalForUrl} (same endpoint,
|
|
1914
|
+
* returns a {@link Portal} plus a reusable resource handle).
|
|
1915
|
+
*/
|
|
1149
1916
|
async create(input, options) {
|
|
1150
1917
|
validateCreateInput(input);
|
|
1151
1918
|
return this.request("POST", "/v1/qurls", input, options);
|
|
@@ -1381,7 +2148,7 @@ export class QURLClient {
|
|
|
1381
2148
|
`got an ID starting with "${observedPrefix}". ` +
|
|
1382
2149
|
"To revoke a single access token, use the resource-scoped token endpoint.");
|
|
1383
2150
|
}
|
|
1384
|
-
await this.
|
|
2151
|
+
await this.requestNoContent(`/v1/qurls/${encodeURIComponent(id)}`);
|
|
1385
2152
|
}
|
|
1386
2153
|
/**
|
|
1387
2154
|
* Extend a qURL's expiration.
|
|
@@ -1442,6 +2209,8 @@ export class QURLClient {
|
|
|
1442
2209
|
/**
|
|
1443
2210
|
* Mint a new access link for a qURL.
|
|
1444
2211
|
*
|
|
2212
|
+
* Portal-flow equivalent: {@link createPortal}.
|
|
2213
|
+
*
|
|
1445
2214
|
* Accepts either a resource ID (`r_` prefix) or a qURL display ID (`q_`
|
|
1446
2215
|
* prefix); the API resolves `q_` IDs to the parent resource automatically.
|
|
1447
2216
|
*
|
|
@@ -1498,6 +2267,9 @@ export class QURLClient {
|
|
|
1498
2267
|
/**
|
|
1499
2268
|
* Resolve a qURL access token (headless).
|
|
1500
2269
|
*
|
|
2270
|
+
* Portal-flow equivalent: {@link enterPortal} (also accepts full qURL
|
|
2271
|
+
* links and fails closed when no resource URL is returned).
|
|
2272
|
+
*
|
|
1501
2273
|
* Triggers an NHP knock to grant network access for the caller's IP.
|
|
1502
2274
|
* Requires `qurl:resolve` scope on the API key.
|
|
1503
2275
|
*
|
|
@@ -1528,15 +2300,6 @@ export class QURLClient {
|
|
|
1528
2300
|
async getQuota() {
|
|
1529
2301
|
return this.request("GET", "/v1/quota");
|
|
1530
2302
|
}
|
|
1531
|
-
/** Bootstrap a LayerV qURL Connector agent. */
|
|
1532
|
-
async bootstrapAgent(input, options) {
|
|
1533
|
-
requireObjectInput(input, "bootstrapAgent");
|
|
1534
|
-
requireNoUnknownFields(input, AGENT_BOOTSTRAP_FIELD_KEYS, "bootstrapAgent");
|
|
1535
|
-
const normalizedRecord = normalizePatchFields(input, AGENT_BOOTSTRAP_FIELD_KEYS);
|
|
1536
|
-
const normalized = normalizedRecord;
|
|
1537
|
-
requireNonEmptyStringField(normalizedRecord, "public_key", "bootstrapAgent");
|
|
1538
|
-
return this.request("POST", "/v1/agent/bootstrap", normalized, options);
|
|
1539
|
-
}
|
|
1540
2303
|
/** List resources from the `/v1/resources` API. */
|
|
1541
2304
|
async listResources(input = {}) {
|
|
1542
2305
|
const { data, meta } = await this.rawRequest("GET", appendQuery("/v1/resources", input, RESOURCE_LIST_PARAM_KEYS, "listResources"));
|
|
@@ -1552,7 +2315,12 @@ export class QURLClient {
|
|
|
1552
2315
|
validateListAllInput(input, RESOURCE_LIST_PARAM_KEYS, "listAllResources");
|
|
1553
2316
|
return this.paginateAll("listAllResources", input, (pageInput) => this.listResources(pageInput), (page) => page.resources);
|
|
1554
2317
|
}
|
|
1555
|
-
/**
|
|
2318
|
+
/**
|
|
2319
|
+
* Create a resource directly.
|
|
2320
|
+
*
|
|
2321
|
+
* Portal-flow equivalent for URL resources: {@link protectUrl} (returns
|
|
2322
|
+
* a portal-minting handle).
|
|
2323
|
+
*/
|
|
1556
2324
|
async createResource(input, options) {
|
|
1557
2325
|
requireObjectInput(input, "createResource");
|
|
1558
2326
|
requireNoUnknownFields(input, CREATE_RESOURCE_FIELD_KEYS, "createResource");
|
|
@@ -1585,9 +2353,13 @@ export class QURLClient {
|
|
|
1585
2353
|
/** Revoke a resource and all of its qURLs. */
|
|
1586
2354
|
async deleteResource(id) {
|
|
1587
2355
|
requireNonEmptyId(id, "deleteResource");
|
|
1588
|
-
await this.
|
|
2356
|
+
await this.requestNoContent(`/v1/resources/${encodeURIComponent(id)}`);
|
|
1589
2357
|
}
|
|
1590
|
-
/**
|
|
2358
|
+
/**
|
|
2359
|
+
* Mint a qURL against an existing resource.
|
|
2360
|
+
*
|
|
2361
|
+
* Portal-flow equivalent: {@link createPortal} (same endpoint).
|
|
2362
|
+
*/
|
|
1591
2363
|
async createQurlForResource(id, input, options) {
|
|
1592
2364
|
requireNonEmptyId(id, "createQurlForResource");
|
|
1593
2365
|
let normalized = input;
|
|
@@ -1604,7 +2376,7 @@ export class QURLClient {
|
|
|
1604
2376
|
async revokeResourceQurl(id, qurlId) {
|
|
1605
2377
|
requireNonEmptyId(id, "revokeResourceQurl");
|
|
1606
2378
|
requireNonEmptyId(qurlId, "revokeResourceQurl");
|
|
1607
|
-
await this.
|
|
2379
|
+
await this.requestNoContent(`/v1/resources/${encodeURIComponent(id)}/qurls/${encodeURIComponent(qurlId)}`);
|
|
1608
2380
|
}
|
|
1609
2381
|
/** Update a specific qURL token on a resource. */
|
|
1610
2382
|
async updateResourceQurl(id, qurlId, input, options) {
|
|
@@ -1640,13 +2412,7 @@ export class QURLClient {
|
|
|
1640
2412
|
page_size: meta?.page_size,
|
|
1641
2413
|
};
|
|
1642
2414
|
}
|
|
1643
|
-
/**
|
|
1644
|
-
* Terminate all active sessions for a resource.
|
|
1645
|
-
*
|
|
1646
|
-
* The returned count is best-effort under retries: if the first DELETE
|
|
1647
|
-
* succeeds server-side but the response is lost, a retried request may
|
|
1648
|
-
* return `0` because there are no sessions left to terminate.
|
|
1649
|
-
*/
|
|
2415
|
+
/** Terminate all active sessions for a resource and return the server count. */
|
|
1650
2416
|
async terminateAllResourceSessions(id) {
|
|
1651
2417
|
requireNonEmptyId(id, "terminateAllResourceSessions");
|
|
1652
2418
|
const path = `/v1/resources/${encodeURIComponent(id)}/sessions`;
|
|
@@ -1665,7 +2431,7 @@ export class QURLClient {
|
|
|
1665
2431
|
async terminateResourceSession(id, sessionId) {
|
|
1666
2432
|
requireNonEmptyId(id, "terminateResourceSession");
|
|
1667
2433
|
requireNonEmptyId(sessionId, "terminateResourceSession");
|
|
1668
|
-
await this.
|
|
2434
|
+
await this.requestNoContent(`/v1/resources/${encodeURIComponent(id)}/sessions/${encodeURIComponent(sessionId)}`);
|
|
1669
2435
|
}
|
|
1670
2436
|
/** List connector installations. */
|
|
1671
2437
|
async listConnectorInstallations(input = {}) {
|
|
@@ -1773,7 +2539,7 @@ export class QURLClient {
|
|
|
1773
2539
|
/** Remove a custom domain. */
|
|
1774
2540
|
async deleteDomain(domain) {
|
|
1775
2541
|
requireNonEmptyId(domain, "deleteDomain", "domain");
|
|
1776
|
-
await this.
|
|
2542
|
+
await this.requestNoContent(`/v1/domains/${encodeURIComponent(domain)}`);
|
|
1777
2543
|
}
|
|
1778
2544
|
/** Trigger DNS verification for a custom domain. */
|
|
1779
2545
|
async verifyDomain(domain, options) {
|
|
@@ -1834,7 +2600,7 @@ export class QURLClient {
|
|
|
1834
2600
|
/** Delete a webhook. */
|
|
1835
2601
|
async deleteWebhook(id) {
|
|
1836
2602
|
requireNonEmptyId(id, "deleteWebhook");
|
|
1837
|
-
await this.
|
|
2603
|
+
await this.requestNoContent(`/v1/webhooks/${encodeURIComponent(id)}`);
|
|
1838
2604
|
}
|
|
1839
2605
|
/** Regenerate a webhook signing secret. */
|
|
1840
2606
|
async regenerateWebhookSecret(id, options) {
|
|
@@ -1898,7 +2664,7 @@ export class QURLClient {
|
|
|
1898
2664
|
/** Revoke an API key. */
|
|
1899
2665
|
async revokeApiKey(keyId) {
|
|
1900
2666
|
requireNonEmptyId(keyId, "revokeApiKey");
|
|
1901
|
-
await this.
|
|
2667
|
+
await this.requestNoContent(`/v1/api-keys/${encodeURIComponent(keyId)}`);
|
|
1902
2668
|
}
|
|
1903
2669
|
/** Redeem an access code. */
|
|
1904
2670
|
async redeemAccessCode(input, options) {
|
|
@@ -1943,7 +2709,7 @@ export class QURLClient {
|
|
|
1943
2709
|
/** Revoke an access code. */
|
|
1944
2710
|
async revokeAccessCode(id) {
|
|
1945
2711
|
requireNonEmptyId(id, "revokeAccessCode");
|
|
1946
|
-
await this.
|
|
2712
|
+
await this.requestNoContent(`/v1/access-codes/${encodeURIComponent(id)}`);
|
|
1947
2713
|
}
|
|
1948
2714
|
// --- Internal HTTP plumbing ---
|
|
1949
2715
|
async request(method, path, body, requestOptions) {
|
|
@@ -1957,6 +2723,22 @@ export class QURLClient {
|
|
|
1957
2723
|
}
|
|
1958
2724
|
return data;
|
|
1959
2725
|
}
|
|
2726
|
+
/**
|
|
2727
|
+
* Require the service's exact 204 response with no response bytes exposed by
|
|
2728
|
+
* Fetch. A standards-compliant Fetch implementation discards any forbidden
|
|
2729
|
+
* wire body on 204, so the SDK cannot detect bytes that Fetch did not expose.
|
|
2730
|
+
*/
|
|
2731
|
+
async requestNoContent(path) {
|
|
2732
|
+
const response = await this.rawRequest("DELETE", path, undefined, {
|
|
2733
|
+
allowEmptySuccessBody: true,
|
|
2734
|
+
});
|
|
2735
|
+
if (response.__http_status !== 204 || response.__http_body_empty !== true) {
|
|
2736
|
+
const status = response.__http_status ?? 0;
|
|
2737
|
+
const bodyShape = response.__http_body_empty ? "with an empty body" : "with response bytes";
|
|
2738
|
+
throw unexpectedResponseError(`Unexpected response from DELETE; expected empty HTTP 204, received HTTP ${status} ${bodyShape}. ` +
|
|
2739
|
+
"The delete may already have been applied; reconcile resource state before retrying.", { status });
|
|
2740
|
+
}
|
|
2741
|
+
}
|
|
1960
2742
|
/**
|
|
1961
2743
|
* Issue an HTTP request and parse the JSON response.
|
|
1962
2744
|
*
|
|
@@ -1968,7 +2750,7 @@ export class QURLClient {
|
|
|
1968
2750
|
*/
|
|
1969
2751
|
async rawRequest(method, path, body, rawOptions = {}) {
|
|
1970
2752
|
const url = `${this.baseUrl}${path}`;
|
|
1971
|
-
const { passthroughStatuses = NO_PASSTHROUGH_STATUSES, requestOptions } = rawOptions;
|
|
2753
|
+
const { passthroughStatuses = NO_PASSTHROUGH_STATUSES, requestOptions, allowEmptySuccessBody = false, } = rawOptions;
|
|
1972
2754
|
const idempotencyKey = idempotencyKeyForRequest(method, requestOptions);
|
|
1973
2755
|
const headers = {
|
|
1974
2756
|
Authorization: `Bearer ${this.apiKey}`,
|
|
@@ -1981,12 +2763,20 @@ export class QURLClient {
|
|
|
1981
2763
|
if (idempotencyKey !== undefined) {
|
|
1982
2764
|
headers["Idempotency-Key"] = idempotencyKey;
|
|
1983
2765
|
}
|
|
1984
|
-
//
|
|
1985
|
-
//
|
|
1986
|
-
//
|
|
1987
|
-
//
|
|
1988
|
-
|
|
1989
|
-
|
|
2766
|
+
// Reads may retry transient statuses and transport failures. POST/PATCH
|
|
2767
|
+
// keep status-code retries limited to rate limits and reuse the same
|
|
2768
|
+
// Idempotency-Key for fetch-level failures. DELETE is deliberately never
|
|
2769
|
+
// replayed, including after 429: this matches qurl-go's no-hidden-HTTP-
|
|
2770
|
+
// retry rule and avoids SDK pacing on an application request path. The
|
|
2771
|
+
// caller receives retryAfter when supplied and must reconcile resource
|
|
2772
|
+
// state before it chooses to issue another delete.
|
|
2773
|
+
const idempotencyKeyBackedRetry = MUTATING_RETRY_METHODS.has(method);
|
|
2774
|
+
const retryFetchFailure = method === "GET" || idempotencyKeyBackedRetry;
|
|
2775
|
+
const retryable = method === "GET"
|
|
2776
|
+
? RETRYABLE_STATUS
|
|
2777
|
+
: idempotencyKeyBackedRetry
|
|
2778
|
+
? RETRYABLE_STATUS_MUTATING
|
|
2779
|
+
: NO_RETRYABLE_STATUSES;
|
|
1990
2780
|
const serializedBody = body !== undefined ? JSON.stringify(body) : undefined;
|
|
1991
2781
|
let lastError;
|
|
1992
2782
|
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
@@ -1997,6 +2787,7 @@ export class QURLClient {
|
|
|
1997
2787
|
}
|
|
1998
2788
|
this.log(`${method} ${url}`);
|
|
1999
2789
|
let response;
|
|
2790
|
+
const requestSignal = AbortSignal.timeout(this.timeout);
|
|
2000
2791
|
try {
|
|
2001
2792
|
// `timeout` is intentionally a per-attempt budget, not a total-
|
|
2002
2793
|
// request budget. With maxRetries=3 and timeout=30s, a slow
|
|
@@ -2010,20 +2801,151 @@ export class QURLClient {
|
|
|
2010
2801
|
method,
|
|
2011
2802
|
headers,
|
|
2012
2803
|
body: serializedBody,
|
|
2013
|
-
|
|
2804
|
+
redirect: "manual",
|
|
2805
|
+
signal: requestSignal,
|
|
2014
2806
|
});
|
|
2015
2807
|
}
|
|
2016
2808
|
catch (err) {
|
|
2017
|
-
lastError = this.classifyFetchError(err);
|
|
2809
|
+
lastError = this.classifyFetchError(err, requestSignal);
|
|
2018
2810
|
this.log(`${method} ${url} ${lastError instanceof TimeoutError ? "timed out" : "network error"}`, {
|
|
2019
|
-
error: lastError.message,
|
|
2811
|
+
error: boundedErrorSnippet(lastError.message),
|
|
2020
2812
|
});
|
|
2021
|
-
|
|
2813
|
+
// An injected fetch can abort or impose a deadline for its own reason.
|
|
2814
|
+
// That is not this SDK's timeout. Replaying it can defeat caller-side
|
|
2815
|
+
// cancellation or deadline policy.
|
|
2816
|
+
if (retryFetchFailure &&
|
|
2817
|
+
!isIndependentAbort(err, requestSignal) &&
|
|
2818
|
+
attempt < this.maxRetries) {
|
|
2022
2819
|
continue;
|
|
2023
2820
|
}
|
|
2024
2821
|
throw lastError;
|
|
2025
2822
|
}
|
|
2026
2823
|
this.log(`${method} ${url} → ${response.status}`);
|
|
2824
|
+
// Redirects are deterministic protocol violations for SDK API calls.
|
|
2825
|
+
// Refuse them before reading Location or entering retry handling so
|
|
2826
|
+
// Authorization and Idempotency-Key can never reach a follow-up target.
|
|
2827
|
+
const responseUrlStateValue = responseUrlState(response.url, url);
|
|
2828
|
+
if (response.redirected === true ||
|
|
2829
|
+
responseUrlStateValue !== "same" ||
|
|
2830
|
+
REDIRECT_RESPONSE_STATUSES.has(response.status) ||
|
|
2831
|
+
response.type === "opaqueredirect") {
|
|
2832
|
+
await cancelResponseBody(response.body);
|
|
2833
|
+
const redirectKind = response.redirected === true
|
|
2834
|
+
? "followed"
|
|
2835
|
+
: responseUrlStateValue === "invalid"
|
|
2836
|
+
? "invalid custom Response.url"
|
|
2837
|
+
: responseUrlStateValue === "changed"
|
|
2838
|
+
? "changed Response.url"
|
|
2839
|
+
: REDIRECT_RESPONSE_STATUSES.has(response.status)
|
|
2840
|
+
? `HTTP ${response.status}`
|
|
2841
|
+
: "opaque";
|
|
2842
|
+
throw unexpectedResponseError(`Refused ${redirectKind} redirect response for ${method}`, {
|
|
2843
|
+
status: response.status,
|
|
2844
|
+
});
|
|
2845
|
+
}
|
|
2846
|
+
let responseBody;
|
|
2847
|
+
try {
|
|
2848
|
+
responseBody = await readBoundedResponseBody(response);
|
|
2849
|
+
}
|
|
2850
|
+
catch (err) {
|
|
2851
|
+
if (err instanceof ResponseBodyTooLargeError) {
|
|
2852
|
+
this.log(`rejected oversized response body from ${response.status}`, {
|
|
2853
|
+
status: response.status,
|
|
2854
|
+
});
|
|
2855
|
+
const detail = `Response body exceeds ${MAX_RESPONSE_BODY_BYTES}-byte limit on HTTP ${response.status}; ` +
|
|
2856
|
+
"request a smaller page when listing collections";
|
|
2857
|
+
if (response.ok) {
|
|
2858
|
+
throw unexpectedResponseError(detail, { status: response.status });
|
|
2859
|
+
}
|
|
2860
|
+
const bodyError = createError({
|
|
2861
|
+
status: response.status,
|
|
2862
|
+
// The unread body cannot supply a trustworthy server code. Keep
|
|
2863
|
+
// the real HTTP status class (429/5xx) just like other unreadable
|
|
2864
|
+
// error bodies instead of reclassifying it as client validation.
|
|
2865
|
+
code: ERROR_CODE_UNKNOWN,
|
|
2866
|
+
title: boundedErrorSnippet(response.statusText) || `HTTP ${response.status}`,
|
|
2867
|
+
detail,
|
|
2868
|
+
retry_after: this.parseRetryAfter(response),
|
|
2869
|
+
});
|
|
2870
|
+
// The unread body does not change operation semantics: retry a GET,
|
|
2871
|
+
// or an idempotency-key-backed mutation on an explicitly retryable
|
|
2872
|
+
// status. Successful oversized responses remain deterministic
|
|
2873
|
+
// contract failures, and DELETE has no retryable statuses.
|
|
2874
|
+
if (retryable.has(response.status) && attempt < this.maxRetries) {
|
|
2875
|
+
lastError = bodyError;
|
|
2876
|
+
continue;
|
|
2877
|
+
}
|
|
2878
|
+
throw bodyError;
|
|
2879
|
+
}
|
|
2880
|
+
if (err instanceof InvalidResponseEncodingError) {
|
|
2881
|
+
this.log(`rejected malformed UTF-8 response body from ${response.status}`, {
|
|
2882
|
+
status: response.status,
|
|
2883
|
+
});
|
|
2884
|
+
const detail = `Response body is not valid UTF-8 on HTTP ${response.status}`;
|
|
2885
|
+
if (response.ok) {
|
|
2886
|
+
// A successful status with an invalid wire body is a deterministic
|
|
2887
|
+
// response-contract failure. Do not replay a GET or a mutation.
|
|
2888
|
+
throw unexpectedResponseError(detail, { status: response.status });
|
|
2889
|
+
}
|
|
2890
|
+
const encodingError = createError({
|
|
2891
|
+
status: response.status,
|
|
2892
|
+
code: ERROR_CODE_UNKNOWN,
|
|
2893
|
+
title: boundedErrorSnippet(response.statusText) || `HTTP ${response.status}`,
|
|
2894
|
+
detail,
|
|
2895
|
+
retry_after: this.parseRetryAfter(response),
|
|
2896
|
+
});
|
|
2897
|
+
// For an explicit error status, the HTTP classification remains
|
|
2898
|
+
// authoritative even when its body encoding is invalid.
|
|
2899
|
+
if (retryable.has(response.status) && attempt < this.maxRetries) {
|
|
2900
|
+
lastError = encodingError;
|
|
2901
|
+
continue;
|
|
2902
|
+
}
|
|
2903
|
+
throw encodingError;
|
|
2904
|
+
}
|
|
2905
|
+
await cancelResponseBody(response.body);
|
|
2906
|
+
const transportReadError = this.classifyResponseReadError(err, response, requestSignal);
|
|
2907
|
+
if (transportReadError !== undefined) {
|
|
2908
|
+
this.log(`transport failure while reading response body from ${response.status}`, {
|
|
2909
|
+
status: response.status,
|
|
2910
|
+
error: transportReadError.code,
|
|
2911
|
+
});
|
|
2912
|
+
// A successful GET can be replayed when its success body is lost. A
|
|
2913
|
+
// non-success GET retries only the status set that the normal error
|
|
2914
|
+
// path retries. Mutations are never replayed after a body transport
|
|
2915
|
+
// failure because the operation may have applied.
|
|
2916
|
+
if (method === "GET" &&
|
|
2917
|
+
!isIndependentAbort(err, requestSignal) &&
|
|
2918
|
+
(response.ok || retryable.has(response.status)) &&
|
|
2919
|
+
attempt < this.maxRetries) {
|
|
2920
|
+
lastError = transportReadError;
|
|
2921
|
+
continue;
|
|
2922
|
+
}
|
|
2923
|
+
throw transportReadError;
|
|
2924
|
+
}
|
|
2925
|
+
this.log(`failed to read response body from ${response.status}`, {
|
|
2926
|
+
status: response.status,
|
|
2927
|
+
});
|
|
2928
|
+
// ResponseBodyMaterializationError messages are fixed SDK-authored
|
|
2929
|
+
// contract text. The underlying shim rejection is never interpolated.
|
|
2930
|
+
const detail = err instanceof ResponseBodyMaterializationError
|
|
2931
|
+
? err.message
|
|
2932
|
+
: `Failed to read response body on HTTP ${response.status}`;
|
|
2933
|
+
const readError = response.ok
|
|
2934
|
+
? unexpectedResponseError(detail, { status: response.status })
|
|
2935
|
+
: createError({
|
|
2936
|
+
status: response.status,
|
|
2937
|
+
code: ERROR_CODE_UNKNOWN,
|
|
2938
|
+
title: boundedErrorSnippet(response.statusText) || `HTTP ${response.status}`,
|
|
2939
|
+
detail,
|
|
2940
|
+
retry_after: this.parseRetryAfter(response),
|
|
2941
|
+
});
|
|
2942
|
+
// This arm contains deterministic Response-like shim failures (for
|
|
2943
|
+
// example a BigInt returned by json()) and non-Error throws. Do not
|
|
2944
|
+
// replay them: another attempt will see the same local contract bug.
|
|
2945
|
+
throw err instanceof ResponseBodyMaterializationError
|
|
2946
|
+
? withErrorCause(readError, err)
|
|
2947
|
+
: readError;
|
|
2948
|
+
}
|
|
2027
2949
|
// `response.ok` is true for the entire 200-299 range, so partial-
|
|
2028
2950
|
// success responses like 207 flow through this path naturally —
|
|
2029
2951
|
// `passthroughStatuses` only needs to enumerate non-2xx statuses
|
|
@@ -2046,33 +2968,56 @@ export class QURLClient {
|
|
|
2046
2968
|
url,
|
|
2047
2969
|
});
|
|
2048
2970
|
}
|
|
2049
|
-
return {
|
|
2971
|
+
return {
|
|
2972
|
+
data: undefined,
|
|
2973
|
+
__http_status: response.status,
|
|
2974
|
+
__http_body_empty: responseBody.length === 0,
|
|
2975
|
+
};
|
|
2976
|
+
}
|
|
2977
|
+
// Exact no-content helpers must inspect both axes of the success
|
|
2978
|
+
// contract. Let only those callers observe an empty non-204 response;
|
|
2979
|
+
// body-returning methods still reject it as non-JSON below.
|
|
2980
|
+
if (response.ok && allowEmptySuccessBody && responseBody.length === 0) {
|
|
2981
|
+
return {
|
|
2982
|
+
data: undefined,
|
|
2983
|
+
__http_status: response.status,
|
|
2984
|
+
__http_body_empty: true,
|
|
2985
|
+
};
|
|
2050
2986
|
}
|
|
2051
2987
|
try {
|
|
2052
|
-
const json =
|
|
2053
|
-
return {
|
|
2988
|
+
const json = JSON.parse(responseBody);
|
|
2989
|
+
return {
|
|
2990
|
+
...json,
|
|
2991
|
+
__http_status: response.status,
|
|
2992
|
+
// JSON.parse("") throws, so parsed JSON is necessarily non-empty.
|
|
2993
|
+
__http_body_empty: false,
|
|
2994
|
+
};
|
|
2054
2995
|
}
|
|
2055
2996
|
catch {
|
|
2056
2997
|
// Non-JSON body on a 2xx response (server contract violation)
|
|
2057
2998
|
// or on a passthrough status (e.g. proxy HTML on 400). The
|
|
2058
|
-
// body
|
|
2059
|
-
//
|
|
2060
|
-
//
|
|
2061
|
-
// miss it.
|
|
2999
|
+
// body was read through the cap before JSON decoding.
|
|
3000
|
+
// Synthesize a typed QURLError directly so consumers catching
|
|
3001
|
+
// by-class don't miss the contract failure.
|
|
2062
3002
|
this.log(`non-JSON body on ${isPassthrough ? "passthrough" : "success"} response ${response.status}`, {
|
|
2063
3003
|
status: response.status,
|
|
2064
|
-
content_type: response.headers.get("content-type")
|
|
3004
|
+
content_type: boundedErrorSnippet(response.headers.get("content-type")),
|
|
2065
3005
|
});
|
|
2066
3006
|
throw createError({
|
|
2067
3007
|
status: response.status,
|
|
2068
3008
|
code: ERROR_CODE_UNEXPECTED_RESPONSE,
|
|
2069
|
-
title: response.statusText || `HTTP ${response.status}`,
|
|
3009
|
+
title: boundedErrorSnippet(response.statusText) || `HTTP ${response.status}`,
|
|
2070
3010
|
detail: `Expected JSON response body on HTTP ${response.status} but received non-JSON content`,
|
|
2071
3011
|
});
|
|
2072
3012
|
}
|
|
2073
3013
|
}
|
|
2074
|
-
const errorData =
|
|
3014
|
+
const errorData = this.parseError(response, responseBody);
|
|
2075
3015
|
const err = createError(errorData);
|
|
3016
|
+
// Retryability is determined by status even when an intermediary
|
|
3017
|
+
// returned HTML, an empty body, or another non-envelope error. Those
|
|
3018
|
+
// shapes are common for transient 429/502/503/504 responses and do not
|
|
3019
|
+
// prove that a retry cannot succeed. Redirects and oversized bodies
|
|
3020
|
+
// already throw before reaching this check.
|
|
2076
3021
|
if (retryable.has(response.status) && attempt < this.maxRetries) {
|
|
2077
3022
|
lastError = err;
|
|
2078
3023
|
continue;
|
|
@@ -2081,9 +3026,9 @@ export class QURLClient {
|
|
|
2081
3026
|
}
|
|
2082
3027
|
throw lastError ?? new Error("Request failed after retries");
|
|
2083
3028
|
}
|
|
2084
|
-
|
|
3029
|
+
parseError(response, responseBody) {
|
|
2085
3030
|
try {
|
|
2086
|
-
const json =
|
|
3031
|
+
const json = JSON.parse(responseBody);
|
|
2087
3032
|
if (json.error) {
|
|
2088
3033
|
const err = json.error;
|
|
2089
3034
|
// Detail fallback chain:
|
|
@@ -2092,18 +3037,27 @@ export class QURLClient {
|
|
|
2092
3037
|
// 3. err.title (RFC 7807 required field)
|
|
2093
3038
|
// 4. HTTP status (final safety net)
|
|
2094
3039
|
// This prevents `"Title (403): undefined"` when the API omits detail.
|
|
2095
|
-
const detail = err.detail ??
|
|
3040
|
+
const detail = boundedErrorSnippet(err.detail) ??
|
|
3041
|
+
boundedErrorSnippet(err.message) ??
|
|
3042
|
+
boundedErrorSnippet(err.title) ??
|
|
3043
|
+
`HTTP ${response.status}`;
|
|
2096
3044
|
// HTTP/2 omits reason-phrases — `statusText` may be "".
|
|
2097
|
-
const title = err.title ??
|
|
3045
|
+
const title = boundedErrorSnippet(err.title) ??
|
|
3046
|
+
boundedErrorSnippet(response.statusText) ??
|
|
3047
|
+
`HTTP ${response.status}`;
|
|
3048
|
+
// The transport status is authoritative for error classification. A
|
|
3049
|
+
// conflicting or malformed RFC 7807 body must not turn a real 5xx into
|
|
3050
|
+
// a NotFoundError (or vice versa) in caller reconciliation logic.
|
|
3051
|
+
const status = response.status;
|
|
2098
3052
|
return {
|
|
2099
|
-
status
|
|
2100
|
-
code: err.code ?? ERROR_CODE_UNKNOWN,
|
|
3053
|
+
status,
|
|
3054
|
+
code: boundedErrorIdentifier(err.code) ?? ERROR_CODE_UNKNOWN,
|
|
2101
3055
|
title,
|
|
2102
3056
|
detail,
|
|
2103
|
-
type: err.type,
|
|
2104
|
-
instance: err.instance,
|
|
2105
|
-
invalid_fields: err.invalid_fields,
|
|
2106
|
-
request_id: json.meta?.request_id,
|
|
3057
|
+
type: boundedErrorIdentifier(err.type),
|
|
3058
|
+
instance: boundedErrorIdentifier(err.instance),
|
|
3059
|
+
invalid_fields: boundedInvalidFields(err.invalid_fields),
|
|
3060
|
+
request_id: boundedErrorIdentifier(json.meta?.request_id),
|
|
2107
3061
|
retry_after: this.parseRetryAfter(response),
|
|
2108
3062
|
};
|
|
2109
3063
|
}
|
|
@@ -2113,31 +3067,37 @@ export class QURLClient {
|
|
|
2113
3067
|
// status-only safety net below.
|
|
2114
3068
|
this.log(`unexpected error response shape from ${response.status}`, {
|
|
2115
3069
|
status: response.status,
|
|
2116
|
-
body_keys:
|
|
3070
|
+
body_keys: typeof json === "object" && json !== null ? boundedObjectKeys(json) : undefined,
|
|
2117
3071
|
});
|
|
2118
3072
|
}
|
|
2119
3073
|
catch {
|
|
2120
|
-
// Body wasn't valid JSON
|
|
2121
|
-
//
|
|
2122
|
-
// envelope, and fall through to the status-only safety net.
|
|
3074
|
+
// Body wasn't valid JSON. Log so operators can distinguish this from a
|
|
3075
|
+
// malformed envelope, and fall through to the status-only safety net.
|
|
2123
3076
|
this.log(`non-JSON error response from ${response.status}`, {
|
|
2124
3077
|
status: response.status,
|
|
2125
|
-
content_type: response.headers.get("content-type")
|
|
3078
|
+
content_type: boundedErrorSnippet(response.headers.get("content-type")),
|
|
2126
3079
|
});
|
|
2127
3080
|
}
|
|
3081
|
+
const statusText = boundedErrorSnippet(response.statusText) || `HTTP ${response.status}`;
|
|
2128
3082
|
return {
|
|
2129
3083
|
status: response.status,
|
|
2130
3084
|
code: ERROR_CODE_UNKNOWN,
|
|
2131
|
-
title:
|
|
2132
|
-
detail:
|
|
3085
|
+
title: statusText,
|
|
3086
|
+
detail: statusText,
|
|
3087
|
+
retry_after: this.parseRetryAfter(response),
|
|
2133
3088
|
};
|
|
2134
3089
|
}
|
|
2135
3090
|
parseRetryAfter(response) {
|
|
2136
|
-
// RFC 7231 §7.1.3: Retry-After honored on 429 + 503.
|
|
2137
|
-
// also
|
|
2138
|
-
//
|
|
3091
|
+
// RFC 7231 §7.1.3: Retry-After honored on 429 + 503. Although the RFC
|
|
3092
|
+
// also permits it on 3xx responses, rawRequest now refuses redirects
|
|
3093
|
+
// as deterministic errors before retry parsing, so only 429/503 apply.
|
|
2139
3094
|
if (response.status !== 429 && response.status !== 503)
|
|
2140
3095
|
return undefined;
|
|
3096
|
+
// This fallback can run after the body reader rejects a malformed injected
|
|
3097
|
+
// Response-like shim. Preserve the typed, non-retryable SDK error instead
|
|
3098
|
+
// of dereferencing the same missing method and leaking a raw TypeError.
|
|
3099
|
+
if (typeof response.headers?.get !== "function")
|
|
3100
|
+
return undefined;
|
|
2141
3101
|
const header = response.headers.get("Retry-After");
|
|
2142
3102
|
if (!header)
|
|
2143
3103
|
return undefined;
|
|
@@ -2199,12 +3159,81 @@ export class QURLClient {
|
|
|
2199
3159
|
const jitter = Math.random() * base * 0.5;
|
|
2200
3160
|
return Math.min(base + jitter, RETRY_MAX_DELAY_MS);
|
|
2201
3161
|
}
|
|
2202
|
-
classifyFetchError(err) {
|
|
2203
|
-
|
|
3162
|
+
classifyFetchError(err, requestSignal) {
|
|
3163
|
+
// AbortSignal.timeout is the only timeout source owned by this SDK. Keep
|
|
3164
|
+
// this check tied to that signal instead of trusting an injected fetch's
|
|
3165
|
+
// AbortError or TimeoutError name.
|
|
3166
|
+
if (isSdkTimeout(err, requestSignal)) {
|
|
2204
3167
|
return new TimeoutError("Request timed out", { cause: err });
|
|
2205
3168
|
}
|
|
2206
3169
|
const cause = err instanceof Error ? err : undefined;
|
|
2207
3170
|
return new NetworkError(cause?.message ?? String(err), { cause });
|
|
2208
3171
|
}
|
|
3172
|
+
classifyResponseReadError(err, response, requestSignal) {
|
|
3173
|
+
if (err instanceof ResponseBodyMaterializationError)
|
|
3174
|
+
return undefined;
|
|
3175
|
+
if (!(err instanceof Error))
|
|
3176
|
+
return undefined;
|
|
3177
|
+
if (!response.ok) {
|
|
3178
|
+
const statusText = boundedErrorSnippet(response.statusText) || `HTTP ${response.status}`;
|
|
3179
|
+
return withErrorCause(createError({
|
|
3180
|
+
status: response.status,
|
|
3181
|
+
code: ERROR_CODE_UNKNOWN,
|
|
3182
|
+
title: statusText,
|
|
3183
|
+
detail: `Failed to read response body after HTTP ${response.status}`,
|
|
3184
|
+
retry_after: this.parseRetryAfter(response),
|
|
3185
|
+
}), err);
|
|
3186
|
+
}
|
|
3187
|
+
if (isSdkTimeout(err, requestSignal)) {
|
|
3188
|
+
return new TimeoutError(`Request timed out while reading response body after HTTP ${response.status}`, { cause: err });
|
|
3189
|
+
}
|
|
3190
|
+
return new NetworkError(`Network failure while reading response body after HTTP ${response.status}`, { cause: err });
|
|
3191
|
+
}
|
|
3192
|
+
}
|
|
3193
|
+
/**
|
|
3194
|
+
* A LayerV-protected resource, bound to the client that produced it.
|
|
3195
|
+
*
|
|
3196
|
+
* Obtained from {@link QURLClient.protectUrl},
|
|
3197
|
+
* {@link QURLClient.connectorResource}, or {@link QURLClient.resourceById} —
|
|
3198
|
+
* not constructed directly. Mint short-lived access links for the resource
|
|
3199
|
+
* with {@link createPortal} (qurl-go: `qurl.Resource`).
|
|
3200
|
+
*/
|
|
3201
|
+
export class ProtectedResource {
|
|
3202
|
+
// A true private field (not a module-scope WeakMap registry, which
|
|
3203
|
+
// CONTRIBUTING.md's dual-build rule bars) so the binding stays on the
|
|
3204
|
+
// instance and out of Object.keys/JSON.stringify output.
|
|
3205
|
+
#client;
|
|
3206
|
+
/** The LayerV resource id (`r_` prefix). */
|
|
3207
|
+
id;
|
|
3208
|
+
/** The private URL protected by this resource, when known. */
|
|
3209
|
+
targetUrl;
|
|
3210
|
+
/**
|
|
3211
|
+
* Full server-populated {@link Resource} metadata when the handle came
|
|
3212
|
+
* from an API response; `undefined` for {@link QURLClient.resourceById}
|
|
3213
|
+
* handles and for the one-call {@link QURLClient.createPortalForUrl} path.
|
|
3214
|
+
*/
|
|
3215
|
+
details;
|
|
3216
|
+
constructor(client, id, targetUrl, details) {
|
|
3217
|
+
this.#client = client;
|
|
3218
|
+
this.id = id;
|
|
3219
|
+
this.targetUrl = targetUrl;
|
|
3220
|
+
this.details = details;
|
|
3221
|
+
}
|
|
3222
|
+
/**
|
|
3223
|
+
* Whether `resource` was produced by `client`. A static because the
|
|
3224
|
+
* binding lives in a private field that only this class can read; used
|
|
3225
|
+
* by {@link QURLClient.createPortal} to refuse handles bound to a
|
|
3226
|
+
* different client.
|
|
3227
|
+
*/
|
|
3228
|
+
static isBoundTo(resource, client) {
|
|
3229
|
+
return resource.#client === client;
|
|
3230
|
+
}
|
|
3231
|
+
/**
|
|
3232
|
+
* Mint a short-lived qURL link (a portal) for this resource. See
|
|
3233
|
+
* {@link QURLClient.createPortal} for the option reference.
|
|
3234
|
+
*/
|
|
3235
|
+
async createPortal(opts = {}, requestOptions) {
|
|
3236
|
+
return this.#client.createPortal(this, opts, requestOptions);
|
|
3237
|
+
}
|
|
2209
3238
|
}
|
|
2210
3239
|
//# sourceMappingURL=client.js.map
|