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