@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.
Files changed (75) hide show
  1. package/README.md +340 -23
  2. package/dist/cjs/client.d.ts +180 -13
  3. package/dist/cjs/client.js +1126 -96
  4. package/dist/cjs/errors.d.ts +15 -11
  5. package/dist/cjs/errors.js +30 -21
  6. package/dist/cjs/index.d.ts +3 -3
  7. package/dist/cjs/index.js +4 -1
  8. package/dist/cjs/internal.d.ts +4 -0
  9. package/dist/cjs/internal.js +16 -0
  10. package/dist/cjs/node/deployment.d.ts +44 -0
  11. package/dist/cjs/node/deployment.js +167 -0
  12. package/dist/cjs/node/native-udp.d.ts +37 -0
  13. package/dist/cjs/node/native-udp.js +294 -0
  14. package/dist/cjs/node/nhp-wire.d.ts +35 -0
  15. package/dist/cjs/node/nhp-wire.js +261 -0
  16. package/dist/cjs/node/portal-opener.d.ts +83 -0
  17. package/dist/cjs/node/portal-opener.js +767 -0
  18. package/dist/cjs/node/qv2.d.ts +38 -0
  19. package/dist/cjs/node/qv2.js +292 -0
  20. package/dist/cjs/node/strict-json.d.ts +12 -0
  21. package/dist/cjs/node/strict-json.js +174 -0
  22. package/dist/cjs/node.d.ts +3 -0
  23. package/dist/cjs/node.js +11 -0
  24. package/dist/cjs/types.d.ts +90 -18
  25. package/dist/cjs/version.d.ts +1 -1
  26. package/dist/cjs/version.js +1 -1
  27. package/dist/esm/client.d.ts +180 -13
  28. package/dist/esm/client.d.ts.map +1 -1
  29. package/dist/esm/client.js +1125 -96
  30. package/dist/esm/client.js.map +1 -1
  31. package/dist/esm/errors.d.ts +15 -11
  32. package/dist/esm/errors.d.ts.map +1 -1
  33. package/dist/esm/errors.js +29 -20
  34. package/dist/esm/errors.js.map +1 -1
  35. package/dist/esm/index.d.ts +3 -3
  36. package/dist/esm/index.d.ts.map +1 -1
  37. package/dist/esm/index.js +2 -2
  38. package/dist/esm/index.js.map +1 -1
  39. package/dist/esm/internal.d.ts +5 -0
  40. package/dist/esm/internal.d.ts.map +1 -0
  41. package/dist/esm/internal.js +14 -0
  42. package/dist/esm/internal.js.map +1 -0
  43. package/dist/esm/node/deployment.d.ts +45 -0
  44. package/dist/esm/node/deployment.d.ts.map +1 -0
  45. package/dist/esm/node/deployment.js +163 -0
  46. package/dist/esm/node/deployment.js.map +1 -0
  47. package/dist/esm/node/native-udp.d.ts +38 -0
  48. package/dist/esm/node/native-udp.d.ts.map +1 -0
  49. package/dist/esm/node/native-udp.js +291 -0
  50. package/dist/esm/node/native-udp.js.map +1 -0
  51. package/dist/esm/node/nhp-wire.d.ts +36 -0
  52. package/dist/esm/node/nhp-wire.d.ts.map +1 -0
  53. package/dist/esm/node/nhp-wire.js +257 -0
  54. package/dist/esm/node/nhp-wire.js.map +1 -0
  55. package/dist/esm/node/portal-opener.d.ts +84 -0
  56. package/dist/esm/node/portal-opener.d.ts.map +1 -0
  57. package/dist/esm/node/portal-opener.js +757 -0
  58. package/dist/esm/node/portal-opener.js.map +1 -0
  59. package/dist/esm/node/qv2.d.ts +39 -0
  60. package/dist/esm/node/qv2.d.ts.map +1 -0
  61. package/dist/esm/node/qv2.js +286 -0
  62. package/dist/esm/node/qv2.js.map +1 -0
  63. package/dist/esm/node/strict-json.d.ts +13 -0
  64. package/dist/esm/node/strict-json.d.ts.map +1 -0
  65. package/dist/esm/node/strict-json.js +171 -0
  66. package/dist/esm/node/strict-json.js.map +1 -0
  67. package/dist/esm/node.d.ts +4 -0
  68. package/dist/esm/node.d.ts.map +1 -0
  69. package/dist/esm/node.js +2 -0
  70. package/dist/esm/node.js.map +1 -0
  71. package/dist/esm/types.d.ts +90 -18
  72. package/dist/esm/types.d.ts.map +1 -1
  73. package/dist/esm/version.d.ts +1 -1
  74. package/dist/esm/version.js +1 -1
  75. package/package.json +25 -6
@@ -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
- // Network-error retries still reuse Idempotency-Key below.
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 the offending HTTP status (400/207/etc.) isn't
322
- * the thing being reported — the shape mismatch is.
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, request_id) {
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(`max_sessions: must be an integer between 0 and ${MAX_MAX_SESSIONS} (got ${value})`);
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).replace(/\/+$/, "");
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
- /** Create a new qURL. */
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.rawRequest("DELETE", `/v1/qurls/${encodeURIComponent(id)}`);
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
- /** Create a resource directly. */
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.rawRequest("DELETE", `/v1/resources/${encodeURIComponent(id)}`);
2356
+ await this.requestNoContent(`/v1/resources/${encodeURIComponent(id)}`);
1589
2357
  }
1590
- /** Mint a qURL against an existing resource. */
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.rawRequest("DELETE", `/v1/resources/${encodeURIComponent(id)}/qurls/${encodeURIComponent(qurlId)}`);
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.rawRequest("DELETE", `/v1/resources/${encodeURIComponent(id)}/sessions/${encodeURIComponent(sessionId)}`);
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.rawRequest("DELETE", `/v1/domains/${encodeURIComponent(domain)}`);
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.rawRequest("DELETE", `/v1/webhooks/${encodeURIComponent(id)}`);
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.rawRequest("DELETE", `/v1/api-keys/${encodeURIComponent(keyId)}`);
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.rawRequest("DELETE", `/v1/access-codes/${encodeURIComponent(id)}`);
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
- // POST/PATCH keep status-code retries limited to rate limits. They
1985
- // still retry fetch-level failures below with the same Idempotency-Key,
1986
- // which fixes the duplicate-creation path from lost responses
1987
- // without broadening mutating 5xx replay behavior.
1988
- const mutatingForRetry = MUTATING_RETRY_METHODS.has(method);
1989
- const retryable = mutatingForRetry ? RETRYABLE_STATUS_MUTATING : RETRYABLE_STATUS;
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
- signal: AbortSignal.timeout(this.timeout),
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
- if (attempt < this.maxRetries) {
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 { data: undefined, __http_status: response.status };
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 = (await response.json());
2053
- return { ...json, __http_status: response.status };
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 stream is already consumed by the failed `.json()`,
2059
- // so we can't delegate to parseError — synthesize a typed
2060
- // QURLError directly so consumers catching by-class don't
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") ?? undefined,
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 = await this.parseError(response);
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
- async parseError(response) {
3029
+ parseError(response, responseBody) {
2085
3030
  try {
2086
- const json = (await response.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 ?? err.message ?? err.title ?? `HTTP ${response.status}`;
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 ?? (response.statusText || `HTTP ${response.status}`);
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: err.status ?? response.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: Object.keys(json),
3070
+ body_keys: typeof json === "object" && json !== null ? boundedObjectKeys(json) : undefined,
2117
3071
  });
2118
3072
  }
2119
3073
  catch {
2120
- // Body wasn't valid JSON (or the network stream errored during
2121
- // read). Log so operators can distinguish this from a malformed
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") ?? undefined,
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: response.statusText || `HTTP ${response.status}`,
2132
- detail: response.statusText || `HTTP ${response.status}`,
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. (RFC 7231
2137
- // also allows Retry-After on 3xx redirects; the SDK doesn't follow
2138
- // redirects, so 429/503 cover the relevant cases.)
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
- if (err instanceof DOMException && err.name === "TimeoutError") {
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