@layervai/qurl 0.2.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.
@@ -0,0 +1,2204 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.QURLClient = void 0;
4
+ const errors_js_1 = require("./errors.js");
5
+ const version_js_1 = require("./version.js");
6
+ const DEFAULT_BASE_URL = "https://api.layerv.ai";
7
+ const DEFAULT_MAX_RETRIES = 3;
8
+ const DEFAULT_TIMEOUT = 30_000;
9
+ const RETRY_BASE_DELAY_MS = 500;
10
+ // Bounds local exponential backoff (NOT server-asserted Retry-After —
11
+ // see `RETRY_AFTER_HARD_CAP_MS` for that).
12
+ const RETRY_MAX_DELAY_MS = 30_000;
13
+ // Hard cap on server-asserted Retry-After. Guards against `Retry-After`
14
+ // values >2^31-1 ms overflowing Node's setTimeout (silently truncated to
15
+ // 1ms — turns a "wait a month" directive into a hot retry loop).
16
+ const RETRY_AFTER_HARD_CAP_MS = 60 * 60 * 1000;
17
+ const RETRY_AFTER_PARSE_LIMIT_S = RETRY_AFTER_HARD_CAP_MS / 1000;
18
+ const RETRYABLE_STATUS = new Set([429, 502, 503, 504]);
19
+ // Keep mutating status retries 429-only until the service-side idempotency
20
+ // cache contract is proven across 5xx, TTL, and replay-window edge cases.
21
+ // Network-error retries still reuse Idempotency-Key below.
22
+ const RETRYABLE_STATUS_MUTATING = new Set([429]);
23
+ const IDEMPOTENCY_KEY_METHODS = new Set(["POST", "PATCH"]);
24
+ const MUTATING_RETRY_METHODS = new Set(["POST", "PATCH"]);
25
+ const MAX_IDEMPOTENCY_KEY = 256;
26
+ const IDEMPOTENCY_KEY_VALUE_RE = /^[\x21-\x7e](?:[\x20-\x7e]*[\x21-\x7e])?$/;
27
+ const UUID_HEX = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, "0"));
28
+ const NO_PASSTHROUGH_STATUSES = [];
29
+ const BATCH_PASSTHROUGH_STATUSES = [400];
30
+ /** Allowlist of known query-param keys for the `list()` endpoint. */
31
+ const LIST_PARAM_KEYS = [
32
+ "limit",
33
+ "cursor",
34
+ "status",
35
+ "q",
36
+ "sort",
37
+ "created_after",
38
+ "created_before",
39
+ "expires_before",
40
+ "expires_after",
41
+ ];
42
+ const REQUEST_OPTION_KEYS = ["idempotencyKey"];
43
+ // Compile-time witness: `Exclude<keyof X, (typeof KEYS)[number]>` is
44
+ // `never` iff KEYS lists every key of X. Paired with the
45
+ // `satisfies readonly (keyof X)[]` clause on the array (which catches
46
+ // the other direction), this gives bidirectional drift detection on
47
+ // the *_KEYS allowlists at zero runtime cost.
48
+ function assertExhaustive(_) {
49
+ /* type-only check */
50
+ }
51
+ assertExhaustive(true);
52
+ assertExhaustive(true);
53
+ const CREATE_FIELD_KEYS = [
54
+ "type",
55
+ "target_url",
56
+ "expires_in",
57
+ "one_time_use",
58
+ "max_sessions",
59
+ "session_duration",
60
+ "label",
61
+ "access_policy",
62
+ "custom_domain",
63
+ ];
64
+ assertExhaustive(true);
65
+ /**
66
+ * Fields accepted by `update()`. The empty-input pre-flight check in
67
+ * {@link QURLClient.update} iterates this const — so adding a new field to
68
+ * {@link UpdateInput} without listing it here will fail the
69
+ * paired `assertExhaustive` witness at compile time rather than silently
70
+ * sneaking through an empty-input request.
71
+ */
72
+ const UPDATE_FIELD_KEYS = [
73
+ "extend_by",
74
+ "expires_at",
75
+ "description",
76
+ "tags",
77
+ ];
78
+ assertExhaustive(true);
79
+ /**
80
+ * Fields accepted by `mintLink()`. Iterated by the null-stripping
81
+ * normalization loop in {@link QURLClient.mintLink} so unknown keys
82
+ * from untyped-JS callers don't leak through to the wire body.
83
+ * Symmetric with `LIST_PARAM_KEYS` / `UPDATE_FIELD_KEYS`.
84
+ */
85
+ const MINT_FIELD_KEYS = [
86
+ "expires_in",
87
+ "expires_at",
88
+ "label",
89
+ "one_time_use",
90
+ "max_sessions",
91
+ "session_duration",
92
+ "access_policy",
93
+ ];
94
+ assertExhaustive(true);
95
+ const RESOURCE_LIST_PARAM_KEYS = [
96
+ "cursor",
97
+ "limit",
98
+ "alias",
99
+ "slug",
100
+ "status",
101
+ "type",
102
+ ];
103
+ assertExhaustive(true);
104
+ const CREATE_RESOURCE_FIELD_KEYS = [
105
+ "type",
106
+ "target_url",
107
+ "description",
108
+ "tags",
109
+ "custom_domain",
110
+ "alias",
111
+ "slug",
112
+ "find_or_create",
113
+ ];
114
+ assertExhaustive(true);
115
+ const UPDATE_RESOURCE_FIELD_KEYS = [
116
+ "description",
117
+ "tags",
118
+ "custom_domain",
119
+ "preserve_host",
120
+ "alias",
121
+ ];
122
+ assertExhaustive(true);
123
+ const CREATE_QURL_FOR_RESOURCE_FIELD_KEYS = [
124
+ "expires_in",
125
+ "one_time_use",
126
+ "max_sessions",
127
+ "session_duration",
128
+ "label",
129
+ "access_policy",
130
+ ];
131
+ assertExhaustive(true);
132
+ const UPDATE_RESOURCE_QURL_FIELD_KEYS = [
133
+ "extend_by",
134
+ "expires_at",
135
+ "label",
136
+ "access_policy",
137
+ "max_sessions",
138
+ "session_duration",
139
+ ];
140
+ assertExhaustive(true);
141
+ const AGENT_BOOTSTRAP_FIELD_KEYS = [
142
+ "public_key",
143
+ "agent_id",
144
+ "hostname",
145
+ "version",
146
+ ];
147
+ assertExhaustive(true);
148
+ const UPDATE_CUSTOMER_FIELD_KEYS = [
149
+ "spending_cap_cents",
150
+ ];
151
+ assertExhaustive(true);
152
+ const CREATE_BILLING_CHECKOUT_FIELD_KEYS = [
153
+ "plan",
154
+ ];
155
+ assertExhaustive(true);
156
+ const REGISTER_DOMAIN_FIELD_KEYS = [
157
+ "domain",
158
+ ];
159
+ assertExhaustive(true);
160
+ const CREATE_WEBHOOK_FIELD_KEYS = [
161
+ "url",
162
+ "events",
163
+ "description",
164
+ ];
165
+ assertExhaustive(true);
166
+ const UPDATE_WEBHOOK_FIELD_KEYS = [
167
+ "url",
168
+ "events",
169
+ "description",
170
+ "status",
171
+ ];
172
+ assertExhaustive(true);
173
+ const CREATE_API_KEY_FIELD_KEYS = [
174
+ "name",
175
+ "scopes",
176
+ "expires_in",
177
+ "purpose",
178
+ "tunnel_slug",
179
+ ];
180
+ assertExhaustive(true);
181
+ const UPDATE_API_KEY_FIELD_KEYS = [
182
+ "name",
183
+ "scopes",
184
+ ];
185
+ assertExhaustive(true);
186
+ const REDEEM_ACCESS_CODE_FIELD_KEYS = [
187
+ "code",
188
+ "honeypot",
189
+ "elapsed_ms",
190
+ ];
191
+ assertExhaustive(true);
192
+ const CREATE_ACCESS_CODE_FIELD_KEYS = [
193
+ "resource_id",
194
+ "name",
195
+ "max_uses",
196
+ "expires_at",
197
+ ];
198
+ assertExhaustive(true);
199
+ const CONNECTOR_INSTALLATION_LIST_PARAM_KEYS = [
200
+ "cursor",
201
+ "limit",
202
+ ];
203
+ assertExhaustive(true);
204
+ const BILLING_INVOICE_LIST_PARAM_KEYS = [
205
+ "limit",
206
+ "cursor",
207
+ ];
208
+ assertExhaustive(true);
209
+ const DOMAIN_LIST_PARAM_KEYS = [
210
+ "limit",
211
+ "cursor",
212
+ ];
213
+ assertExhaustive(true);
214
+ const WEBHOOK_LIST_PARAM_KEYS = [
215
+ "limit",
216
+ "cursor",
217
+ ];
218
+ assertExhaustive(true);
219
+ const WEBHOOK_DELIVERY_LIST_PARAM_KEYS = [
220
+ "limit",
221
+ "cursor",
222
+ ];
223
+ assertExhaustive(true);
224
+ const API_KEY_LIST_PARAM_KEYS = [
225
+ "limit",
226
+ "cursor",
227
+ "status",
228
+ ];
229
+ assertExhaustive(true);
230
+ /**
231
+ * Construct a {@link ValidationError} for a client-side pre-flight check.
232
+ * Uses `status: 0` (matching {@link NetworkError}/{@link TimeoutError}) and
233
+ * `code: "client_validation"` so catch-by-class still works and callers can
234
+ * tell the error originated inside the SDK rather than from the API.
235
+ */
236
+ function clientValidationError(detail) {
237
+ return new errors_js_1.ValidationError({
238
+ status: 0,
239
+ code: errors_js_1.ERROR_CODE_CLIENT_VALIDATION,
240
+ title: "Invalid Argument",
241
+ detail,
242
+ });
243
+ }
244
+ function validateRequestOptions(options) {
245
+ if (options === undefined) {
246
+ return;
247
+ }
248
+ if (typeof options !== "object" || options === null || Array.isArray(options)) {
249
+ throw clientValidationError(`request options: must be an object (got ${options === null ? "null" : describeShape(options)})`);
250
+ }
251
+ requireNoUnknownFields(options, REQUEST_OPTION_KEYS, "request options");
252
+ const key = options.idempotencyKey;
253
+ if (key !== undefined) {
254
+ if (typeof key !== "string" || key.length === 0) {
255
+ throw clientValidationError("idempotencyKey: must be a non-empty string");
256
+ }
257
+ requireMaxLength(key, "idempotencyKey", MAX_IDEMPOTENCY_KEY);
258
+ if (!IDEMPOTENCY_KEY_VALUE_RE.test(key)) {
259
+ throw clientValidationError("idempotencyKey: must contain only printable ASCII characters and must not start or end with spaces");
260
+ }
261
+ }
262
+ }
263
+ function idempotencyKeyForRequest(method, options) {
264
+ // Validate before method gating so future read-only methods that accept
265
+ // RequestOptions fail loudly on malformed options instead of discarding them.
266
+ validateRequestOptions(options);
267
+ if (!IDEMPOTENCY_KEY_METHODS.has(method)) {
268
+ return undefined;
269
+ }
270
+ return options?.idempotencyKey ?? generateUuidV7();
271
+ }
272
+ function generateUuidV7() {
273
+ const bytes = new Uint8Array(16);
274
+ fillRandomBytes(bytes);
275
+ const timestamp = Date.now();
276
+ // Avoid right-shift operators here: JS bitwise shifts truncate to 32 bits,
277
+ // while UUIDv7 stores a 48-bit millisecond timestamp.
278
+ bytes[0] = Math.floor(timestamp / 0x10000000000) & 0xff;
279
+ bytes[1] = Math.floor(timestamp / 0x100000000) & 0xff;
280
+ bytes[2] = Math.floor(timestamp / 0x1000000) & 0xff;
281
+ bytes[3] = Math.floor(timestamp / 0x10000) & 0xff;
282
+ bytes[4] = Math.floor(timestamp / 0x100) & 0xff;
283
+ bytes[5] = timestamp & 0xff;
284
+ bytes[6] = (bytes[6] & 0x0f) | 0x70;
285
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
286
+ return (UUID_HEX[bytes[0]] +
287
+ UUID_HEX[bytes[1]] +
288
+ UUID_HEX[bytes[2]] +
289
+ UUID_HEX[bytes[3]] +
290
+ "-" +
291
+ UUID_HEX[bytes[4]] +
292
+ UUID_HEX[bytes[5]] +
293
+ "-" +
294
+ UUID_HEX[bytes[6]] +
295
+ UUID_HEX[bytes[7]] +
296
+ "-" +
297
+ UUID_HEX[bytes[8]] +
298
+ UUID_HEX[bytes[9]] +
299
+ "-" +
300
+ UUID_HEX[bytes[10]] +
301
+ UUID_HEX[bytes[11]] +
302
+ UUID_HEX[bytes[12]] +
303
+ UUID_HEX[bytes[13]] +
304
+ UUID_HEX[bytes[14]] +
305
+ UUID_HEX[bytes[15]]);
306
+ }
307
+ function fillRandomBytes(bytes) {
308
+ const crypto = globalThis.crypto;
309
+ if (crypto?.getRandomValues) {
310
+ crypto.getRandomValues(bytes);
311
+ return;
312
+ }
313
+ throw new errors_js_1.RuntimeError("globalThis.crypto.getRandomValues is required to generate Idempotency-Key");
314
+ }
315
+ /**
316
+ * Construct a {@link ValidationError} for the case where the API returned
317
+ * a response that parsed as JSON but didn't match the expected schema
318
+ * shape (e.g. 400 with a body that isn't a {@link BatchCreateOutput}).
319
+ *
320
+ * Distinct from {@link clientValidationError} so callers can `.code`-branch
321
+ * between "I passed bad input locally" (`"client_validation"`) and "the
322
+ * server returned a body I can't interpret" (`"unexpected_response"`).
323
+ * Uses `status: 0` because the offending HTTP status (400/207/etc.) isn't
324
+ * the thing being reported — the shape mismatch is.
325
+ *
326
+ * Threads `request_id` through to {@link QURLError.requestId} when the
327
+ * server-side correlation ID is available — operators debugging
328
+ * "unexpected response" tickets need it on the *error* path too, not
329
+ * just on success/passthrough returns.
330
+ */
331
+ function unexpectedResponseError(detail, request_id) {
332
+ return new errors_js_1.ValidationError({
333
+ status: 0,
334
+ code: errors_js_1.ERROR_CODE_UNEXPECTED_RESPONSE,
335
+ title: "Unexpected Response",
336
+ detail,
337
+ request_id,
338
+ });
339
+ }
340
+ // ---- Spec-derived validation helpers ------------------------------------
341
+ // These mirror constraints documented on each request schema in
342
+ // `openapi.yaml` so obvious mistakes fail fast instead of round-tripping
343
+ // to the API and coming back as a generic 400.
344
+ const MAX_TARGET_URL = 2048;
345
+ const MAX_LABEL = 500;
346
+ const MAX_BATCH_ITEMS = 100;
347
+ const MAX_DESCRIPTION = 500;
348
+ const MAX_CUSTOM_DOMAIN = 253;
349
+ const MAX_MAX_SESSIONS = 1000;
350
+ const MAX_API_KEY_NAME = 100;
351
+ const MAX_ALIAS = 64;
352
+ const MAX_SLUG = 64;
353
+ const MAX_TAGS = 10;
354
+ const MAX_TAG_LENGTH = 50;
355
+ const MAX_AUTO_PAGINATION_PAGES = 10_000;
356
+ // CreateQurlRequest.target_url pattern is loose (just a URI) but
357
+ // UpdateQurlRequest.tags pattern is specific — enforce it here.
358
+ const TAG_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9 _-]*$/;
359
+ const RESOURCE_ID_PREFIX = "r_";
360
+ function requireMaxLength(value, field, max) {
361
+ if (value === undefined)
362
+ return;
363
+ // Untyped-JS safety: a non-string `value` would silently pass since
364
+ // `(42).length === undefined > max` is `false` and the bad value would
365
+ // sail through to the wire. Match the typeof guard pattern used by
366
+ // requireValidTags / requireValidTargetUrl.
367
+ if (typeof value !== "string") {
368
+ throw clientValidationError(`${field}: must be a string (got ${value === null ? "null" : typeof value})`);
369
+ }
370
+ if (value.length > max) {
371
+ throw clientValidationError(`${field}: must be ${max} characters or fewer (got ${value.length})`);
372
+ }
373
+ }
374
+ function requireNonEmptyIfPresent(value, field) {
375
+ // Reject `""` so uninitialized form state doesn't reach the wire.
376
+ if (value === "") {
377
+ throw clientValidationError(`${field}: must not be an empty string`);
378
+ }
379
+ }
380
+ function requireMaxSessionsInRange(value) {
381
+ if (value === undefined)
382
+ return;
383
+ if (!Number.isInteger(value) || value < 0 || value > MAX_MAX_SESSIONS) {
384
+ throw clientValidationError(`max_sessions: must be an integer between 0 and ${MAX_MAX_SESSIONS} (got ${value})`);
385
+ }
386
+ }
387
+ function requireBooleanIfPresent(value, field) {
388
+ if (value === undefined)
389
+ return;
390
+ if (typeof value !== "boolean") {
391
+ throw clientValidationError(`${field}: must be a boolean (got ${describeShape(value)})`);
392
+ }
393
+ }
394
+ function requireListLimitInRange(value, methodName) {
395
+ if (value === undefined || value === null)
396
+ return;
397
+ // All current paginated OpenAPI query params share 1..100. If a future
398
+ // endpoint diverges, add a per-endpoint bound rather than changing this
399
+ // shared guard silently.
400
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > 100) {
401
+ const rendered = typeof value === "string" ? JSON.stringify(value) : String(value);
402
+ throw clientValidationError(`${methodName}: limit: must be an integer between 1 and 100 (got ${rendered})`);
403
+ }
404
+ }
405
+ function appendQuery(path, input, keys, methodName) {
406
+ validateQueryInput(input, keys, methodName);
407
+ const params = new URLSearchParams();
408
+ for (const key of keys) {
409
+ const value = input[key];
410
+ if (value === null || value === undefined || value === "")
411
+ continue;
412
+ params.set(key, String(value));
413
+ }
414
+ const query = params.toString();
415
+ return query ? `${path}?${query}` : path;
416
+ }
417
+ function validateQueryInput(input, keys, methodName) {
418
+ requireNoUnknownFields(input, keys, methodName);
419
+ if (keys.includes("limit")) {
420
+ requireListLimitInRange(input.limit, methodName);
421
+ }
422
+ for (const key of keys) {
423
+ const value = input[key];
424
+ if (value === null || value === undefined || value === "")
425
+ continue;
426
+ if (key === "limit")
427
+ continue;
428
+ if (typeof value !== "string") {
429
+ throw clientValidationError(`${methodName}: ${key}: must be a string (got ${describeShape(value)})`);
430
+ }
431
+ }
432
+ }
433
+ function validateListAllInput(input, keys, methodName) {
434
+ // Eager validation keeps listAll* call sites consistent with single-page
435
+ // list methods: bad input throws before the async generator is consumed.
436
+ // paginateAll intentionally re-enters the single-page method per page, so
437
+ // each request revalidates the threaded cursor/filter set before dispatch.
438
+ validateQueryInput(input, keys, methodName);
439
+ }
440
+ function pageFromMeta(out, meta) {
441
+ return {
442
+ ...out,
443
+ next_cursor: meta?.next_cursor,
444
+ has_more: meta?.has_more ?? Boolean(meta?.next_cursor),
445
+ request_id: meta?.request_id,
446
+ page_size: meta?.page_size,
447
+ };
448
+ }
449
+ /**
450
+ * Validates that a path-parameter argument is a non-empty string. Some callers
451
+ * pass resource/qURL display IDs, while newer endpoints also pass domains,
452
+ * session IDs, webhook IDs, and API-key IDs, so this intentionally enforces
453
+ * only basic shape and leaves endpoint-specific grammar to the service.
454
+ */
455
+ function requireNonEmptyId(id, method, field = "id") {
456
+ // `.trim()` catches whitespace-only and padded IDs before they round-trip as
457
+ // `%20...%20` paths that the server can only reject with a less useful 404.
458
+ // The pre-flight error is more actionable than the 404.
459
+ if (typeof id !== "string" || id.trim() === "") {
460
+ throw clientValidationError(`${method}: ${field} is required`);
461
+ }
462
+ if (id.trim() !== id) {
463
+ throw clientValidationError(`${method}: ${field} must not include leading or trailing whitespace`);
464
+ }
465
+ }
466
+ function requireValidTags(tags) {
467
+ // null tolerance for untyped-JS callers — treated as "don't touch."
468
+ // Duplicates pass through to the server (authoritative on dedupe policy).
469
+ if (tags === undefined || tags === null)
470
+ return;
471
+ if (!Array.isArray(tags)) {
472
+ throw clientValidationError(`tags: must be an array of strings (got ${typeof tags})`);
473
+ }
474
+ if (tags.length > MAX_TAGS) {
475
+ throw clientValidationError(`tags: max ${MAX_TAGS} items allowed (got ${tags.length})`);
476
+ }
477
+ const errors = [];
478
+ for (let i = 0; i < tags.length; i++) {
479
+ const tag = tags[i];
480
+ // Per-element type guard for untyped-JS callers. Without this, a
481
+ // non-string element would silently pass (`.length` on a number is
482
+ // `undefined`; `TAG_PATTERN.test(42)` coerces to `"42"`) or TypeError
483
+ // (`null.length`).
484
+ if (typeof tag !== "string") {
485
+ errors.push(`tags[${i}]: must be a string (got ${tag === null ? "null" : typeof tag})`);
486
+ continue;
487
+ }
488
+ if (tag.length < 1 || tag.length > MAX_TAG_LENGTH) {
489
+ errors.push(`tags[${i}]: must be 1-${MAX_TAG_LENGTH} characters (got ${tag.length})`);
490
+ continue;
491
+ }
492
+ if (!TAG_PATTERN.test(tag)) {
493
+ errors.push(`tags[${i}]: must start with an alphanumeric and contain only letters, numbers, spaces, underscores, or hyphens`);
494
+ }
495
+ }
496
+ if (errors.length > 0) {
497
+ throw clientValidationError(errors.join("; "));
498
+ }
499
+ }
500
+ function describeShape(value) {
501
+ if (value === null)
502
+ return "null";
503
+ if (Array.isArray(value))
504
+ return "array";
505
+ return typeof value;
506
+ }
507
+ const ACCESS_POLICY_LIST_FIELDS = [
508
+ "ip_allowlist",
509
+ "ip_denylist",
510
+ "geo_allowlist",
511
+ "geo_denylist",
512
+ ];
513
+ const ACCESS_POLICY_STRING_FIELDS = [
514
+ "user_agent_allow_regex",
515
+ "user_agent_deny_regex",
516
+ ];
517
+ const AI_AGENT_POLICY_LIST_FIELDS = [
518
+ "deny_categories",
519
+ "allow_categories",
520
+ ];
521
+ function requireValidAccessPolicy(policy) {
522
+ // null/undefined → "don't touch". Server is authoritative on CIDR /
523
+ // ISO-3166 / regex grammar; this guard catches only the shape errors
524
+ // an untyped-JS caller is likely to make (e.g. `geo_allowlist: "US"`
525
+ // instead of `["US"]`) so they get a structured ValidationError
526
+ // instead of a server 400 they have to debug.
527
+ if (policy === undefined || policy === null)
528
+ return;
529
+ if (typeof policy !== "object" || Array.isArray(policy)) {
530
+ throw clientValidationError(`access_policy: must be an object (got ${describeShape(policy)})`);
531
+ }
532
+ for (const field of ACCESS_POLICY_LIST_FIELDS) {
533
+ const value = policy[field];
534
+ if (value === undefined)
535
+ continue;
536
+ if (!Array.isArray(value)) {
537
+ throw clientValidationError(`access_policy.${field}: must be an array (got ${describeShape(value)})`);
538
+ }
539
+ }
540
+ for (const field of ACCESS_POLICY_STRING_FIELDS) {
541
+ const value = policy[field];
542
+ if (value === undefined)
543
+ continue;
544
+ if (typeof value !== "string") {
545
+ throw clientValidationError(`access_policy.${field}: must be a string (got ${describeShape(value)})`);
546
+ }
547
+ }
548
+ const aip = policy.ai_agent_policy;
549
+ if (aip !== undefined) {
550
+ if (aip === null || typeof aip !== "object" || Array.isArray(aip)) {
551
+ throw clientValidationError(`access_policy.ai_agent_policy: must be an object (got ${describeShape(aip)})`);
552
+ }
553
+ if (aip.block_all !== undefined && typeof aip.block_all !== "boolean") {
554
+ throw clientValidationError(`access_policy.ai_agent_policy.block_all: must be a boolean (got ${describeShape(aip.block_all)})`);
555
+ }
556
+ for (const field of AI_AGENT_POLICY_LIST_FIELDS) {
557
+ const value = aip[field];
558
+ if (value === undefined)
559
+ continue;
560
+ if (!Array.isArray(value)) {
561
+ throw clientValidationError(`access_policy.ai_agent_policy.${field}: must be an array (got ${describeShape(value)})`);
562
+ }
563
+ }
564
+ }
565
+ }
566
+ // `format: uri` in the OpenAPI spec allows schemes the SDK doesn't
567
+ // usefully support (`ftp://`, `file://`, `javascript:`, …). This is a
568
+ // cheap client-side sanity check — the server is still the
569
+ // authoritative validator (e.g. it rejects localhost, cloud metadata,
570
+ // and private-range hosts; the SDK doesn't need to duplicate that).
571
+ const ALLOWED_URL_SCHEMES = ["http://", "https://"];
572
+ function requireValidHttpUrl(value, field) {
573
+ // Three checks in priority order: type guard, scheme, length.
574
+ // Stop at the first failure so a non-string URL doesn't
575
+ // produce both "must be a string" AND "must be ≤ 2048 characters"
576
+ // (the length check on a non-string would also typeof-fail and
577
+ // duplicate the type message). The collect-all loop in
578
+ // validateCreateInput runs each FIELD's validator independently — within
579
+ // a single URL field itself we want fail-fast.
580
+ if (typeof value !== "string") {
581
+ throw clientValidationError(`${field}: must be a string (got ${value === null ? "null" : typeof value})`);
582
+ }
583
+ if (!ALLOWED_URL_SCHEMES.some((scheme) => value.startsWith(scheme))) {
584
+ // Truncate to keep the error message compact and avoid
585
+ // pathologically long schemes from filling logs.
586
+ const repr = JSON.stringify(value).slice(0, 40);
587
+ throw clientValidationError(`${field}: must start with http:// or https:// (got ${repr})`);
588
+ }
589
+ if (value.length > MAX_TARGET_URL) {
590
+ throw clientValidationError(`${field}: must be ${MAX_TARGET_URL} characters or fewer (got ${value.length})`);
591
+ }
592
+ }
593
+ function requireValidTargetUrl(target_url) {
594
+ requireValidHttpUrl(target_url, "target_url");
595
+ }
596
+ function validateCreateInput(input) {
597
+ // Untyped-JS guard: surface as ValidationError instead of raw TypeError.
598
+ if (typeof input !== "object" || input === null) {
599
+ throw clientValidationError(`create: input must be an object (got ${input === null ? "null" : typeof input})`);
600
+ }
601
+ // Collect-all: run every validator and aggregate the messages so a
602
+ // caller fixing multiple bad fields sees them all at once instead
603
+ // of fix-re-run-repeat. Matches the documented batchCreate UX
604
+ // ("All failures are collected into a single ValidationError")
605
+ // and mirrors the requireValidTags collect-all pattern.
606
+ const errors = [];
607
+ const collect = (fn) => {
608
+ try {
609
+ fn();
610
+ }
611
+ catch (err) {
612
+ if (err instanceof errors_js_1.ValidationError) {
613
+ errors.push(err.detail);
614
+ return;
615
+ }
616
+ throw err;
617
+ }
618
+ };
619
+ collect(() => requireNoUnknownFields(input, CREATE_FIELD_KEYS, "create"));
620
+ const resourceType = input.type;
621
+ if (resourceType !== undefined && typeof resourceType !== "string") {
622
+ errors.push(`type: must be a string (got ${resourceType === null ? "null" : typeof resourceType})`);
623
+ }
624
+ collect(() => {
625
+ if (input.target_url === undefined) {
626
+ if (resourceType === undefined || resourceType === "url") {
627
+ throw clientValidationError("target_url: is required for url qURLs");
628
+ }
629
+ return;
630
+ }
631
+ requireValidTargetUrl(input.target_url);
632
+ });
633
+ collect(() => requireMaxLength(input.label, "label", MAX_LABEL));
634
+ collect(() => requireNonEmptyIfPresent(input.label, "label"));
635
+ collect(() => requireMaxLength(input.custom_domain, "custom_domain", MAX_CUSTOM_DOMAIN));
636
+ collect(() => requireNonEmptyIfPresent(input.custom_domain, "custom_domain"));
637
+ collect(() => requireBooleanIfPresent(input.one_time_use, "one_time_use"));
638
+ collect(() => requireMaxSessionsInRange(input.max_sessions));
639
+ collect(() => requireNonEmptyIfPresent(input.expires_in, "expires_in"));
640
+ collect(() => requireNonEmptyIfPresent(input.session_duration, "session_duration"));
641
+ collect(() => requireValidAccessPolicy(input.access_policy));
642
+ if (errors.length > 0) {
643
+ // Inner `; ` is contractual (paired with batchCreate's outer ` | `).
644
+ // Future field-level validators must not aggregate via `; ` internally.
645
+ throw clientValidationError(errors.join("; "));
646
+ }
647
+ // Intentional omission: duration *grammar* (`expires_in`,
648
+ // `session_duration`) is server-authoritative. Empty strings are
649
+ // rejected up front (see collect() calls above) but unit/whitespace
650
+ // edge cases ("24hh", "1w 3d") surface as a clean server 400 — a
651
+ // client-side regex would just create a drift surface against the
652
+ // server's rego/duration parser.
653
+ //
654
+ // `access_policy` contents (CIDRs, ISO-3166 codes, regex patterns)
655
+ // are pass-through for the same reason. The shape-guard above only
656
+ // catches structural mistakes (string-instead-of-array on list fields)
657
+ // an untyped-JS caller is likely to make.
658
+ }
659
+ function validateQurlTokenOptions(input) {
660
+ if (input === undefined)
661
+ return;
662
+ requireMaxLength(input.label, "label", MAX_LABEL);
663
+ requireNonEmptyIfPresent(input.label, "label");
664
+ requireNonEmptyIfPresent(input.expires_in, "expires_in");
665
+ requireNonEmptyIfPresent(input.session_duration, "session_duration");
666
+ requireBooleanIfPresent(input.one_time_use, "one_time_use");
667
+ requireMaxSessionsInRange(input.max_sessions);
668
+ requireValidAccessPolicy(input.access_policy);
669
+ }
670
+ function validateResourceQurlUpdateInput(input) {
671
+ requireMaxLength(input.label, "label", MAX_LABEL);
672
+ requireNonEmptyIfPresent(input.label, "label");
673
+ requireNonEmptyIfPresent(input.extend_by, "extend_by");
674
+ requireNonEmptyIfPresent(input.expires_at, "expires_at");
675
+ requireNonEmptyIfPresent(input.session_duration, "session_duration");
676
+ requireMaxSessionsInRange(input.max_sessions);
677
+ requireValidAccessPolicy(input.access_policy);
678
+ if (input.extend_by !== undefined && input.expires_at !== undefined) {
679
+ throw clientValidationError("updateResourceQurl: `extend_by` and `expires_at` are mutually exclusive — provide at most one");
680
+ }
681
+ }
682
+ function requireObjectInput(input, method) {
683
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
684
+ throw clientValidationError(`${method}: input must be an object (got ${input === null ? "null" : typeof input})`);
685
+ }
686
+ }
687
+ function requireNonEmptyStringField(input, field, method) {
688
+ const value = input[field];
689
+ if (typeof value !== "string" || value.length === 0) {
690
+ throw clientValidationError(`${method}: ${field} must be a non-empty string`);
691
+ }
692
+ }
693
+ function requireNonEmptyArrayField(input, field, method) {
694
+ const value = input[field];
695
+ if (!Array.isArray(value) || value.length === 0) {
696
+ throw clientValidationError(`${method}: ${field} must be a non-empty array`);
697
+ }
698
+ }
699
+ function requireStringArrayElements(value, field, method) {
700
+ if (!Array.isArray(value)) {
701
+ throw clientValidationError(`${method}: ${field} must be a non-empty array`);
702
+ }
703
+ value.forEach((entry, index) => {
704
+ if (typeof entry !== "string" || entry.length === 0) {
705
+ throw clientValidationError(`${method}: ${field}[${index}] must be a non-empty string`);
706
+ }
707
+ });
708
+ }
709
+ function requireAtLeastOneField(input, fields, method) {
710
+ if (!fields.some((field) => input[field] !== undefined)) {
711
+ throw clientValidationError(`${method}: at least one field (${fields.join(", ")}) must be provided`);
712
+ }
713
+ }
714
+ function requireNoUnknownFields(input, fields, method) {
715
+ const unknown = Object.keys(input).filter((field) => !fields.includes(field));
716
+ if (unknown.length > 0) {
717
+ const rendered = unknown.map((field) => `"${field}"`).join(", ");
718
+ const label = unknown.length === 1 ? "unknown field" : "unknown fields";
719
+ throw clientValidationError(`${method}: ${label} ${rendered}`);
720
+ }
721
+ }
722
+ function normalizePatchFields(input, fields, options = {}) {
723
+ const preserveNull = new Set(options.preserveNullFields ?? []);
724
+ const normalized = {};
725
+ for (const key of fields) {
726
+ const value = input[key];
727
+ if (value !== undefined && (value !== null || preserveNull.has(key))) {
728
+ normalized[key] = value;
729
+ }
730
+ }
731
+ return normalized;
732
+ }
733
+ function validateResourceWriteFields(input, options = {}) {
734
+ const errors = [];
735
+ const collect = (fn) => {
736
+ try {
737
+ fn();
738
+ }
739
+ catch (err) {
740
+ if (err instanceof errors_js_1.ValidationError) {
741
+ errors.push(err.detail);
742
+ return;
743
+ }
744
+ throw err;
745
+ }
746
+ };
747
+ const resourceType = input.type;
748
+ if (resourceType !== undefined && typeof resourceType !== "string") {
749
+ errors.push(`type: must be a string (got ${resourceType === null ? "null" : typeof resourceType})`);
750
+ }
751
+ collect(() => {
752
+ if (input.target_url === undefined) {
753
+ if (options.requireUrlTarget && (resourceType === undefined || resourceType === "url")) {
754
+ throw clientValidationError("target_url: is required for url resources");
755
+ }
756
+ return;
757
+ }
758
+ requireValidTargetUrl(input.target_url);
759
+ });
760
+ collect(() => requireMaxLength(input.description, "description", MAX_DESCRIPTION));
761
+ collect(() => requireMaxLength(input.custom_domain, "custom_domain", MAX_CUSTOM_DOMAIN));
762
+ collect(() => {
763
+ if (options.allowCustomDomainClear && input.custom_domain === "")
764
+ return;
765
+ requireNonEmptyIfPresent(input.custom_domain, "custom_domain");
766
+ });
767
+ collect(() => requireValidTags(input.tags));
768
+ collect(() => {
769
+ if (input.alias !== undefined) {
770
+ if (input.alias === null) {
771
+ if (!options.allowAliasClear) {
772
+ throw clientValidationError("alias: must be a string (got null)");
773
+ }
774
+ }
775
+ else {
776
+ requireMaxLength(input.alias, "alias", MAX_ALIAS);
777
+ requireNonEmptyIfPresent(input.alias, "alias");
778
+ }
779
+ }
780
+ });
781
+ collect(() => {
782
+ if (input.slug === undefined)
783
+ return;
784
+ requireMaxLength(input.slug, "slug", MAX_SLUG);
785
+ requireNonEmptyIfPresent(input.slug, "slug");
786
+ });
787
+ collect(() => {
788
+ if (options.validateFindOrCreate &&
789
+ input.find_or_create !== undefined &&
790
+ typeof input.find_or_create !== "boolean") {
791
+ throw clientValidationError(`find_or_create: must be a boolean (got ${describeShape(input.find_or_create)})`);
792
+ }
793
+ });
794
+ collect(() => {
795
+ if (options.validatePreserveHost &&
796
+ input.preserve_host !== undefined &&
797
+ typeof input.preserve_host !== "boolean") {
798
+ throw clientValidationError(`preserve_host: must be a boolean (got ${describeShape(input.preserve_host)})`);
799
+ }
800
+ });
801
+ if (errors.length > 0) {
802
+ throw clientValidationError(errors.join("; "));
803
+ }
804
+ }
805
+ function validateWebhookWriteFields(input, method, requiredFields = {}) {
806
+ if (requiredFields.url || input.url !== undefined) {
807
+ requireNonEmptyStringField(input, "url", method);
808
+ requireValidHttpUrl(input.url, "url");
809
+ }
810
+ if (requiredFields.events || input.events !== undefined) {
811
+ requireNonEmptyArrayField(input, "events", method);
812
+ requireStringArrayElements(input.events, "events", method);
813
+ }
814
+ requireMaxLength(input.description, "description", MAX_DESCRIPTION);
815
+ if (input.status !== undefined) {
816
+ requireNonEmptyStringField(input, "status", method);
817
+ }
818
+ }
819
+ function validateApiKeyWriteFields(input, method, requiredFields = {}) {
820
+ if (requiredFields.name || input.name !== undefined) {
821
+ requireNonEmptyStringField(input, "name", method);
822
+ requireMaxLength(input.name, "name", MAX_API_KEY_NAME);
823
+ }
824
+ if (requiredFields.scopes || input.scopes !== undefined) {
825
+ requireNonEmptyArrayField(input, "scopes", method);
826
+ requireStringArrayElements(input.scopes, "scopes", method);
827
+ }
828
+ }
829
+ /**
830
+ * Per-entry shape guard for {@link BatchItemResult}. Verifies every
831
+ * non-optional field on the branch of the discriminated union selected
832
+ * by the `success` discriminant. Protects consumers who narrow on
833
+ * `success` and then access `resource_id` / `qurl_link` / `error` as
834
+ * guaranteed-present — if the API ever omits one, we trip the guard
835
+ * rather than returning `undefined` where the type says `string`.
836
+ *
837
+ * Returns `null` when the entry is valid, or a human-readable reason
838
+ * string describing the *specific* field that's missing or wrong. The
839
+ * reason never echoes the entry's values — only field names and shape
840
+ * descriptors — so the caller can safely surface it in error messages
841
+ * that may end up in observability pipelines. Contrast with the outer
842
+ * guard's intentional silence about response-body content for the
843
+ * same info-leak reason.
844
+ */
845
+ function batchItemResultValidationReason(entry) {
846
+ if (!entry || typeof entry !== "object")
847
+ return "not an object";
848
+ const e = entry;
849
+ // `Number.isInteger` rejects NaN, Infinity, and floats; combined
850
+ // with `>= 0`, the check rules out pathological proxy bodies that
851
+ // would attribute to a useless index. `index` is informational, so
852
+ // this isn't a correctness hazard, but tightening the constraint
853
+ // matches the same posture as the count integer guards above.
854
+ if (!Number.isInteger(e.index) || e.index < 0) {
855
+ return "missing/invalid 'index' (expected non-negative integer)";
856
+ }
857
+ if (e.success === true) {
858
+ if (typeof e.resource_id !== "string") {
859
+ return "success-branch missing required field 'resource_id' (expected string)";
860
+ }
861
+ if (typeof e.qurl_link !== "string") {
862
+ return "success-branch missing required field 'qurl_link' (expected string)";
863
+ }
864
+ if (typeof e.qurl_site !== "string") {
865
+ return "success-branch missing required field 'qurl_site' (expected string)";
866
+ }
867
+ return null;
868
+ }
869
+ if (e.success === false) {
870
+ if (!e.error || typeof e.error !== "object") {
871
+ return "failure-branch missing required field 'error' (expected object)";
872
+ }
873
+ const err = e.error;
874
+ if (typeof err.code !== "string") {
875
+ return "failure-branch missing required field 'error.code' (expected string)";
876
+ }
877
+ if (typeof err.message !== "string") {
878
+ return "failure-branch missing required field 'error.message' (expected string)";
879
+ }
880
+ return null;
881
+ }
882
+ return "missing/invalid 'success' discriminant (expected boolean)";
883
+ }
884
+ /** qURL API client. */
885
+ class QURLClient {
886
+ baseUrl;
887
+ apiKey;
888
+ fetchFn;
889
+ maxRetries;
890
+ timeout;
891
+ userAgent;
892
+ debugFn;
893
+ constructor(options) {
894
+ if (!options.apiKey) {
895
+ throw clientValidationError("apiKey is required");
896
+ }
897
+ // Header-injection guard: `Authorization: Bearer ${apiKey}` would
898
+ // otherwise be exploitable on a CR/LF-bearing key. Surface as
899
+ // ValidationError at construction, not as TypeError on first request.
900
+ if (/[\r\n]/.test(options.apiKey)) {
901
+ throw clientValidationError("apiKey: must not contain CR/LF characters");
902
+ }
903
+ this.apiKey = options.apiKey;
904
+ const rawBaseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
905
+ // Parse the URL so the loopback exemption matches on the hostname,
906
+ // not a string prefix. `startsWith("http://localhost")` would
907
+ // accept `http://localhost.attacker.com` and silently send the
908
+ // bearer token in plaintext to an arbitrary host — exactly what
909
+ // this guard is meant to prevent.
910
+ let parsedBaseUrl;
911
+ try {
912
+ parsedBaseUrl = new URL(rawBaseUrl);
913
+ }
914
+ catch {
915
+ throw clientValidationError(`baseUrl: must be a valid URL (got ${JSON.stringify(rawBaseUrl).slice(0, 60)})`);
916
+ }
917
+ // Reject any non-http/https scheme outright (ftp://, file://,
918
+ // javascript:, ...). The loopback exemption logic below handles
919
+ // IPv6 bracket form on `URL.hostname` — see the inner comment.
920
+ if (parsedBaseUrl.protocol !== "http:" && parsedBaseUrl.protocol !== "https:") {
921
+ throw clientValidationError(`baseUrl: must use http:// or https:// scheme (got ${JSON.stringify(rawBaseUrl).slice(0, 60)})`);
922
+ }
923
+ if (parsedBaseUrl.protocol === "http:") {
924
+ // Node's `URL.hostname` returns IPv6 hosts WITH brackets (e.g.
925
+ // `[::1]`) — verified empirically. The bare `::1` arm is defensive
926
+ // against runtimes that strip brackets (some whatwg-URL builds);
927
+ // both are cheap, keep both.
928
+ const host = parsedBaseUrl.hostname.toLowerCase();
929
+ // `0.0.0.0` is bind-all, not loopback — would route the bearer to any local listener.
930
+ const isLoopback = host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
931
+ if (!isLoopback) {
932
+ throw clientValidationError(`baseUrl: must use https:// scheme (got ${JSON.stringify(rawBaseUrl).slice(0, 60)})`);
933
+ }
934
+ }
935
+ this.baseUrl = rawBaseUrl;
936
+ this.fetchFn = options.fetch ?? globalThis.fetch;
937
+ // Clamp `maxRetries` to a non-negative integer. Negative values
938
+ // would skip the loop entirely (zero attempts, not just zero
939
+ // retries); NaN slips past `Math.max` so use `Number.isFinite`.
940
+ const requestedMaxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
941
+ this.maxRetries = Number.isFinite(requestedMaxRetries)
942
+ ? Math.max(0, Math.floor(requestedMaxRetries))
943
+ : DEFAULT_MAX_RETRIES;
944
+ // WHATWG: `AbortSignal.timeout(non-finite|≤0)` is immediate-abort.
945
+ const requestedTimeout = options.timeout ?? DEFAULT_TIMEOUT;
946
+ this.timeout =
947
+ Number.isFinite(requestedTimeout) && requestedTimeout > 0
948
+ ? requestedTimeout
949
+ : DEFAULT_TIMEOUT;
950
+ const userAgent = options.userAgent ?? `qurl-typescript/${version_js_1.VERSION}`;
951
+ if (/[\r\n]/.test(userAgent)) {
952
+ throw clientValidationError("userAgent: must not contain CR/LF characters");
953
+ }
954
+ this.userAgent = userAgent;
955
+ if (options.debug === true) {
956
+ this.debugFn = (msg, data) => console.debug(`[qurl] ${msg}`, data ?? "");
957
+ }
958
+ else if (typeof options.debug === "function") {
959
+ this.debugFn = options.debug;
960
+ }
961
+ }
962
+ /** Returns a JSON-safe representation with the API key masked. */
963
+ toJSON() {
964
+ return { baseUrl: this.baseUrl, apiKey: this.maskKey() };
965
+ }
966
+ /** Custom Node.js inspect output with the API key masked. */
967
+ [Symbol.for("nodejs.util.inspect.custom")]() {
968
+ return `QURLClient ${JSON.stringify(this.toJSON())}`;
969
+ }
970
+ maskKey() {
971
+ if (this.apiKey.length > 8) {
972
+ return this.apiKey.slice(0, 4) + "***" + this.apiKey.slice(-4);
973
+ }
974
+ return "***";
975
+ }
976
+ log(message, data) {
977
+ this.debugFn?.(message, data);
978
+ }
979
+ // --- Helpers ---
980
+ /**
981
+ * Map the API's wire-format `qurls` field to the SDK's `access_tokens`.
982
+ *
983
+ * Uses `hasOwnProperty.call` (not `if (raw.qurls)`) so empty `qurls: []`
984
+ * still enters the rename branch — consumers expect `access_tokens: []`,
985
+ * not `qurls: []` leaking through. `.call` form confines the check to
986
+ * own-properties; safe even on pre-parsed bodies from arbitrary sources.
987
+ */
988
+ mapQurlsField(raw) {
989
+ if (!Object.prototype.hasOwnProperty.call(raw, "qurls"))
990
+ return raw;
991
+ const { qurls, ...rest } = raw;
992
+ // Defensive: drop non-array `qurls` to keep `access_tokens: AccessToken[]`'s
993
+ // type promise honest under a misbehaving proxy.
994
+ if (qurls !== undefined && qurls !== null && !Array.isArray(qurls)) {
995
+ this.log("mapQurlsField: 'qurls' was not an array; dropping", {
996
+ branch: "non-array",
997
+ qurls_type: typeof qurls,
998
+ });
999
+ return rest;
1000
+ }
1001
+ // null is semantically "no tokens" — silent drop is fine, but surface
1002
+ // via debug so operators spot it if the API starts emitting null.
1003
+ if (qurls === null) {
1004
+ this.log("mapQurlsField: 'qurls' was null; dropping", { branch: "null" });
1005
+ return rest;
1006
+ }
1007
+ if (qurls === undefined) {
1008
+ // Symmetric with null / non-array branches.
1009
+ this.log("mapQurlsField: 'qurls' was undefined; dropping", { branch: "undefined" });
1010
+ return rest;
1011
+ }
1012
+ if (!rest.access_tokens) {
1013
+ return { ...rest, access_tokens: qurls };
1014
+ }
1015
+ // Server-side bug — drop `qurls` rather than merge. If the arrays
1016
+ // disagree, no consistent reconciliation exists; a silent merge
1017
+ // could double-count or surface stale data.
1018
+ this.log("mapQurlsField: received both 'qurls' and 'access_tokens'; keeping access_tokens", {
1019
+ branch: "both",
1020
+ qurls_count: qurls.length,
1021
+ access_tokens_count: rest.access_tokens.length,
1022
+ });
1023
+ return rest;
1024
+ }
1025
+ /**
1026
+ * Validate the response envelope from POST /v1/qurls/batch and return
1027
+ * the inner {@link BatchCreateOutput}. Throws
1028
+ * {@link unexpectedResponseError} on any shape-guard failure.
1029
+ *
1030
+ * Three layers of defense:
1031
+ * 1. Top-level shape (succeeded/failed are non-negative integers;
1032
+ * results is an array).
1033
+ * 2. Arithmetic invariants: `succeeded + failed === results.length`
1034
+ * and `results.length === requestItemCount` (catches silent
1035
+ * server-side item drops).
1036
+ * 3. Per-entry discriminated-union contract + index range / dedupe.
1037
+ *
1038
+ * Error messages include the observed HTTP status as a suffix so
1039
+ * operators can attribute drift; entry values are deliberately
1040
+ * never echoed (unexpected bodies may carry sensitive data).
1041
+ *
1042
+ * **Fail-fast, unlike `validateCreateInput`'s collect-all.** This is
1043
+ * a server-contract integrity check, not user input — once the envelope
1044
+ * is malformed, additional shape errors don't help debug the upstream
1045
+ * issue.
1046
+ */
1047
+ validateBatchCreateResponse(envelope, requestItemCount) {
1048
+ const result = envelope.data;
1049
+ const statusSuffix = envelope.__http_status !== undefined ? ` (HTTP ${envelope.__http_status})` : "";
1050
+ // Thread the server's request_id into the shape-guard error so
1051
+ // operators debugging "unexpected response" tickets have the
1052
+ // correlation handle on the error path, mirroring the success-
1053
+ // path propagation in batchCreate(). The field is optional —
1054
+ // older API versions without `meta.request_id` simply produce
1055
+ // an error without it, same as today. Embedded in BOTH the
1056
+ // .requestId property AND the message string so a stack trace
1057
+ // pasted into a support ticket carries the correlation handle
1058
+ // without a follow-up round-trip.
1059
+ const requestId = envelope.meta?.request_id;
1060
+ const requestIdSuffix = requestId !== undefined ? ` [request_id=${requestId}]` : "";
1061
+ // `Number.isInteger` rejects NaN, Infinity, and floats. Combined
1062
+ // with `>= 0`, this rules out pathological proxy bodies (e.g.
1063
+ // `succeeded: -1, failed: 1, results: [<one entry>]`) that the
1064
+ // arithmetic invariant below would only catch by accident. Make
1065
+ // the constraint intentional rather than incidental.
1066
+ if (!result ||
1067
+ !Number.isInteger(result.succeeded) ||
1068
+ result.succeeded < 0 ||
1069
+ !Number.isInteger(result.failed) ||
1070
+ result.failed < 0 ||
1071
+ !Array.isArray(result.results)) {
1072
+ throw unexpectedResponseError(`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}${requestIdSuffix}`, requestId);
1073
+ }
1074
+ if (result.succeeded + result.failed !== result.results.length) {
1075
+ throw unexpectedResponseError(`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}: ` +
1076
+ `counts/results length mismatch (succeeded=${result.succeeded}, ` +
1077
+ `failed=${result.failed}, results.length=${result.results.length})${requestIdSuffix}`, requestId);
1078
+ }
1079
+ // OpenAPI contract: one result per input item. Without this check, a
1080
+ // server that drops items (e.g. sends 2 results for 3 inputs with
1081
+ // succeeded=1, failed=1) passes the arithmetic invariant above and
1082
+ // every per-entry check below — but the consumer keying by `r.index`
1083
+ // would silently lose the dropped item. Combined with the per-entry
1084
+ // index range + dedup checks, this guarantees the seen-indices set is
1085
+ // exactly [0, requestItemCount).
1086
+ if (result.results.length !== requestItemCount) {
1087
+ throw unexpectedResponseError(`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}: ` +
1088
+ `results.length (${result.results.length}) does not match request item count (${requestItemCount})${requestIdSuffix}`, requestId);
1089
+ }
1090
+ // Per-entry discriminated-union contract. Reason strings report
1091
+ // field NAMES only, never values — safe for observability pipelines.
1092
+ // Short-circuit on first bad entry (server-contract check, not user
1093
+ // input). `index` is bound to `[0, requestItemCount)` and de-duped:
1094
+ // both are unambiguous server bugs that silently break per-item
1095
+ // attribution, so fail closed to match this guard's posture.
1096
+ const seenIndices = new Set();
1097
+ for (let i = 0; i < result.results.length; i++) {
1098
+ const reason = batchItemResultValidationReason(result.results[i]);
1099
+ if (reason !== null) {
1100
+ throw unexpectedResponseError(`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}: results[${i}] ${reason}${requestIdSuffix}`, requestId);
1101
+ }
1102
+ const entry = result.results[i];
1103
+ if (entry.index >= requestItemCount) {
1104
+ throw unexpectedResponseError(`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}: results[${i}] index out of range (sent ${requestItemCount} items)${requestIdSuffix}`, requestId);
1105
+ }
1106
+ if (seenIndices.has(entry.index)) {
1107
+ throw unexpectedResponseError(`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}: results[${i}] duplicate index${requestIdSuffix}`, requestId);
1108
+ }
1109
+ seenIndices.add(entry.index);
1110
+ }
1111
+ return result;
1112
+ }
1113
+ async *paginateAll(methodName, input, loadPage, itemsFromPage) {
1114
+ let cursor;
1115
+ let pageCount = 0;
1116
+ // Bounded by MAX_AUTO_PAGINATION_PAGES, so pathological cursor streams
1117
+ // cannot grow this set without limit.
1118
+ const seenCursors = new Set();
1119
+ do {
1120
+ pageCount += 1;
1121
+ const page = await loadPage({ ...input, cursor });
1122
+ for (const item of itemsFromPage(page)) {
1123
+ yield item;
1124
+ }
1125
+ cursor = page.next_cursor;
1126
+ if (page.has_more === false) {
1127
+ break;
1128
+ }
1129
+ if (!cursor) {
1130
+ break;
1131
+ }
1132
+ if (seenCursors.has(cursor)) {
1133
+ throw unexpectedResponseError(`${methodName}: server returned repeated cursor after ${pageCount} auto-pagination pages`, page.request_id);
1134
+ }
1135
+ seenCursors.add(cursor);
1136
+ if (pageCount >= MAX_AUTO_PAGINATION_PAGES) {
1137
+ throw unexpectedResponseError(`${methodName}: exceeded ${MAX_AUTO_PAGINATION_PAGES} auto-pagination pages without termination`, page.request_id);
1138
+ }
1139
+ } while (cursor);
1140
+ }
1141
+ // --- Public API ---
1142
+ /** Create a new qURL. */
1143
+ async create(input, options) {
1144
+ validateCreateInput(input);
1145
+ return this.request("POST", "/v1/qurls", input, options);
1146
+ }
1147
+ /**
1148
+ * Batch create multiple qURLs (1-`MAX_BATCH_ITEMS` items).
1149
+ *
1150
+ * **Partial failures do not throw.** Two paths resolve normally with
1151
+ * structured per-item results:
1152
+ * - **HTTP 207 Multi-Status** (some succeeded, some failed) — passes the
1153
+ * `response.ok` check naturally since 207 is in the 2xx range.
1154
+ * - **HTTP 400** (every item failed validation) — the API returns a
1155
+ * populated `BatchCreateOutput` body in this case; whitelisted via
1156
+ * `passthroughStatuses` so the structured per-item errors aren't
1157
+ * swallowed by the generic error path.
1158
+ *
1159
+ * Callers that only catch thrown errors will **silently miss partial
1160
+ * failures**. Always branch on `result.failed > 0` before treating the
1161
+ * call as successful. Other error statuses (401, 403, 429, 5xx) still
1162
+ * throw the appropriate `QURLError` subclass.
1163
+ *
1164
+ * **Client-side validation:** All items are validated before any network
1165
+ * request is made (target_url scheme, label length, max_sessions range,
1166
+ * tag constraints). All failures are collected into a single
1167
+ * `ValidationError` with per-index attribution
1168
+ * (`"items[0]: ... | items[3]: ..."`) — items are separated by ` | ` and
1169
+ * field-level errors within an item by `; ` so callers can split the
1170
+ * detail message reliably. Every problem surfaces in one throw instead
1171
+ * of fix-re-run-repeat.
1172
+ *
1173
+ * Throws `ValidationError` client-side (`status: 0`, `code: "client_validation"`)
1174
+ * when `items` is empty or exceeds `MAX_BATCH_ITEMS`, or when the
1175
+ * HTTP 400 response body doesn't match the expected `BatchCreateOutput`
1176
+ * shape (defense-in-depth for cases where the endpoint returns a non-batch
1177
+ * error on 400).
1178
+ *
1179
+ * @example
1180
+ * Always check `result.failed` after the call — exceptions alone are
1181
+ * not enough, because 207 / 400 partial failures resolve normally:
1182
+ * ```ts
1183
+ * const result = await client.batchCreate({ items });
1184
+ *
1185
+ * if (result.failed > 0) {
1186
+ * console.warn(`${result.failed}/${result.results.length} items failed`);
1187
+ * }
1188
+ *
1189
+ * // Discriminated union on `success` lets you narrow per-item:
1190
+ * for (const r of result.results) {
1191
+ * if (r.success) {
1192
+ * handleOk(r.resource_id, r.qurl_link);
1193
+ * } else {
1194
+ * handleErr(r.index, r.error.code, r.error.message);
1195
+ * }
1196
+ * }
1197
+ * ```
1198
+ */
1199
+ async batchCreate(input, options) {
1200
+ // Untyped-JS safety: the rest of the validators (requireValidTags,
1201
+ // requireMaxLength, etc.) all surface a structured ValidationError
1202
+ // for non-conforming inputs. Without this guard, `batchCreate({} as
1203
+ // any)` would throw a raw TypeError on `.length`. Match the pattern.
1204
+ if (!input || !Array.isArray(input.items)) {
1205
+ throw clientValidationError("batchCreate: items must be an array");
1206
+ }
1207
+ // Size checks are fail-fast (binary, no useful aggregation), per-item
1208
+ // validation below is collect-all (multiple bad items aggregate into
1209
+ // one throw). The asymmetry is deliberate: there's nothing to combine
1210
+ // for empty input or oversized batches — the size is wrong or it isn't.
1211
+ if (input.items.length === 0) {
1212
+ throw clientValidationError("batchCreate requires at least 1 item");
1213
+ }
1214
+ if (input.items.length > MAX_BATCH_ITEMS) {
1215
+ throw clientValidationError(`batchCreate accepts at most ${MAX_BATCH_ITEMS} items, got ${input.items.length}`);
1216
+ }
1217
+ // Collect ALL per-item validation errors in one pass (not fail-fast)
1218
+ // so callers see every problem without fix-re-run-repeat. Kept inline
1219
+ // (not unified with `validateCreateInput`'s `collect()`) because the
1220
+ // separator policy and `items[i]:` prefix differ.
1221
+ const perItemErrors = [];
1222
+ for (let i = 0; i < input.items.length; i++) {
1223
+ try {
1224
+ validateCreateInput(input.items[i]);
1225
+ }
1226
+ catch (err) {
1227
+ if (err instanceof errors_js_1.ValidationError) {
1228
+ perItemErrors.push(`items[${i}]: ${err.detail}`);
1229
+ continue;
1230
+ }
1231
+ throw err;
1232
+ }
1233
+ }
1234
+ if (perItemErrors.length > 0) {
1235
+ // Outer separator differs from `validateCreateInput`'s inner
1236
+ // `"; "` so callers can split the message reliably:
1237
+ // `items[0]: a; b | items[2]: c; d`
1238
+ // Without this asymmetry, the same `; ` at both levels makes
1239
+ // it ambiguous which separator is "next item" vs "next field
1240
+ // within item". Mirrors the JSDoc example shape.
1241
+ throw clientValidationError(`batchCreate: ${perItemErrors.join(" | ")}`);
1242
+ }
1243
+ // 400 carries per-item errors (see rawRequest JSDoc). Use rawRequest
1244
+ // directly (not `this.request`) so we can read `meta.request_id`
1245
+ // from the envelope and propagate it into the returned
1246
+ // BatchCreateOutput — consumers filing support tickets on partial
1247
+ // or total batch failures need the correlation ID.
1248
+ const envelope = await this.rawRequest("POST", "/v1/qurls/batch", input, {
1249
+ passthroughStatuses: BATCH_PASSTHROUGH_STATUSES,
1250
+ requestOptions: options,
1251
+ });
1252
+ const result = this.validateBatchCreateResponse(envelope, input.items.length);
1253
+ // Attach the server request_id from the envelope meta without
1254
+ // mutating `result` (which is the parsed JSON body). Mirrors the
1255
+ // non-mutating style used elsewhere (e.g. `mapQurlsField`). In the
1256
+ // no-request_id branch we can return `result` directly because
1257
+ // `response.json()` already produced a fresh object — a spread
1258
+ // there would be a redundant allocation. Only the attach path
1259
+ // copies. The field is optional on BatchCreateOutput so older API
1260
+ // versions that omit `meta.request_id` still produce a valid
1261
+ // return value.
1262
+ //
1263
+ // If a future server payload puts `request_id` on both `data` and
1264
+ // `meta`, the spread `{ ...result, request_id: requestId }` makes
1265
+ // `meta` win — that's deliberate: the type only documents `meta`
1266
+ // as the source, and `meta.request_id` is the canonical envelope
1267
+ // field. The data-side variant would be a wire-format duplication
1268
+ // we don't want to silently honor.
1269
+ const requestId = envelope.meta?.request_id;
1270
+ // Both-fields-present and data-only-present are both worth surfacing
1271
+ // via debug — the former because meta wins silently, the latter
1272
+ // because it suggests an envelope-shape transition. Data-side value
1273
+ // is preserved when meta is absent (no over-eager strip).
1274
+ const dataRequestId = result.request_id;
1275
+ if (dataRequestId !== undefined) {
1276
+ const dataRequestIdRepr = String(dataRequestId).slice(0, 80);
1277
+ if (requestId !== undefined && dataRequestId !== requestId) {
1278
+ this.log("batchCreate: response carries request_id on BOTH data and meta; keeping meta", {
1279
+ meta_request_id: requestId,
1280
+ data_request_id: dataRequestIdRepr,
1281
+ });
1282
+ }
1283
+ else if (requestId === undefined) {
1284
+ // Data-only request_id suggests an envelope-shape transition;
1285
+ // surface for observability.
1286
+ this.log("batchCreate: response carries request_id on data only (meta absent)", {
1287
+ data_request_id: dataRequestIdRepr,
1288
+ });
1289
+ }
1290
+ }
1291
+ return requestId !== undefined ? { ...result, request_id: requestId } : result;
1292
+ }
1293
+ /**
1294
+ * Get a qURL resource and its access tokens.
1295
+ *
1296
+ * Accepts either a resource ID (`r_` prefix) or a qURL display ID (`q_`
1297
+ * prefix); the API resolves `q_` IDs to the parent resource automatically.
1298
+ */
1299
+ async get(id) {
1300
+ requireNonEmptyId(id, "get");
1301
+ const raw = await this.request("GET", `/v1/qurls/${encodeURIComponent(id)}`);
1302
+ return this.mapQurlsField(raw);
1303
+ }
1304
+ /**
1305
+ * Lists protected URLs. Each qURL groups access tokens sharing the same target URL.
1306
+ * Note: list items include qurl_count but not access_tokens (too expensive at scale).
1307
+ *
1308
+ * **`limit` semantics:** `limit` is validated client-side to be in
1309
+ * the range `[1, 100]` per the OpenAPI spec (`GET /v1/qurls` defines
1310
+ * `limit: integer, minimum: 1, maximum: 100, default: 20`). Passing
1311
+ * a value outside that range throws `ValidationError` before the
1312
+ * request is issued. Omitting `limit` lets the server apply its
1313
+ * default page size (currently 20). Matches the client-side
1314
+ * validation style used for `max_sessions`, `tag count`, URL length.
1315
+ */
1316
+ async list(input = {}) {
1317
+ const path = appendQuery("/v1/qurls", input, LIST_PARAM_KEYS, "list");
1318
+ const { data, meta } = await this.rawRequest("GET", path);
1319
+ return pageFromMeta({
1320
+ // Defensive: map in case API includes nested tokens on list items in the future.
1321
+ qurls: (data ?? []).map((raw) => this.mapQurlsField(raw)),
1322
+ }, meta);
1323
+ }
1324
+ /**
1325
+ * Iterate over all qURLs, automatically paginating.
1326
+ *
1327
+ * Termination honors explicit `has_more: false` first, then falls back
1328
+ * to the cursor shape. A missing `next_cursor` still terminates even if
1329
+ * a buggy response claimed `has_more: true`, and a stale cursor with
1330
+ * `has_more: false` does not trigger an extra round-trip.
1331
+ *
1332
+ * Input is validated synchronously when this method is called, before
1333
+ * iteration begins.
1334
+ */
1335
+ listAll(input = {}) {
1336
+ validateListAllInput(input, LIST_PARAM_KEYS, "listAll");
1337
+ return this.paginateAll("listAll", input, (pageInput) => this.list(pageInput), (page) => page.qurls);
1338
+ }
1339
+ /**
1340
+ * Delete (revoke) a qURL resource and all its access tokens.
1341
+ *
1342
+ * Only accepts a resource ID (`r_` prefix), not a qURL display ID (`q_`
1343
+ * prefix). Per the OpenAPI spec: *"Requires a resource ID (r_ prefix).
1344
+ * To revoke a single token, use DELETE /v1/resources/:id/qurls/:qurl_id"*.
1345
+ * A client-side prefix check catches the mistake before the API round-trip.
1346
+ */
1347
+ async delete(id) {
1348
+ // Type guard for untyped-JS callers — without it, `(undefined).length`
1349
+ // is a raw TypeError instead of the structured ValidationError the
1350
+ // rest of the surface produces.
1351
+ if (typeof id !== "string") {
1352
+ throw clientValidationError(`delete: requires a resource ID (${RESOURCE_ID_PREFIX} prefix + suffix) — got ${id === null ? "null" : typeof id}`);
1353
+ }
1354
+ if (id.trim() !== id) {
1355
+ throw clientValidationError("delete: id must not include leading or trailing whitespace");
1356
+ }
1357
+ // Too-short check runs BEFORE the prefix check so it catches
1358
+ // bare-prefix inputs like `"r_"` (right prefix, no suffix) in
1359
+ // addition to `""` / `"x"` / `"ab"` / `"q_"`. Without this
1360
+ // ordering, an exact `"r_"` would pass the startsWith check and
1361
+ // the SDK would send `DELETE /v1/qurls/r_` to the server —
1362
+ // rejected server-side, but this catches it client-side without
1363
+ // a round-trip.
1364
+ if (id.length <= RESOURCE_ID_PREFIX.length) {
1365
+ throw clientValidationError(`delete: requires a resource ID (${RESOURCE_ID_PREFIX} prefix + suffix) — got ${id.length} character${id.length === 1 ? "" : "s"}`);
1366
+ }
1367
+ if (!id.startsWith(RESOURCE_ID_PREFIX)) {
1368
+ // Wrong-prefix branch: the input is long enough to plausibly be
1369
+ // an ID but has the wrong prefix (e.g. `q_3a7f2c8e91b`,
1370
+ // `at_xyz…`). Echo only the 2-char prefix — never the raw ID
1371
+ // — so observability pipelines don't end up with caller-supplied
1372
+ // identifiers in error logs.
1373
+ const observedPrefix = id.slice(0, RESOURCE_ID_PREFIX.length);
1374
+ throw clientValidationError(`delete: only resource IDs (${RESOURCE_ID_PREFIX} prefix) are accepted — ` +
1375
+ `got an ID starting with "${observedPrefix}". ` +
1376
+ "To revoke a single access token, use the resource-scoped token endpoint.");
1377
+ }
1378
+ await this.rawRequest("DELETE", `/v1/qurls/${encodeURIComponent(id)}`);
1379
+ }
1380
+ /**
1381
+ * Extend a qURL's expiration.
1382
+ *
1383
+ * Accepts either a resource ID (`r_` prefix) or a qURL display ID (`q_`
1384
+ * prefix). Convenience method — delegates to {@link update} with only the
1385
+ * expiration fields. `ExtendInput` shares its `extend_by` / `expires_at`
1386
+ * fields with `UpdateInput` but is *narrower in two ways*: (1) exactly
1387
+ * one of the two must be present (XOR via `?: never`), where `UpdateInput`
1388
+ * allows neither; and (2) `description` / `tags` are absent.
1389
+ *
1390
+ * Destructuring before delegation strips wider fields at runtime, so an
1391
+ * untyped-JS caller spreading `{ description, tags, ... }` can't leak
1392
+ * those through this path. The XOR (exactly-one-of-two) invariant is
1393
+ * enforced separately by the runtime check inside `update()`.
1394
+ */
1395
+ async extend(id, input, options) {
1396
+ requireNonEmptyId(id, "extend");
1397
+ requireObjectInput(input, "extend");
1398
+ const { extend_by, expires_at } = input;
1399
+ if (extend_by === undefined && expires_at === undefined) {
1400
+ throw clientValidationError("extend: exactly one of `extend_by` or `expires_at` is required");
1401
+ }
1402
+ if (extend_by !== undefined && expires_at !== undefined) {
1403
+ throw clientValidationError("extend: `extend_by` and `expires_at` are mutually exclusive — provide exactly one");
1404
+ }
1405
+ return this.update(id, { extend_by, expires_at }, options);
1406
+ }
1407
+ /**
1408
+ * Update a qURL — extend expiration, change description, rename tags.
1409
+ *
1410
+ * Accepts either a resource ID (`r_` prefix) or a qURL display ID (`q_`
1411
+ * prefix); the API resolves `q_` IDs to the parent resource automatically.
1412
+ */
1413
+ async update(id, input, options) {
1414
+ requireNonEmptyId(id, "update");
1415
+ requireObjectInput(input, "update");
1416
+ requireNoUnknownFields(input, UPDATE_FIELD_KEYS, "update");
1417
+ // Normalize null → undefined so untyped-JS callers passing
1418
+ // `{ tags: null }` don't leak null into the wire body or crash
1419
+ // downstream validators. Shallow copy keeps caller input untouched.
1420
+ const normalized = normalizePatchFields(input, UPDATE_FIELD_KEYS);
1421
+ // Timing fields have no clear-semantic (unlike `description: ""`).
1422
+ requireNonEmptyIfPresent(normalized.extend_by, "extend_by");
1423
+ requireNonEmptyIfPresent(normalized.expires_at, "expires_at");
1424
+ if (normalized.extend_by !== undefined && normalized.expires_at !== undefined) {
1425
+ throw clientValidationError("update: `extend_by` and `expires_at` are mutually exclusive — provide at most one");
1426
+ }
1427
+ requireAtLeastOneField(normalized, UPDATE_FIELD_KEYS, "update");
1428
+ // Per-field validators run after the empty-input guard so the
1429
+ // cheap binary check short-circuits first; order is correctness-neutral
1430
+ // (`requireMaxLength(undefined, …)` is a no-op).
1431
+ requireMaxLength(normalized.description, "description", MAX_DESCRIPTION);
1432
+ requireValidTags(normalized.tags);
1433
+ const raw = await this.request("PATCH", `/v1/qurls/${encodeURIComponent(id)}`, normalized, options);
1434
+ return this.mapQurlsField(raw);
1435
+ }
1436
+ /**
1437
+ * Mint a new access link for a qURL.
1438
+ *
1439
+ * Accepts either a resource ID (`r_` prefix) or a qURL display ID (`q_`
1440
+ * prefix); the API resolves `q_` IDs to the parent resource automatically.
1441
+ *
1442
+ * Passing `{}` or an object with all fields `null`/`undefined` is
1443
+ * equivalent to omitting the second argument: no body is sent, and the
1444
+ * server applies its 24h default expiration.
1445
+ */
1446
+ async mintLink(id, input, options) {
1447
+ requireNonEmptyId(id, "mintLink");
1448
+ // Normalize null → omitted so untyped-JS callers passing
1449
+ // `{ expires_in: null, expires_at: "..." }` don't leak null into
1450
+ // the wire body via JSON.stringify and don't bypass the XOR check
1451
+ // below (which uses `!== undefined`). Mirrors the null-normalization
1452
+ // pattern used by update(); keeps the two write surfaces symmetric.
1453
+ let normalized = input;
1454
+ if (input !== undefined) {
1455
+ requireObjectInput(input, "mintLink");
1456
+ requireNoUnknownFields(input, MINT_FIELD_KEYS, "mintLink");
1457
+ // Iterate the explicit allowlist (matching update() / list())
1458
+ // so unknown keys from untyped-JS callers can't leak through
1459
+ // to the wire body. The compile-time `assertExhaustive` check
1460
+ // on MINT_FIELD_KEYS keeps this in sync with MintInput.
1461
+ const stripped = {};
1462
+ let hasAnyField = false;
1463
+ for (const key of MINT_FIELD_KEYS) {
1464
+ const value = input[key];
1465
+ if (value !== null && value !== undefined) {
1466
+ stripped[key] = value;
1467
+ hasAnyField = true;
1468
+ }
1469
+ }
1470
+ // Collapse to `undefined` when stripping leaves nothing so
1471
+ // `mintLink(id, {})` matches `mintLink(id)` on the wire (no
1472
+ // Content-Type, no body); server-side defaults are identical.
1473
+ normalized = hasAnyField ? stripped : undefined;
1474
+ }
1475
+ if (normalized !== undefined) {
1476
+ requireNonEmptyIfPresent(normalized.expires_in, "expires_in");
1477
+ requireNonEmptyIfPresent(normalized.expires_at, "expires_at");
1478
+ if (normalized.expires_in !== undefined && normalized.expires_at !== undefined) {
1479
+ throw clientValidationError("mintLink: `expires_in` and `expires_at` are mutually exclusive — provide at most one");
1480
+ }
1481
+ requireMaxLength(normalized.label, "label", MAX_LABEL);
1482
+ requireNonEmptyIfPresent(normalized.label, "label");
1483
+ requireNonEmptyIfPresent(normalized.session_duration, "session_duration");
1484
+ requireMaxSessionsInRange(normalized.max_sessions);
1485
+ requireValidAccessPolicy(normalized.access_policy);
1486
+ // Duration *grammar* and access_policy contents are server-authoritative
1487
+ // (same rationale as validateCreateInput). Empty-string rejection happens
1488
+ // above; bound/unit checks happen server-side.
1489
+ }
1490
+ return this.request("POST", `/v1/qurls/${encodeURIComponent(id)}/mint_link`, normalized, options);
1491
+ }
1492
+ /**
1493
+ * Resolve a qURL access token (headless).
1494
+ *
1495
+ * Triggers an NHP knock to grant network access for the caller's IP.
1496
+ * Requires `qurl:resolve` scope on the API key.
1497
+ *
1498
+ * Accepts a plain token string or a `ResolveInput` object.
1499
+ */
1500
+ async resolve(input, options) {
1501
+ // Mirror get/update/mintLink: catch untyped-JS misuse client-side.
1502
+ let body;
1503
+ if (typeof input === "string") {
1504
+ if (input.length === 0) {
1505
+ throw clientValidationError("resolve: access_token must be a non-empty string");
1506
+ }
1507
+ body = { access_token: input };
1508
+ }
1509
+ else if (typeof input === "object" && input !== null) {
1510
+ if (typeof input.access_token !== "string" || input.access_token.length === 0) {
1511
+ throw clientValidationError("resolve: access_token must be a non-empty string");
1512
+ }
1513
+ // Allowlist-rebuild — symmetric with list()/update()/mintLink().
1514
+ body = { access_token: input.access_token };
1515
+ }
1516
+ else {
1517
+ throw clientValidationError(`resolve: input must be a string or { access_token } object (got ${input === null ? "null" : typeof input})`);
1518
+ }
1519
+ return this.request("POST", "/v1/resolve", body, options);
1520
+ }
1521
+ /** Get quota and usage information. */
1522
+ async getQuota() {
1523
+ return this.request("GET", "/v1/quota");
1524
+ }
1525
+ /** Bootstrap a LayerV qURL Connector agent. */
1526
+ async bootstrapAgent(input, options) {
1527
+ requireObjectInput(input, "bootstrapAgent");
1528
+ requireNoUnknownFields(input, AGENT_BOOTSTRAP_FIELD_KEYS, "bootstrapAgent");
1529
+ const normalizedRecord = normalizePatchFields(input, AGENT_BOOTSTRAP_FIELD_KEYS);
1530
+ const normalized = normalizedRecord;
1531
+ requireNonEmptyStringField(normalizedRecord, "public_key", "bootstrapAgent");
1532
+ return this.request("POST", "/v1/agent/bootstrap", normalized, options);
1533
+ }
1534
+ /** List resources from the `/v1/resources` API. */
1535
+ async listResources(input = {}) {
1536
+ const { data, meta } = await this.rawRequest("GET", appendQuery("/v1/resources", input, RESOURCE_LIST_PARAM_KEYS, "listResources"));
1537
+ return pageFromMeta({ resources: data ?? [] }, meta);
1538
+ }
1539
+ /**
1540
+ * Iterate all resources, automatically paginating.
1541
+ *
1542
+ * Input is validated synchronously when this method is called, before
1543
+ * iteration begins.
1544
+ */
1545
+ listAllResources(input = {}) {
1546
+ validateListAllInput(input, RESOURCE_LIST_PARAM_KEYS, "listAllResources");
1547
+ return this.paginateAll("listAllResources", input, (pageInput) => this.listResources(pageInput), (page) => page.resources);
1548
+ }
1549
+ /** Create a resource directly. */
1550
+ async createResource(input, options) {
1551
+ requireObjectInput(input, "createResource");
1552
+ requireNoUnknownFields(input, CREATE_RESOURCE_FIELD_KEYS, "createResource");
1553
+ const normalized = normalizePatchFields(input, CREATE_RESOURCE_FIELD_KEYS);
1554
+ validateResourceWriteFields(normalized, {
1555
+ requireUrlTarget: true,
1556
+ validateFindOrCreate: true,
1557
+ });
1558
+ return this.request("POST", "/v1/resources", normalized, options);
1559
+ }
1560
+ /** Get one resource plus its bounded qURL preview. */
1561
+ async getResource(id) {
1562
+ requireNonEmptyId(id, "getResource");
1563
+ return this.request("GET", `/v1/resources/${encodeURIComponent(id)}`);
1564
+ }
1565
+ /** Update resource metadata. */
1566
+ async updateResource(id, input, options) {
1567
+ requireNonEmptyId(id, "updateResource");
1568
+ requireObjectInput(input, "updateResource");
1569
+ requireNoUnknownFields(input, UPDATE_RESOURCE_FIELD_KEYS, "updateResource");
1570
+ const validationInput = normalizePatchFields(input, UPDATE_RESOURCE_FIELD_KEYS, { preserveNullFields: ["alias"] });
1571
+ requireAtLeastOneField(validationInput, UPDATE_RESOURCE_FIELD_KEYS, "updateResource");
1572
+ validateResourceWriteFields(validationInput, {
1573
+ allowAliasClear: true,
1574
+ allowCustomDomainClear: true,
1575
+ validatePreserveHost: true,
1576
+ });
1577
+ return this.request("PATCH", `/v1/resources/${encodeURIComponent(id)}`, validationInput, options);
1578
+ }
1579
+ /** Revoke a resource and all of its qURLs. */
1580
+ async deleteResource(id) {
1581
+ requireNonEmptyId(id, "deleteResource");
1582
+ await this.rawRequest("DELETE", `/v1/resources/${encodeURIComponent(id)}`);
1583
+ }
1584
+ /** Mint a qURL against an existing resource. */
1585
+ async createQurlForResource(id, input, options) {
1586
+ requireNonEmptyId(id, "createQurlForResource");
1587
+ let normalized = input;
1588
+ if (input !== undefined) {
1589
+ requireObjectInput(input, "createQurlForResource");
1590
+ requireNoUnknownFields(input, CREATE_QURL_FOR_RESOURCE_FIELD_KEYS, "createQurlForResource");
1591
+ const stripped = normalizePatchFields(input, CREATE_QURL_FOR_RESOURCE_FIELD_KEYS);
1592
+ normalized = Object.keys(stripped).length > 0 ? stripped : undefined;
1593
+ }
1594
+ validateQurlTokenOptions(normalized);
1595
+ return this.request("POST", `/v1/resources/${encodeURIComponent(id)}/qurls`, normalized, options);
1596
+ }
1597
+ /** Revoke a specific qURL token on a resource. */
1598
+ async revokeResourceQurl(id, qurlId) {
1599
+ requireNonEmptyId(id, "revokeResourceQurl");
1600
+ requireNonEmptyId(qurlId, "revokeResourceQurl");
1601
+ await this.rawRequest("DELETE", `/v1/resources/${encodeURIComponent(id)}/qurls/${encodeURIComponent(qurlId)}`);
1602
+ }
1603
+ /** Update a specific qURL token on a resource. */
1604
+ async updateResourceQurl(id, qurlId, input, options) {
1605
+ requireNonEmptyId(id, "updateResourceQurl");
1606
+ requireNonEmptyId(qurlId, "updateResourceQurl");
1607
+ requireObjectInput(input, "updateResourceQurl");
1608
+ requireNoUnknownFields(input, UPDATE_RESOURCE_QURL_FIELD_KEYS, "updateResourceQurl");
1609
+ const normalized = normalizePatchFields(input, UPDATE_RESOURCE_QURL_FIELD_KEYS);
1610
+ requireAtLeastOneField(normalized, UPDATE_RESOURCE_QURL_FIELD_KEYS, "updateResourceQurl");
1611
+ validateResourceQurlUpdateInput(normalized);
1612
+ return this.request("PATCH", `/v1/resources/${encodeURIComponent(id)}/qurls/${encodeURIComponent(qurlId)}`, normalized, options);
1613
+ }
1614
+ /**
1615
+ * List active access sessions for a resource.
1616
+ *
1617
+ * The current API contract has no cursor query params for this endpoint.
1618
+ * If the service later advertises pagination metadata, the SDK returns
1619
+ * this page and emits a debug log rather than pretending it fetched all pages.
1620
+ */
1621
+ async listResourceSessions(id) {
1622
+ requireNonEmptyId(id, "listResourceSessions");
1623
+ const { data, meta } = await this.rawRequest("GET", `/v1/resources/${encodeURIComponent(id)}/sessions`);
1624
+ if (meta?.has_more || meta?.next_cursor) {
1625
+ this.log("listResourceSessions: pagination metadata surfaced on unpaginated endpoint", {
1626
+ has_more: meta.has_more,
1627
+ next_cursor: meta.next_cursor,
1628
+ });
1629
+ }
1630
+ return {
1631
+ sessions: data ?? [],
1632
+ request_id: meta?.request_id,
1633
+ has_more: false,
1634
+ page_size: meta?.page_size,
1635
+ };
1636
+ }
1637
+ /**
1638
+ * Terminate all active sessions for a resource.
1639
+ *
1640
+ * The returned count is best-effort under retries: if the first DELETE
1641
+ * succeeds server-side but the response is lost, a retried request may
1642
+ * return `0` because there are no sessions left to terminate.
1643
+ */
1644
+ async terminateAllResourceSessions(id) {
1645
+ requireNonEmptyId(id, "terminateAllResourceSessions");
1646
+ const path = `/v1/resources/${encodeURIComponent(id)}/sessions`;
1647
+ const { data, meta, __http_status } = await this.rawRequest("DELETE", path);
1648
+ if (__http_status === 204) {
1649
+ throw unexpectedResponseError(`Unexpected 204 No Content from DELETE ${path}; expected response body`);
1650
+ }
1651
+ if (data?.terminated === undefined) {
1652
+ this.log("terminateAllResourceSessions: missing terminated count; defaulting to zero", {
1653
+ request_id: meta?.request_id,
1654
+ });
1655
+ }
1656
+ return { terminated: data?.terminated ?? 0, request_id: meta?.request_id };
1657
+ }
1658
+ /** Terminate a specific resource session. */
1659
+ async terminateResourceSession(id, sessionId) {
1660
+ requireNonEmptyId(id, "terminateResourceSession");
1661
+ requireNonEmptyId(sessionId, "terminateResourceSession");
1662
+ await this.rawRequest("DELETE", `/v1/resources/${encodeURIComponent(id)}/sessions/${encodeURIComponent(sessionId)}`);
1663
+ }
1664
+ /** List connector installations. */
1665
+ async listConnectorInstallations(input = {}) {
1666
+ const { data, meta } = await this.rawRequest("GET", appendQuery("/v1/connectors/installations", input, CONNECTOR_INSTALLATION_LIST_PARAM_KEYS, "listConnectorInstallations"));
1667
+ return pageFromMeta({ installations: data ?? [] }, meta);
1668
+ }
1669
+ /**
1670
+ * Iterate all connector installations, automatically paginating.
1671
+ *
1672
+ * Input is validated synchronously when this method is called, before
1673
+ * iteration begins.
1674
+ */
1675
+ listAllConnectorInstallations(input = {}) {
1676
+ validateListAllInput(input, CONNECTOR_INSTALLATION_LIST_PARAM_KEYS, "listAllConnectorInstallations");
1677
+ return this.paginateAll("listAllConnectorInstallations", input, (pageInput) => this.listConnectorInstallations(pageInput), (page) => page.installations);
1678
+ }
1679
+ /** Get current-period usage. */
1680
+ async getUsageCurrentPeriod() {
1681
+ return this.request("GET", "/v1/usage/current-period");
1682
+ }
1683
+ /** Get daily usage for the current period. */
1684
+ async getUsageDaily() {
1685
+ return this.request("GET", "/v1/usage/daily");
1686
+ }
1687
+ /** Get the customer profile. */
1688
+ async getCustomer() {
1689
+ return this.request("GET", "/v1/customer");
1690
+ }
1691
+ /** Update customer settings. */
1692
+ async updateCustomer(input, options) {
1693
+ requireObjectInput(input, "updateCustomer");
1694
+ requireNoUnknownFields(input, UPDATE_CUSTOMER_FIELD_KEYS, "updateCustomer");
1695
+ const normalizedRecord = normalizePatchFields(input, UPDATE_CUSTOMER_FIELD_KEYS);
1696
+ const normalized = normalizedRecord;
1697
+ const spendingCap = normalized.spending_cap_cents;
1698
+ if (spendingCap === undefined) {
1699
+ throw clientValidationError("updateCustomer: spending_cap_cents is required");
1700
+ }
1701
+ if (!Number.isInteger(spendingCap) || spendingCap < 0) {
1702
+ const rendered = typeof spendingCap === "number" ? String(spendingCap) : describeShape(spendingCap);
1703
+ throw clientValidationError(`updateCustomer: spending_cap_cents must be a non-negative integer (got ${rendered})`);
1704
+ }
1705
+ return this.request("PATCH", "/v1/customer", normalized, options);
1706
+ }
1707
+ /** Create a Stripe checkout session. */
1708
+ async createBillingCheckout(input, options) {
1709
+ requireObjectInput(input, "createBillingCheckout");
1710
+ requireNoUnknownFields(input, CREATE_BILLING_CHECKOUT_FIELD_KEYS, "createBillingCheckout");
1711
+ const normalizedRecord = normalizePatchFields(input, CREATE_BILLING_CHECKOUT_FIELD_KEYS);
1712
+ const normalized = normalizedRecord;
1713
+ requireNonEmptyStringField(normalizedRecord, "plan", "createBillingCheckout");
1714
+ return this.request("POST", "/v1/billing/checkout", normalized, options);
1715
+ }
1716
+ /** Create a Stripe billing portal session. */
1717
+ async createBillingPortal(options) {
1718
+ return this.request("POST", "/v1/billing/portal", undefined, options);
1719
+ }
1720
+ /** List billing invoices. */
1721
+ async listBillingInvoices(input = {}) {
1722
+ // This endpoint nests the list in `data.invoices` unlike the other list
1723
+ // endpoints, which return `data` as the array directly.
1724
+ const { data, meta } = await this.rawRequest("GET", appendQuery("/v1/billing/invoices", input, BILLING_INVOICE_LIST_PARAM_KEYS, "listBillingInvoices"));
1725
+ return pageFromMeta({ invoices: data?.invoices ?? [] }, meta);
1726
+ }
1727
+ /**
1728
+ * Iterate all billing invoices, automatically paginating.
1729
+ *
1730
+ * Input is validated synchronously when this method is called, before
1731
+ * iteration begins.
1732
+ */
1733
+ listAllBillingInvoices(input = {}) {
1734
+ validateListAllInput(input, BILLING_INVOICE_LIST_PARAM_KEYS, "listAllBillingInvoices");
1735
+ return this.paginateAll("listAllBillingInvoices", input, (pageInput) => this.listBillingInvoices(pageInput), (page) => page.invoices);
1736
+ }
1737
+ /** Register a custom domain. */
1738
+ async registerDomain(input, options) {
1739
+ requireObjectInput(input, "registerDomain");
1740
+ requireNoUnknownFields(input, REGISTER_DOMAIN_FIELD_KEYS, "registerDomain");
1741
+ const normalizedRecord = normalizePatchFields(input, REGISTER_DOMAIN_FIELD_KEYS);
1742
+ const normalized = normalizedRecord;
1743
+ requireNonEmptyStringField(normalizedRecord, "domain", "registerDomain");
1744
+ requireMaxLength(normalized.domain, "domain", MAX_CUSTOM_DOMAIN);
1745
+ return this.request("POST", "/v1/domains", normalized, options);
1746
+ }
1747
+ /** List custom domains. */
1748
+ async listDomains(input = {}) {
1749
+ const { data, meta } = await this.rawRequest("GET", appendQuery("/v1/domains", input, DOMAIN_LIST_PARAM_KEYS, "listDomains"));
1750
+ return pageFromMeta({ domains: data ?? [] }, meta);
1751
+ }
1752
+ /**
1753
+ * Iterate all domains, automatically paginating.
1754
+ *
1755
+ * Input is validated synchronously when this method is called, before
1756
+ * iteration begins.
1757
+ */
1758
+ listAllDomains(input = {}) {
1759
+ validateListAllInput(input, DOMAIN_LIST_PARAM_KEYS, "listAllDomains");
1760
+ return this.paginateAll("listAllDomains", input, (pageInput) => this.listDomains(pageInput), (page) => page.domains);
1761
+ }
1762
+ /** Get custom domain status. */
1763
+ async getDomain(domain) {
1764
+ requireNonEmptyId(domain, "getDomain", "domain");
1765
+ return this.request("GET", `/v1/domains/${encodeURIComponent(domain)}`);
1766
+ }
1767
+ /** Remove a custom domain. */
1768
+ async deleteDomain(domain) {
1769
+ requireNonEmptyId(domain, "deleteDomain", "domain");
1770
+ await this.rawRequest("DELETE", `/v1/domains/${encodeURIComponent(domain)}`);
1771
+ }
1772
+ /** Trigger DNS verification for a custom domain. */
1773
+ async verifyDomain(domain, options) {
1774
+ requireNonEmptyId(domain, "verifyDomain", "domain");
1775
+ return this.request("POST", `/v1/domains/${encodeURIComponent(domain)}/verify`, undefined, options);
1776
+ }
1777
+ /** Regenerate a domain verification token. */
1778
+ async regenerateDomainToken(domain, options) {
1779
+ requireNonEmptyId(domain, "regenerateDomainToken", "domain");
1780
+ return this.request("POST", `/v1/domains/${encodeURIComponent(domain)}/regenerate-token`, undefined, options);
1781
+ }
1782
+ /** List webhooks. */
1783
+ async listWebhooks(input = {}) {
1784
+ const { data, meta } = await this.rawRequest("GET", appendQuery("/v1/webhooks", input, WEBHOOK_LIST_PARAM_KEYS, "listWebhooks"));
1785
+ return pageFromMeta({ webhooks: data ?? [] }, meta);
1786
+ }
1787
+ /**
1788
+ * Iterate all webhooks, automatically paginating.
1789
+ *
1790
+ * Input is validated synchronously when this method is called, before
1791
+ * iteration begins.
1792
+ */
1793
+ listAllWebhooks(input = {}) {
1794
+ validateListAllInput(input, WEBHOOK_LIST_PARAM_KEYS, "listAllWebhooks");
1795
+ return this.paginateAll("listAllWebhooks", input, (pageInput) => this.listWebhooks(pageInput), (page) => page.webhooks);
1796
+ }
1797
+ /** Create a webhook. */
1798
+ async createWebhook(input, options) {
1799
+ requireObjectInput(input, "createWebhook");
1800
+ requireNoUnknownFields(input, CREATE_WEBHOOK_FIELD_KEYS, "createWebhook");
1801
+ const normalizedRecord = normalizePatchFields(input, CREATE_WEBHOOK_FIELD_KEYS);
1802
+ const normalized = normalizedRecord;
1803
+ validateWebhookWriteFields(normalizedRecord, "createWebhook", {
1804
+ url: true,
1805
+ events: true,
1806
+ });
1807
+ return this.request("POST", "/v1/webhooks", normalized, options);
1808
+ }
1809
+ /** List available webhook event types. */
1810
+ async listWebhookEventTypes() {
1811
+ return this.request("GET", "/v1/webhooks/events");
1812
+ }
1813
+ /** Get a webhook. */
1814
+ async getWebhook(id) {
1815
+ requireNonEmptyId(id, "getWebhook");
1816
+ return this.request("GET", `/v1/webhooks/${encodeURIComponent(id)}`);
1817
+ }
1818
+ /** Update a webhook. */
1819
+ async updateWebhook(id, input, options) {
1820
+ requireNonEmptyId(id, "updateWebhook");
1821
+ requireObjectInput(input, "updateWebhook");
1822
+ requireNoUnknownFields(input, UPDATE_WEBHOOK_FIELD_KEYS, "updateWebhook");
1823
+ const normalized = normalizePatchFields(input, UPDATE_WEBHOOK_FIELD_KEYS);
1824
+ requireAtLeastOneField(normalized, UPDATE_WEBHOOK_FIELD_KEYS, "updateWebhook");
1825
+ validateWebhookWriteFields(normalized, "updateWebhook");
1826
+ return this.request("PATCH", `/v1/webhooks/${encodeURIComponent(id)}`, normalized, options);
1827
+ }
1828
+ /** Delete a webhook. */
1829
+ async deleteWebhook(id) {
1830
+ requireNonEmptyId(id, "deleteWebhook");
1831
+ await this.rawRequest("DELETE", `/v1/webhooks/${encodeURIComponent(id)}`);
1832
+ }
1833
+ /** Regenerate a webhook signing secret. */
1834
+ async regenerateWebhookSecret(id, options) {
1835
+ requireNonEmptyId(id, "regenerateWebhookSecret");
1836
+ return this.request("POST", `/v1/webhooks/${encodeURIComponent(id)}/secret`, undefined, options);
1837
+ }
1838
+ /** List delivery attempts for a webhook. */
1839
+ async listWebhookDeliveries(id, input = {}) {
1840
+ requireNonEmptyId(id, "listWebhookDeliveries");
1841
+ const { data, meta } = await this.rawRequest("GET", appendQuery(`/v1/webhooks/${encodeURIComponent(id)}/deliveries`, input, WEBHOOK_DELIVERY_LIST_PARAM_KEYS, "listWebhookDeliveries"));
1842
+ return pageFromMeta({ deliveries: data ?? [] }, meta);
1843
+ }
1844
+ /**
1845
+ * Iterate all delivery attempts for a webhook, automatically paginating.
1846
+ *
1847
+ * Input is validated synchronously when this method is called, before
1848
+ * iteration begins.
1849
+ */
1850
+ listAllWebhookDeliveries(id, input = {}) {
1851
+ requireNonEmptyId(id, "listAllWebhookDeliveries");
1852
+ validateListAllInput(input, WEBHOOK_DELIVERY_LIST_PARAM_KEYS, "listAllWebhookDeliveries");
1853
+ return this.paginateAll("listAllWebhookDeliveries", input, (pageInput) => this.listWebhookDeliveries(id, pageInput), (page) => page.deliveries);
1854
+ }
1855
+ /** Create a new API key. */
1856
+ async createApiKey(input, options) {
1857
+ requireObjectInput(input, "createApiKey");
1858
+ requireNoUnknownFields(input, CREATE_API_KEY_FIELD_KEYS, "createApiKey");
1859
+ const normalizedRecord = normalizePatchFields(input, CREATE_API_KEY_FIELD_KEYS);
1860
+ const normalized = normalizedRecord;
1861
+ validateApiKeyWriteFields(normalizedRecord, "createApiKey", {
1862
+ name: true,
1863
+ scopes: true,
1864
+ });
1865
+ return this.request("POST", "/v1/api-keys", normalized, options);
1866
+ }
1867
+ /** List API keys. */
1868
+ async listApiKeys(input = {}) {
1869
+ const { data, meta } = await this.rawRequest("GET", appendQuery("/v1/api-keys", input, API_KEY_LIST_PARAM_KEYS, "listApiKeys"));
1870
+ return pageFromMeta({ api_keys: data ?? [] }, meta);
1871
+ }
1872
+ /**
1873
+ * Iterate all API keys, automatically paginating.
1874
+ *
1875
+ * Input is validated synchronously when this method is called, before
1876
+ * iteration begins.
1877
+ */
1878
+ listAllApiKeys(input = {}) {
1879
+ validateListAllInput(input, API_KEY_LIST_PARAM_KEYS, "listAllApiKeys");
1880
+ return this.paginateAll("listAllApiKeys", input, (pageInput) => this.listApiKeys(pageInput), (page) => page.api_keys);
1881
+ }
1882
+ /** Update an API key. */
1883
+ async updateApiKey(keyId, input, options) {
1884
+ requireNonEmptyId(keyId, "updateApiKey");
1885
+ requireObjectInput(input, "updateApiKey");
1886
+ requireNoUnknownFields(input, UPDATE_API_KEY_FIELD_KEYS, "updateApiKey");
1887
+ const normalized = normalizePatchFields(input, UPDATE_API_KEY_FIELD_KEYS);
1888
+ requireAtLeastOneField(normalized, UPDATE_API_KEY_FIELD_KEYS, "updateApiKey");
1889
+ validateApiKeyWriteFields(normalized, "updateApiKey");
1890
+ return this.request("PATCH", `/v1/api-keys/${encodeURIComponent(keyId)}`, normalized, options);
1891
+ }
1892
+ /** Revoke an API key. */
1893
+ async revokeApiKey(keyId) {
1894
+ requireNonEmptyId(keyId, "revokeApiKey");
1895
+ await this.rawRequest("DELETE", `/v1/api-keys/${encodeURIComponent(keyId)}`);
1896
+ }
1897
+ /** Redeem an access code. */
1898
+ async redeemAccessCode(input, options) {
1899
+ requireObjectInput(input, "redeemAccessCode");
1900
+ requireNoUnknownFields(input, REDEEM_ACCESS_CODE_FIELD_KEYS, "redeemAccessCode");
1901
+ const normalizedRecord = normalizePatchFields(input, REDEEM_ACCESS_CODE_FIELD_KEYS);
1902
+ const normalized = normalizedRecord;
1903
+ requireNonEmptyStringField(normalizedRecord, "code", "redeemAccessCode");
1904
+ return this.request("POST", "/v1/access-codes/redeem", normalized, options);
1905
+ }
1906
+ /** Create an access code. */
1907
+ async createAccessCode(input, options) {
1908
+ requireObjectInput(input, "createAccessCode");
1909
+ requireNoUnknownFields(input, CREATE_ACCESS_CODE_FIELD_KEYS, "createAccessCode");
1910
+ const normalizedRecord = normalizePatchFields(input, CREATE_ACCESS_CODE_FIELD_KEYS);
1911
+ const normalized = normalizedRecord;
1912
+ requireNonEmptyStringField(normalizedRecord, "resource_id", "createAccessCode");
1913
+ return this.request("POST", "/v1/access-codes", normalized, options);
1914
+ }
1915
+ /**
1916
+ * List access codes.
1917
+ *
1918
+ * The current API contract has no cursor query params for this endpoint.
1919
+ * If the service later advertises pagination metadata, the SDK returns
1920
+ * this page and emits a debug log rather than pretending it fetched all pages.
1921
+ */
1922
+ async listAccessCodes() {
1923
+ const { data, meta } = await this.rawRequest("GET", "/v1/access-codes");
1924
+ if (meta?.has_more || meta?.next_cursor) {
1925
+ this.log("listAccessCodes: pagination metadata surfaced on unpaginated endpoint", {
1926
+ has_more: meta.has_more,
1927
+ next_cursor: meta.next_cursor,
1928
+ });
1929
+ }
1930
+ return {
1931
+ access_codes: data ?? [],
1932
+ request_id: meta?.request_id,
1933
+ has_more: false,
1934
+ page_size: meta?.page_size,
1935
+ };
1936
+ }
1937
+ /** Revoke an access code. */
1938
+ async revokeAccessCode(id) {
1939
+ requireNonEmptyId(id, "revokeAccessCode");
1940
+ await this.rawRequest("DELETE", `/v1/access-codes/${encodeURIComponent(id)}`);
1941
+ }
1942
+ // --- Internal HTTP plumbing ---
1943
+ async request(method, path, body, requestOptions) {
1944
+ // List methods call rawRequest directly so 204 can degrade to an empty page.
1945
+ // Body-returning endpoints declare 200 JSON today, so this path fails closed.
1946
+ const { data, __http_status } = await this.rawRequest(method, path, body, {
1947
+ requestOptions,
1948
+ });
1949
+ if (__http_status === 204) {
1950
+ throw unexpectedResponseError(`Unexpected 204 No Content from ${method} ${path}; expected response body`);
1951
+ }
1952
+ return data;
1953
+ }
1954
+ /**
1955
+ * Issue an HTTP request and parse the JSON response.
1956
+ *
1957
+ * `passthroughStatuses` lets a caller opt certain non-2xx codes out of the
1958
+ * default throw-on-error path and receive the parsed body instead. This is
1959
+ * used by `batchCreate`, where the API returns a structured
1960
+ * `BatchCreateOutput` on HTTP 400 (all items rejected) — throwing would
1961
+ * drop the per-item errors.
1962
+ */
1963
+ async rawRequest(method, path, body, rawOptions = {}) {
1964
+ const url = `${this.baseUrl}${path}`;
1965
+ const { passthroughStatuses = NO_PASSTHROUGH_STATUSES, requestOptions } = rawOptions;
1966
+ const idempotencyKey = idempotencyKeyForRequest(method, requestOptions);
1967
+ const headers = {
1968
+ Authorization: `Bearer ${this.apiKey}`,
1969
+ Accept: "application/json",
1970
+ "User-Agent": this.userAgent,
1971
+ };
1972
+ if (body !== undefined) {
1973
+ headers["Content-Type"] = "application/json";
1974
+ }
1975
+ if (idempotencyKey !== undefined) {
1976
+ headers["Idempotency-Key"] = idempotencyKey;
1977
+ }
1978
+ // POST/PATCH keep status-code retries limited to rate limits. They
1979
+ // still retry fetch-level failures below with the same Idempotency-Key,
1980
+ // which fixes the duplicate-creation path from lost responses
1981
+ // without broadening mutating 5xx replay behavior.
1982
+ const mutatingForRetry = MUTATING_RETRY_METHODS.has(method);
1983
+ const retryable = mutatingForRetry ? RETRYABLE_STATUS_MUTATING : RETRYABLE_STATUS;
1984
+ const serializedBody = body !== undefined ? JSON.stringify(body) : undefined;
1985
+ let lastError;
1986
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
1987
+ if (attempt > 0) {
1988
+ const delay = this.retryDelay(attempt, lastError);
1989
+ this.log(`Retry ${attempt}/${this.maxRetries} after ${delay}ms`, { method, url });
1990
+ await new Promise((resolve) => setTimeout(resolve, delay));
1991
+ }
1992
+ this.log(`${method} ${url}`);
1993
+ let response;
1994
+ try {
1995
+ // `timeout` is intentionally a per-attempt budget, not a total-
1996
+ // request budget. With maxRetries=3 and timeout=30s, a slow
1997
+ // upstream + retries can run up to ~120s total. The per-
1998
+ // attempt cap matches `fetch()` semantics elsewhere and lets
1999
+ // each retry get a fresh window — a half-completed slow
2000
+ // attempt shouldn't poison the next try's deadline. Total
2001
+ // request bound: roughly `timeout * (maxRetries + 1) +
2002
+ // sum(retryDelay)`.
2003
+ response = await this.fetchFn(url, {
2004
+ method,
2005
+ headers,
2006
+ body: serializedBody,
2007
+ signal: AbortSignal.timeout(this.timeout),
2008
+ });
2009
+ }
2010
+ catch (err) {
2011
+ lastError = this.classifyFetchError(err);
2012
+ this.log(`${method} ${url} ${lastError instanceof errors_js_1.TimeoutError ? "timed out" : "network error"}`, {
2013
+ error: lastError.message,
2014
+ });
2015
+ if (attempt < this.maxRetries) {
2016
+ continue;
2017
+ }
2018
+ throw lastError;
2019
+ }
2020
+ this.log(`${method} ${url} → ${response.status}`);
2021
+ // `response.ok` is true for the entire 200-299 range, so partial-
2022
+ // success responses like 207 flow through this path naturally —
2023
+ // `passthroughStatuses` only needs to enumerate non-2xx statuses
2024
+ // that the caller wants to parse as a success envelope (e.g.
2025
+ // `batchCreate` opts 400 in so per-item errors still reach the
2026
+ // caller as a structured body instead of being raised as QURLError).
2027
+ const isPassthrough = !response.ok && passthroughStatuses.includes(response.status);
2028
+ if (response.ok || isPassthrough) {
2029
+ if (response.status === 204) {
2030
+ // rawRequest list callers may intentionally degrade 204/missing bodies
2031
+ // to empty collections; scalar/body helpers fail closed in request().
2032
+ // 204 on a non-DELETE method would deliver `undefined` to a
2033
+ // caller whose return type expects data — silent failure.
2034
+ // Surface via debug so operators see drift; the actual return
2035
+ // is unchanged for back-compat (DELETE is the only documented
2036
+ // 204-returning endpoint and its return type is `Promise<void>`).
2037
+ if (method !== "DELETE") {
2038
+ this.log(`unexpected 204 on ${method} ${url} — caller expected a response body`, {
2039
+ method,
2040
+ url,
2041
+ });
2042
+ }
2043
+ return { data: undefined, __http_status: response.status };
2044
+ }
2045
+ try {
2046
+ const json = (await response.json());
2047
+ return { ...json, __http_status: response.status };
2048
+ }
2049
+ catch {
2050
+ // Non-JSON body on a 2xx response (server contract violation)
2051
+ // or on a passthrough status (e.g. proxy HTML on 400). The
2052
+ // body stream is already consumed by the failed `.json()`,
2053
+ // so we can't delegate to parseError — synthesize a typed
2054
+ // QURLError directly so consumers catching by-class don't
2055
+ // miss it.
2056
+ this.log(`non-JSON body on ${isPassthrough ? "passthrough" : "success"} response ${response.status}`, {
2057
+ status: response.status,
2058
+ content_type: response.headers.get("content-type") ?? undefined,
2059
+ });
2060
+ throw (0, errors_js_1.createError)({
2061
+ status: response.status,
2062
+ code: errors_js_1.ERROR_CODE_UNEXPECTED_RESPONSE,
2063
+ title: response.statusText || `HTTP ${response.status}`,
2064
+ detail: `Expected JSON response body on HTTP ${response.status} but received non-JSON content`,
2065
+ });
2066
+ }
2067
+ }
2068
+ const errorData = await this.parseError(response);
2069
+ const err = (0, errors_js_1.createError)(errorData);
2070
+ if (retryable.has(response.status) && attempt < this.maxRetries) {
2071
+ lastError = err;
2072
+ continue;
2073
+ }
2074
+ throw err;
2075
+ }
2076
+ throw lastError ?? new Error("Request failed after retries");
2077
+ }
2078
+ async parseError(response) {
2079
+ try {
2080
+ const json = (await response.json());
2081
+ if (json.error) {
2082
+ const err = json.error;
2083
+ // Detail fallback chain:
2084
+ // 1. err.detail (RFC 7807 primary)
2085
+ // 2. err.message (legacy pre-RFC-7807 shape)
2086
+ // 3. err.title (RFC 7807 required field)
2087
+ // 4. HTTP status (final safety net)
2088
+ // This prevents `"Title (403): undefined"` when the API omits detail.
2089
+ const detail = err.detail ?? err.message ?? err.title ?? `HTTP ${response.status}`;
2090
+ // HTTP/2 omits reason-phrases — `statusText` may be "".
2091
+ const title = err.title ?? (response.statusText || `HTTP ${response.status}`);
2092
+ return {
2093
+ status: err.status ?? response.status,
2094
+ code: err.code ?? errors_js_1.ERROR_CODE_UNKNOWN,
2095
+ title,
2096
+ detail,
2097
+ type: err.type,
2098
+ instance: err.instance,
2099
+ invalid_fields: err.invalid_fields,
2100
+ request_id: json.meta?.request_id,
2101
+ retry_after: this.parseRetryAfter(response),
2102
+ };
2103
+ }
2104
+ // JSON parsed cleanly but the `error` envelope is missing — the API
2105
+ // returned an unexpected shape. Surface it through debugFn so
2106
+ // operators can spot the divergence; fall through to the
2107
+ // status-only safety net below.
2108
+ this.log(`unexpected error response shape from ${response.status}`, {
2109
+ status: response.status,
2110
+ body_keys: Object.keys(json),
2111
+ });
2112
+ }
2113
+ catch {
2114
+ // Body wasn't valid JSON (or the network stream errored during
2115
+ // read). Log so operators can distinguish this from a malformed
2116
+ // envelope, and fall through to the status-only safety net.
2117
+ this.log(`non-JSON error response from ${response.status}`, {
2118
+ status: response.status,
2119
+ content_type: response.headers.get("content-type") ?? undefined,
2120
+ });
2121
+ }
2122
+ return {
2123
+ status: response.status,
2124
+ code: errors_js_1.ERROR_CODE_UNKNOWN,
2125
+ title: response.statusText || `HTTP ${response.status}`,
2126
+ detail: response.statusText || `HTTP ${response.status}`,
2127
+ };
2128
+ }
2129
+ parseRetryAfter(response) {
2130
+ // RFC 7231 §7.1.3: Retry-After honored on 429 + 503. (RFC 7231
2131
+ // also allows Retry-After on 3xx redirects; the SDK doesn't follow
2132
+ // redirects, so 429/503 cover the relevant cases.)
2133
+ if (response.status !== 429 && response.status !== 503)
2134
+ return undefined;
2135
+ const header = response.headers.get("Retry-After");
2136
+ if (!header)
2137
+ return undefined;
2138
+ // TODO: parse HTTP-date format per RFC 7231 §7.1.3 — currently only
2139
+ // handles delta-seconds; HTTP-date headers fall through to the
2140
+ // exponential-backoff branch in `retryDelay`. Tracked in #61.
2141
+ //
2142
+ // Digit-only pre-check rather than `parseInt` alone: `parseInt("60abc")`
2143
+ // returns `60` and would silently honor a malformed header. The
2144
+ // strict check makes any deviation observable (debug log) instead.
2145
+ const trimmed = header.trim();
2146
+ if (!/^\d+$/.test(trimmed)) {
2147
+ this.log("Retry-After header was non-numeric, using exponential backoff", {
2148
+ header_value: header.slice(0, 100),
2149
+ });
2150
+ return undefined;
2151
+ }
2152
+ const value = parseInt(trimmed, 10);
2153
+ // Reject pathological values at parse time. Without this, very large
2154
+ // integers (e.g. `Retry-After: 9999999999999`) propagate through the
2155
+ // `*1000` ms-conversion in retryDelay before the hard cap clamps
2156
+ // them — fail fast instead, with a debug breadcrumb. Cutoff matches
2157
+ // the hard cap so a clean cap-bounded value still parses.
2158
+ if (value > RETRY_AFTER_PARSE_LIMIT_S) {
2159
+ this.log("Retry-After header exceeded parse limit, using exponential backoff", {
2160
+ header_value: header.slice(0, 100),
2161
+ limit_seconds: RETRY_AFTER_PARSE_LIMIT_S,
2162
+ });
2163
+ return undefined;
2164
+ }
2165
+ return value;
2166
+ }
2167
+ /**
2168
+ * Compute the delay before the next retry attempt.
2169
+ *
2170
+ * - If the server sent a usable `Retry-After`, the value (converted
2171
+ * from seconds to ms) is honored without the `RETRY_MAX_DELAY_MS`
2172
+ * clamp — the cap exists to bound exponential backoff against
2173
+ * ourselves; clamping a server-asserted directive (e.g.
2174
+ * `Retry-After: 60`) down to 30s would just re-trip the rate limit.
2175
+ * `Retry-After: 0` is honored verbatim per RFC 7231 §7.1.3 ("retry
2176
+ * immediately").
2177
+ * - Otherwise: `RETRY_BASE_DELAY_MS * 2^(attempt-1)` plus 0-50%
2178
+ * jitter, capped at `RETRY_MAX_DELAY_MS`.
2179
+ */
2180
+ retryDelay(attempt, lastError) {
2181
+ if (lastError instanceof errors_js_1.QURLError && lastError.retryAfter !== undefined) {
2182
+ const requestedMs = lastError.retryAfter * 1000;
2183
+ if (requestedMs > RETRY_AFTER_HARD_CAP_MS) {
2184
+ this.log("Retry-After exceeded hard cap, truncating", {
2185
+ requested_seconds: lastError.retryAfter,
2186
+ capped_ms: RETRY_AFTER_HARD_CAP_MS,
2187
+ });
2188
+ return RETRY_AFTER_HARD_CAP_MS;
2189
+ }
2190
+ return requestedMs;
2191
+ }
2192
+ const base = RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1);
2193
+ const jitter = Math.random() * base * 0.5;
2194
+ return Math.min(base + jitter, RETRY_MAX_DELAY_MS);
2195
+ }
2196
+ classifyFetchError(err) {
2197
+ if (err instanceof DOMException && err.name === "TimeoutError") {
2198
+ return new errors_js_1.TimeoutError("Request timed out", { cause: err });
2199
+ }
2200
+ const cause = err instanceof Error ? err : undefined;
2201
+ return new errors_js_1.NetworkError(cause?.message ?? String(err), { cause });
2202
+ }
2203
+ }
2204
+ exports.QURLClient = QURLClient;