@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,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
- // Network-error retries still reuse Idempotency-Key below.
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 the offending HTTP status (400/207/etc.) isn't
325
- * the thing being reported — the shape mismatch is.
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, request_id) {
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(`max_sessions: must be an integer between 0 and ${MAX_MAX_SESSIONS} (got ${value})`);
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).replace(/\/+$/, "");
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
- /** Create a new qURL. */
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.rawRequest("DELETE", `/v1/qurls/${encodeURIComponent(id)}`);
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
- /** Create a resource directly. */
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.rawRequest("DELETE", `/v1/resources/${encodeURIComponent(id)}`);
2359
+ await this.requestNoContent(`/v1/resources/${encodeURIComponent(id)}`);
1592
2360
  }
1593
- /** Mint a qURL against an existing resource. */
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.rawRequest("DELETE", `/v1/resources/${encodeURIComponent(id)}/qurls/${encodeURIComponent(qurlId)}`);
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.rawRequest("DELETE", `/v1/resources/${encodeURIComponent(id)}/sessions/${encodeURIComponent(sessionId)}`);
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.rawRequest("DELETE", `/v1/domains/${encodeURIComponent(domain)}`);
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.rawRequest("DELETE", `/v1/webhooks/${encodeURIComponent(id)}`);
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.rawRequest("DELETE", `/v1/api-keys/${encodeURIComponent(keyId)}`);
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.rawRequest("DELETE", `/v1/access-codes/${encodeURIComponent(id)}`);
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
- // POST/PATCH keep status-code retries limited to rate limits. They
1988
- // still retry fetch-level failures below with the same Idempotency-Key,
1989
- // which fixes the duplicate-creation path from lost responses
1990
- // without broadening mutating 5xx replay behavior.
1991
- const mutatingForRetry = MUTATING_RETRY_METHODS.has(method);
1992
- const retryable = mutatingForRetry ? RETRYABLE_STATUS_MUTATING : RETRYABLE_STATUS;
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
- signal: AbortSignal.timeout(this.timeout),
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
- if (attempt < this.maxRetries) {
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 { data: undefined, __http_status: response.status };
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 = (await response.json());
2056
- return { ...json, __http_status: response.status };
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 stream is already consumed by the failed `.json()`,
2062
- // so we can't delegate to parseError — synthesize a typed
2063
- // QURLError directly so consumers catching by-class don't
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") ?? undefined,
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 = await this.parseError(response);
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
- async parseError(response) {
3032
+ parseError(response, responseBody) {
2088
3033
  try {
2089
- const json = (await response.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 ?? err.message ?? err.title ?? `HTTP ${response.status}`;
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 ?? (response.statusText || `HTTP ${response.status}`);
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: err.status ?? response.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: Object.keys(json),
3073
+ body_keys: typeof json === "object" && json !== null ? boundedObjectKeys(json) : undefined,
2120
3074
  });
2121
3075
  }
2122
3076
  catch {
2123
- // Body wasn't valid JSON (or the network stream errored during
2124
- // read). Log so operators can distinguish this from a malformed
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") ?? undefined,
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: response.statusText || `HTTP ${response.status}`,
2135
- detail: response.statusText || `HTTP ${response.status}`,
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. (RFC 7231
2140
- // also allows Retry-After on 3xx redirects; the SDK doesn't follow
2141
- // redirects, so 429/503 cover the relevant cases.)
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
- if (err instanceof DOMException && err.name === "TimeoutError") {
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;