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