@layervai/qurl 3.1.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +259 -46
- package/dist/cjs/client-connectors.d.ts +44 -0
- package/dist/cjs/client-connectors.js +189 -0
- package/dist/cjs/client-delegated.d.ts +30 -0
- package/dist/cjs/client-delegated.js +312 -0
- package/dist/cjs/client-errors.d.ts +44 -0
- package/dist/cjs/client-errors.js +219 -0
- package/dist/cjs/client-http-types.d.ts +37 -0
- package/dist/cjs/client-http-types.js +2 -0
- package/dist/cjs/client-input-fields.d.ts +53 -0
- package/dist/cjs/client-input-fields.js +262 -0
- package/dist/cjs/client-limits.d.ts +36 -0
- package/dist/cjs/client-limits.js +61 -0
- package/dist/cjs/client-management.d.ts +12 -0
- package/dist/cjs/client-management.js +119 -0
- package/dist/cjs/client-paths.d.ts +21 -0
- package/dist/cjs/client-paths.js +143 -0
- package/dist/cjs/client-portals.d.ts +82 -0
- package/dist/cjs/client-portals.js +244 -0
- package/dist/cjs/client-resources.d.ts +29 -0
- package/dist/cjs/client-resources.js +331 -0
- package/dist/cjs/client-validation.d.ts +35 -0
- package/dist/cjs/client-validation.js +326 -0
- package/dist/cjs/client.d.ts +45 -75
- package/dist/cjs/client.js +621 -2448
- package/dist/cjs/errors.d.ts +9 -1
- package/dist/cjs/errors.js +10 -1
- package/dist/cjs/index.d.ts +4 -2
- package/dist/cjs/index.js +4 -1
- package/dist/cjs/response-metadata.d.ts +13 -0
- package/dist/cjs/response-metadata.js +38 -0
- package/dist/cjs/share.js +2 -0
- package/dist/cjs/target-path.d.ts +2 -0
- package/dist/cjs/target-path.js +34 -0
- package/dist/cjs/types.d.ts +83 -7
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/client-connectors.d.ts +44 -0
- package/dist/esm/client-connectors.js +180 -0
- package/dist/esm/client-delegated.d.ts +30 -0
- package/dist/esm/client-delegated.js +295 -0
- package/dist/esm/client-errors.d.ts +44 -0
- package/dist/esm/client-errors.js +206 -0
- package/dist/esm/client-http-types.d.ts +37 -0
- package/dist/esm/client-http-types.js +1 -0
- package/dist/esm/client-input-fields.d.ts +53 -0
- package/dist/esm/client-input-fields.js +258 -0
- package/dist/esm/client-limits.d.ts +36 -0
- package/dist/esm/client-limits.js +58 -0
- package/dist/esm/client-management.d.ts +12 -0
- package/dist/esm/client-management.js +113 -0
- package/dist/esm/client-paths.d.ts +21 -0
- package/dist/esm/client-paths.js +138 -0
- package/dist/esm/client-portals.d.ts +82 -0
- package/dist/esm/client-portals.js +236 -0
- package/dist/esm/client-resources.d.ts +29 -0
- package/dist/esm/client-resources.js +323 -0
- package/dist/esm/client-validation.d.ts +35 -0
- package/dist/esm/client-validation.js +302 -0
- package/dist/esm/client.d.ts +45 -76
- package/dist/esm/client.js +318 -2147
- package/dist/esm/crid.d.ts +0 -1
- package/dist/esm/crid.js +0 -1
- package/dist/esm/errors.d.ts +9 -2
- package/dist/esm/errors.js +9 -1
- package/dist/esm/index.d.ts +4 -3
- package/dist/esm/index.js +2 -2
- package/dist/esm/internal.d.ts +0 -1
- package/dist/esm/internal.js +0 -1
- package/dist/esm/node/agent-runtime.d.ts +0 -1
- package/dist/esm/node/agent-runtime.js +0 -1
- package/dist/esm/node/agent-state.d.ts +0 -1
- package/dist/esm/node/agent-state.js +0 -1
- package/dist/esm/node/agent-transport.d.ts +0 -1
- package/dist/esm/node/agent-transport.js +0 -1
- package/dist/esm/node/deployment.d.ts +0 -1
- package/dist/esm/node/deployment.js +0 -1
- package/dist/esm/node/file-agent-state.d.ts +0 -1
- package/dist/esm/node/file-agent-state.js +0 -1
- package/dist/esm/node/native-udp.d.ts +0 -1
- package/dist/esm/node/native-udp.js +0 -1
- package/dist/esm/node/nhp-wire.d.ts +0 -1
- package/dist/esm/node/nhp-wire.js +0 -1
- package/dist/esm/node/portal-opener.d.ts +0 -1
- package/dist/esm/node/portal-opener.js +0 -1
- package/dist/esm/node/provider.d.ts +0 -1
- package/dist/esm/node/provider.js +0 -1
- package/dist/esm/node/qv2.d.ts +0 -1
- package/dist/esm/node/qv2.js +0 -1
- package/dist/esm/node/relay.d.ts +0 -1
- package/dist/esm/node/relay.js +0 -1
- package/dist/esm/node/sealed-agent-state.d.ts +0 -1
- package/dist/esm/node/sealed-agent-state.js +0 -1
- package/dist/esm/node/strict-json.d.ts +0 -1
- package/dist/esm/node/strict-json.js +0 -1
- package/dist/esm/node.d.ts +0 -1
- package/dist/esm/node.js +0 -1
- package/dist/esm/response-metadata.d.ts +13 -0
- package/dist/esm/response-metadata.js +33 -0
- package/dist/esm/share.d.ts +0 -1
- package/dist/esm/share.js +2 -1
- package/dist/esm/target-path.d.ts +2 -0
- package/dist/esm/target-path.js +30 -0
- package/dist/esm/types.d.ts +83 -8
- package/dist/esm/types.js +0 -1
- package/dist/esm/version.d.ts +1 -2
- package/dist/esm/version.js +1 -2
- package/package.json +9 -7
- package/dist/esm/client.d.ts.map +0 -1
- package/dist/esm/client.js.map +0 -1
- package/dist/esm/crid.d.ts.map +0 -1
- package/dist/esm/crid.js.map +0 -1
- package/dist/esm/errors.d.ts.map +0 -1
- package/dist/esm/errors.js.map +0 -1
- package/dist/esm/index.d.ts.map +0 -1
- package/dist/esm/index.js.map +0 -1
- package/dist/esm/internal.d.ts.map +0 -1
- package/dist/esm/internal.js.map +0 -1
- package/dist/esm/node/agent-runtime.d.ts.map +0 -1
- package/dist/esm/node/agent-runtime.js.map +0 -1
- package/dist/esm/node/agent-state.d.ts.map +0 -1
- package/dist/esm/node/agent-state.js.map +0 -1
- package/dist/esm/node/agent-transport.d.ts.map +0 -1
- package/dist/esm/node/agent-transport.js.map +0 -1
- package/dist/esm/node/deployment.d.ts.map +0 -1
- package/dist/esm/node/deployment.js.map +0 -1
- package/dist/esm/node/file-agent-state.d.ts.map +0 -1
- package/dist/esm/node/file-agent-state.js.map +0 -1
- package/dist/esm/node/native-udp.d.ts.map +0 -1
- package/dist/esm/node/native-udp.js.map +0 -1
- package/dist/esm/node/nhp-wire.d.ts.map +0 -1
- package/dist/esm/node/nhp-wire.js.map +0 -1
- package/dist/esm/node/portal-opener.d.ts.map +0 -1
- package/dist/esm/node/portal-opener.js.map +0 -1
- package/dist/esm/node/provider.d.ts.map +0 -1
- package/dist/esm/node/provider.js.map +0 -1
- package/dist/esm/node/qv2.d.ts.map +0 -1
- package/dist/esm/node/qv2.js.map +0 -1
- package/dist/esm/node/relay.d.ts.map +0 -1
- package/dist/esm/node/relay.js.map +0 -1
- package/dist/esm/node/sealed-agent-state.d.ts.map +0 -1
- package/dist/esm/node/sealed-agent-state.js.map +0 -1
- package/dist/esm/node/strict-json.d.ts.map +0 -1
- package/dist/esm/node/strict-json.js.map +0 -1
- package/dist/esm/node.d.ts.map +0 -1
- package/dist/esm/node.js.map +0 -1
- package/dist/esm/share.d.ts.map +0 -1
- package/dist/esm/share.js.map +0 -1
- package/dist/esm/types.d.ts.map +0 -1
- package/dist/esm/types.js.map +0 -1
- package/dist/esm/version.d.ts.map +0 -1
- package/dist/esm/version.js.map +0 -1
package/dist/esm/client.js
CHANGED
|
@@ -1,46 +1,21 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { ERROR_CODE_AMBIGUOUS_RESOURCE, ERROR_CODE_RESOURCE_NOT_FOUND, ERROR_CODE_UNEXPECTED_RESPONSE, ERROR_CODE_UNKNOWN, NetworkError, NotFoundError, QURLError, RuntimeError, TimeoutError, ValidationError, createError, } from "./errors.js";
|
|
2
|
+
import { copyResponseMetadata, withResponseMetadata } from "./response-metadata.js";
|
|
3
3
|
import { stripTrailingSlashes } from "./internal.js";
|
|
4
|
-
import { ShareLink } from "./share.js";
|
|
5
4
|
import { VERSION } from "./version.js";
|
|
5
|
+
import { ShareLink } from "./share.js";
|
|
6
|
+
import { MAX_AUTO_PAGINATION_PAGES, MAX_BATCH_ITEMS, MAX_CUSTOM_DOMAIN, MAX_DELEGATED_ETAG, MAX_DESCRIPTION, MAX_IDEMPOTENCY_KEY, MAX_LABEL, MAX_RESPONSE_BODY_BYTES, MAX_CONFIGURABLE_RESPONSE_BODY_BYTES, RETRY_AFTER_HARD_CAP_MS, RETRY_AFTER_PARSE_LIMIT_S, RETRY_BASE_DELAY_MS, RETRY_MAX_DELAY_MS, } from "./client-limits.js";
|
|
7
|
+
import { TEXT_DECODER, TEXT_ENCODER, boundedErrorIdentifier, boundedErrorSnippet, boundedInvalidFields, boundedObjectKeys, boundedTombstone, clientValidationError, isIndependentAbort, isSdkTimeout, unexpectedResponseError, withErrorCause, } from "./client-errors.js";
|
|
8
|
+
import { describeShape, normalizePatchFields, requireAtLeastOneField, requireBooleanIfPresent, requireConsumerDecision, requireListLimitInRange, requireMaxLength, requireMaxSessionsInRange, requireNoUnknownFields, requireNonEmptyIfPresent, requireNonEmptyStringField, requireObjectInput, requirePortalTargetUrl, requireResourceNameIfPresent, requireValidAccessPolicy, requireValidTags, } from "./client-validation.js";
|
|
9
|
+
import { API_KEY_LIST_PARAM_KEYS, BILLING_INVOICE_LIST_PARAM_KEYS, CONNECTOR_INSTALLATION_LIST_PARAM_KEYS, CREATE_ACCESS_CODE_FIELD_KEYS, CREATE_API_KEY_FIELD_KEYS, CREATE_BILLING_CHECKOUT_FIELD_KEYS, CREATE_QURL_FOR_RESOURCE_FIELD_KEYS, CREATE_RESOURCE_FIELD_KEYS, CREATE_WEBHOOK_FIELD_KEYS, DOMAIN_LIST_PARAM_KEYS, LIST_PARAM_KEYS, MINT_FIELD_KEYS, PROTECT_URL_OPTION_KEYS, REDEEM_ACCESS_CODE_FIELD_KEYS, REGISTER_DOMAIN_FIELD_KEYS, REQUEST_OPTION_KEYS, RESOURCE_LIST_PARAM_KEYS, SHARE_RESOURCE_OPTION_KEYS, UPDATE_API_KEY_FIELD_KEYS, UPDATE_CUSTOMER_FIELD_KEYS, UPDATE_FIELD_KEYS, UPDATE_RESOURCE_FIELD_KEYS, UPDATE_RESOURCE_QURL_FIELD_KEYS, UPDATE_WEBHOOK_FIELD_KEYS, WEBHOOK_DELIVERY_LIST_PARAM_KEYS, WEBHOOK_LIST_PARAM_KEYS, } from "./client-input-fields.js";
|
|
10
|
+
import { batchItemResultValidationReason, sameConsumerPolicy, validateCreateInput, validateQurlTokenOptions, validateResourceQurlUpdateInput, validateResourceWriteFields, } from "./client-resources.js";
|
|
11
|
+
import { ProtectedResource, buildCreatePortalBody, extractAccessToken, parseApiDate, parsePortal, } from "./client-portals.js";
|
|
12
|
+
import { classifyConnectorMutationFailure, parseConnectorResource, requireConnectorSlug, requireConnectorSubtleCrypto, verifyResourceIdentity, } from "./client-connectors.js";
|
|
13
|
+
import { QURL_ID_PATH_OPTIONS, RESOURCE_OR_QURL_ID_PATH_OPTIONS, requireResourceCrid, validatePathId, } from "./client-paths.js";
|
|
14
|
+
import { DELEGATED_BATCH_ID_PATTERN, DELEGATED_QURL_ID_PATTERN, STRONG_ETAG_PATTERN, classifyDelegatedBatchCreateFailure, delegatedBatchBase, delegatedEnvelope, delegatedResponseError, delegatedResponseHeaders, hasDelegatedNoStore, isUtcTimestamp, requiredDelegatedHeader, validateCreateDelegatedQurlBatchInput, validateDelegatedBatchCreateOptions, validateDelegatedBatchResults, validateDelegatedLocation, validateDelegatedQurl, validateGetDelegatedQurlBatchOptions, } from "./client-delegated.js";
|
|
15
|
+
import { validateApiKeyWriteFields, validateEnrollmentTokenFields, validateWebhookWriteFields, } from "./client-management.js";
|
|
6
16
|
const DEFAULT_BASE_URL = "https://api.layerv.ai";
|
|
7
17
|
const DEFAULT_MAX_RETRIES = 3;
|
|
8
18
|
const DEFAULT_TIMEOUT = 30_000;
|
|
9
|
-
// Match qurl-go c4f2f98's exact API response cap. Current qurl-service list
|
|
10
|
-
// routes cap pages at 100 items. A caller can still exceed this cap with a
|
|
11
|
-
// large, heavily escaped webhook-delivery page, so list callers must request a
|
|
12
|
-
// smaller page if the service reports the body-limit error. This cap is fixed:
|
|
13
|
-
// do not add an override or widen one SDK independently from the other.
|
|
14
|
-
// The limit is enforced from Content-Length when usable and again while
|
|
15
|
-
// consuming the response stream.
|
|
16
|
-
const MAX_RESPONSE_BODY_BYTES = 1 << 20;
|
|
17
|
-
const MAX_ERROR_SNIPPET_BYTES = 512;
|
|
18
|
-
// UTF-8 uses at least one byte per UTF-16 code unit. Reading more source units
|
|
19
|
-
// cannot add a byte that fits in a 512-byte retained diagnostic.
|
|
20
|
-
const MAX_ERROR_SNIPPET_SOURCE_CODE_UNITS = MAX_ERROR_SNIPPET_BYTES;
|
|
21
|
-
const MAX_INVALID_FIELD_ENTRIES = 100;
|
|
22
|
-
const MAX_DEBUG_BODY_KEYS = 100;
|
|
23
|
-
// Bound each retained diagnostic collection as well as each string. Without
|
|
24
|
-
// this aggregate cap, 100 valid 512-byte keys and values could turn a small
|
|
25
|
-
// server error into a large caller-visible object. This is a diagnostic budget,
|
|
26
|
-
// not part of the API wire contract.
|
|
27
|
-
const MAX_DIAGNOSTIC_COLLECTION_BYTES = 8 << 10;
|
|
28
|
-
const TEXT_ENCODER = new TextEncoder();
|
|
29
|
-
const ERROR_SNIPPET_ELLIPSIS = "...";
|
|
30
|
-
const ERROR_SNIPPET_ELLIPSIS_BYTES = 3;
|
|
31
|
-
// RFC 8259 JSON exchanged between systems must be valid UTF-8. A non-fatal
|
|
32
|
-
// decoder would replace malformed bytes with U+FFFD before JSON.parse and hide
|
|
33
|
-
// the producer's exact contract violation.
|
|
34
|
-
const TEXT_DECODER = new TextDecoder("utf-8", { fatal: true });
|
|
35
|
-
const RETRY_BASE_DELAY_MS = 500;
|
|
36
|
-
// Bounds local exponential backoff (NOT server-asserted Retry-After —
|
|
37
|
-
// see `RETRY_AFTER_HARD_CAP_MS` for that).
|
|
38
|
-
const RETRY_MAX_DELAY_MS = 30_000;
|
|
39
|
-
// Hard cap on server-asserted Retry-After. Guards against `Retry-After`
|
|
40
|
-
// values >2^31-1 ms overflowing Node's setTimeout (silently truncated to
|
|
41
|
-
// 1ms — turns a "wait a month" directive into a hot retry loop).
|
|
42
|
-
const RETRY_AFTER_HARD_CAP_MS = 60 * 60 * 1000;
|
|
43
|
-
const RETRY_AFTER_PARSE_LIMIT_S = RETRY_AFTER_HARD_CAP_MS / 1000;
|
|
44
19
|
const RETRYABLE_STATUS = new Set([429, 502, 503, 504]);
|
|
45
20
|
// Keep mutating status retries 429-only until the service-side idempotency
|
|
46
21
|
// cache contract is proven across 5xx, TTL, and replay-window edge cases.
|
|
@@ -50,12 +25,11 @@ const REDIRECT_RESPONSE_STATUSES = new Set([300, 301, 302, 303, 305, 307, 308]);
|
|
|
50
25
|
const NO_RETRYABLE_STATUSES = new Set();
|
|
51
26
|
const IDEMPOTENCY_KEY_METHODS = new Set(["POST", "PATCH"]);
|
|
52
27
|
const MUTATING_RETRY_METHODS = new Set(["POST", "PATCH"]);
|
|
53
|
-
const MAX_IDEMPOTENCY_KEY = 256;
|
|
54
28
|
const IDEMPOTENCY_KEY_VALUE_RE = /^[\x21-\x7e](?:[\x20-\x7e]*[\x21-\x7e])?$/;
|
|
55
29
|
const UUID_HEX = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, "0"));
|
|
56
30
|
class ResponseBodyTooLargeError extends Error {
|
|
57
|
-
constructor() {
|
|
58
|
-
super(`Response body exceeds ${
|
|
31
|
+
constructor(limit) {
|
|
32
|
+
super(`Response body exceeds ${limit}-byte limit`);
|
|
59
33
|
this.name = "ResponseBodyTooLargeError";
|
|
60
34
|
}
|
|
61
35
|
}
|
|
@@ -74,272 +48,6 @@ class ResponseBodyMaterializationError extends Error {
|
|
|
74
48
|
const NO_PASSTHROUGH_STATUSES = [];
|
|
75
49
|
const BATCH_PASSTHROUGH_STATUSES = [400];
|
|
76
50
|
const DELEGATED_BATCH_PASSTHROUGH_STATUSES = [304];
|
|
77
|
-
/** Allowlist of known query-param keys for the `list()` endpoint. */
|
|
78
|
-
const LIST_PARAM_KEYS = [
|
|
79
|
-
"limit",
|
|
80
|
-
"cursor",
|
|
81
|
-
"status",
|
|
82
|
-
"q",
|
|
83
|
-
"sort",
|
|
84
|
-
"created_after",
|
|
85
|
-
"created_before",
|
|
86
|
-
"expires_before",
|
|
87
|
-
"expires_after",
|
|
88
|
-
];
|
|
89
|
-
const REQUEST_OPTION_KEYS = ["idempotencyKey"];
|
|
90
|
-
const DELEGATED_BATCH_INPUT_KEYS = [
|
|
91
|
-
"mint_capability",
|
|
92
|
-
"grants",
|
|
93
|
-
];
|
|
94
|
-
const DELEGATED_BATCH_GRANT_KEYS = [
|
|
95
|
-
"expires_in",
|
|
96
|
-
"label",
|
|
97
|
-
"one_time_use",
|
|
98
|
-
"max_sessions",
|
|
99
|
-
"session_duration",
|
|
100
|
-
"access_policy",
|
|
101
|
-
];
|
|
102
|
-
const DELEGATED_BATCH_CREATE_OPTION_KEYS = [
|
|
103
|
-
"idempotencyKey",
|
|
104
|
-
];
|
|
105
|
-
const DELEGATED_BATCH_GET_OPTION_KEYS = [
|
|
106
|
-
"etag",
|
|
107
|
-
];
|
|
108
|
-
// Compile-time witness: `Exclude<keyof X, (typeof KEYS)[number]>` is
|
|
109
|
-
// `never` iff KEYS lists every key of X. Paired with the
|
|
110
|
-
// `satisfies readonly (keyof X)[]` clause on the array (which catches
|
|
111
|
-
// the other direction), this gives bidirectional drift detection on
|
|
112
|
-
// the *_KEYS allowlists at zero runtime cost.
|
|
113
|
-
function assertExhaustive(_) {
|
|
114
|
-
/* type-only check */
|
|
115
|
-
}
|
|
116
|
-
assertExhaustive(true);
|
|
117
|
-
assertExhaustive(true);
|
|
118
|
-
assertExhaustive(true);
|
|
119
|
-
assertExhaustive(true);
|
|
120
|
-
assertExhaustive(true);
|
|
121
|
-
assertExhaustive(true);
|
|
122
|
-
const CREATE_FIELD_KEYS = [
|
|
123
|
-
"private",
|
|
124
|
-
"type",
|
|
125
|
-
"target_url",
|
|
126
|
-
"expires_in",
|
|
127
|
-
"one_time_use",
|
|
128
|
-
"max_sessions",
|
|
129
|
-
"session_duration",
|
|
130
|
-
"label",
|
|
131
|
-
"access_policy",
|
|
132
|
-
"custom_domain",
|
|
133
|
-
];
|
|
134
|
-
assertExhaustive(true);
|
|
135
|
-
/**
|
|
136
|
-
* Fields accepted by `update()`. The empty-input pre-flight check in
|
|
137
|
-
* {@link QURLClient.update} iterates this const — so adding a new field to
|
|
138
|
-
* {@link UpdateInput} without listing it here will fail the
|
|
139
|
-
* paired `assertExhaustive` witness at compile time rather than silently
|
|
140
|
-
* sneaking through an empty-input request.
|
|
141
|
-
*/
|
|
142
|
-
const UPDATE_FIELD_KEYS = [
|
|
143
|
-
"extend_by",
|
|
144
|
-
"expires_at",
|
|
145
|
-
"description",
|
|
146
|
-
"tags",
|
|
147
|
-
];
|
|
148
|
-
assertExhaustive(true);
|
|
149
|
-
/**
|
|
150
|
-
* Fields accepted by `mintLink()`. Iterated by the null-stripping
|
|
151
|
-
* normalization loop in {@link QURLClient.mintLink} so unknown keys
|
|
152
|
-
* from untyped-JS callers don't leak through to the wire body.
|
|
153
|
-
* Symmetric with `LIST_PARAM_KEYS` / `UPDATE_FIELD_KEYS`.
|
|
154
|
-
*/
|
|
155
|
-
const MINT_FIELD_KEYS = [
|
|
156
|
-
"expires_in",
|
|
157
|
-
"expires_at",
|
|
158
|
-
"label",
|
|
159
|
-
"one_time_use",
|
|
160
|
-
"max_sessions",
|
|
161
|
-
"session_duration",
|
|
162
|
-
"access_policy",
|
|
163
|
-
];
|
|
164
|
-
assertExhaustive(true);
|
|
165
|
-
const RESOURCE_LIST_PARAM_KEYS = [
|
|
166
|
-
"cursor",
|
|
167
|
-
"limit",
|
|
168
|
-
"alias",
|
|
169
|
-
"slug",
|
|
170
|
-
"status",
|
|
171
|
-
"type",
|
|
172
|
-
];
|
|
173
|
-
assertExhaustive(true);
|
|
174
|
-
const CREATE_RESOURCE_FIELD_KEYS = [
|
|
175
|
-
"private",
|
|
176
|
-
"consumer_access",
|
|
177
|
-
"type",
|
|
178
|
-
"target_url",
|
|
179
|
-
"description",
|
|
180
|
-
"tags",
|
|
181
|
-
"custom_domain",
|
|
182
|
-
"alias",
|
|
183
|
-
"slug",
|
|
184
|
-
"find_or_create",
|
|
185
|
-
];
|
|
186
|
-
assertExhaustive(true);
|
|
187
|
-
const UPDATE_RESOURCE_FIELD_KEYS = [
|
|
188
|
-
"allowed_device_keys_add",
|
|
189
|
-
"allowed_device_keys_remove",
|
|
190
|
-
"consumer_access",
|
|
191
|
-
"expected_consumer_access_version",
|
|
192
|
-
"description",
|
|
193
|
-
"tags",
|
|
194
|
-
"custom_domain",
|
|
195
|
-
"preserve_host",
|
|
196
|
-
"alias",
|
|
197
|
-
];
|
|
198
|
-
assertExhaustive(true);
|
|
199
|
-
const CREATE_QURL_FOR_RESOURCE_FIELD_KEYS = [
|
|
200
|
-
"expires_in",
|
|
201
|
-
"one_time_use",
|
|
202
|
-
"max_sessions",
|
|
203
|
-
"session_duration",
|
|
204
|
-
"label",
|
|
205
|
-
"access_policy",
|
|
206
|
-
"target_path",
|
|
207
|
-
];
|
|
208
|
-
const SHARE_RESOURCE_OPTION_KEYS = [
|
|
209
|
-
"ttlSeconds",
|
|
210
|
-
];
|
|
211
|
-
assertExhaustive(true);
|
|
212
|
-
assertExhaustive(true);
|
|
213
|
-
/**
|
|
214
|
-
* Options accepted by the portal-verb `protectUrl` / `createPortal`
|
|
215
|
-
* surface. These are camelCase caller-facing options (the portal verbs are
|
|
216
|
-
* the idiomatic layer); the build helpers map them onto the same wire
|
|
217
|
-
* fields the REST-shaped methods send.
|
|
218
|
-
*/
|
|
219
|
-
const PROTECT_URL_OPTION_KEYS = [
|
|
220
|
-
"private",
|
|
221
|
-
"consumerAccess",
|
|
222
|
-
"description",
|
|
223
|
-
"tags",
|
|
224
|
-
"customDomain",
|
|
225
|
-
"alias",
|
|
226
|
-
];
|
|
227
|
-
assertExhaustive(true);
|
|
228
|
-
const CREATE_PORTAL_OPTION_KEYS = [
|
|
229
|
-
"validFor",
|
|
230
|
-
"label",
|
|
231
|
-
"oneTimeUse",
|
|
232
|
-
"maxSessions",
|
|
233
|
-
"sessionDuration",
|
|
234
|
-
"targetPath",
|
|
235
|
-
];
|
|
236
|
-
assertExhaustive(true);
|
|
237
|
-
const UPDATE_RESOURCE_QURL_FIELD_KEYS = [
|
|
238
|
-
"extend_by",
|
|
239
|
-
"expires_at",
|
|
240
|
-
"label",
|
|
241
|
-
"access_policy",
|
|
242
|
-
"max_sessions",
|
|
243
|
-
"session_duration",
|
|
244
|
-
];
|
|
245
|
-
assertExhaustive(true);
|
|
246
|
-
const UPDATE_CUSTOMER_FIELD_KEYS = [
|
|
247
|
-
"spending_cap_cents",
|
|
248
|
-
];
|
|
249
|
-
assertExhaustive(true);
|
|
250
|
-
const CREATE_BILLING_CHECKOUT_FIELD_KEYS = [
|
|
251
|
-
"plan",
|
|
252
|
-
];
|
|
253
|
-
assertExhaustive(true);
|
|
254
|
-
const REGISTER_DOMAIN_FIELD_KEYS = [
|
|
255
|
-
"domain",
|
|
256
|
-
];
|
|
257
|
-
assertExhaustive(true);
|
|
258
|
-
const CREATE_WEBHOOK_FIELD_KEYS = [
|
|
259
|
-
"url",
|
|
260
|
-
"events",
|
|
261
|
-
"description",
|
|
262
|
-
];
|
|
263
|
-
assertExhaustive(true);
|
|
264
|
-
const UPDATE_WEBHOOK_FIELD_KEYS = [
|
|
265
|
-
"url",
|
|
266
|
-
"events",
|
|
267
|
-
"description",
|
|
268
|
-
"status",
|
|
269
|
-
];
|
|
270
|
-
assertExhaustive(true);
|
|
271
|
-
const CREATE_API_KEY_FIELD_KEYS = [
|
|
272
|
-
"kind",
|
|
273
|
-
"name",
|
|
274
|
-
"scopes",
|
|
275
|
-
"target",
|
|
276
|
-
"claims",
|
|
277
|
-
"expires_in",
|
|
278
|
-
];
|
|
279
|
-
assertExhaustive(true);
|
|
280
|
-
const UPDATE_API_KEY_FIELD_KEYS = [
|
|
281
|
-
"name",
|
|
282
|
-
"scopes",
|
|
283
|
-
];
|
|
284
|
-
assertExhaustive(true);
|
|
285
|
-
const REDEEM_ACCESS_CODE_FIELD_KEYS = [
|
|
286
|
-
"code",
|
|
287
|
-
"honeypot",
|
|
288
|
-
"elapsed_ms",
|
|
289
|
-
];
|
|
290
|
-
assertExhaustive(true);
|
|
291
|
-
const CREATE_ACCESS_CODE_FIELD_KEYS = [
|
|
292
|
-
"resource_id",
|
|
293
|
-
"name",
|
|
294
|
-
"max_uses",
|
|
295
|
-
"expires_at",
|
|
296
|
-
];
|
|
297
|
-
assertExhaustive(true);
|
|
298
|
-
const CONNECTOR_INSTALLATION_LIST_PARAM_KEYS = [
|
|
299
|
-
"cursor",
|
|
300
|
-
"limit",
|
|
301
|
-
];
|
|
302
|
-
assertExhaustive(true);
|
|
303
|
-
const BILLING_INVOICE_LIST_PARAM_KEYS = [
|
|
304
|
-
"limit",
|
|
305
|
-
"cursor",
|
|
306
|
-
];
|
|
307
|
-
assertExhaustive(true);
|
|
308
|
-
const DOMAIN_LIST_PARAM_KEYS = [
|
|
309
|
-
"limit",
|
|
310
|
-
"cursor",
|
|
311
|
-
];
|
|
312
|
-
assertExhaustive(true);
|
|
313
|
-
const WEBHOOK_LIST_PARAM_KEYS = [
|
|
314
|
-
"limit",
|
|
315
|
-
"cursor",
|
|
316
|
-
];
|
|
317
|
-
assertExhaustive(true);
|
|
318
|
-
const WEBHOOK_DELIVERY_LIST_PARAM_KEYS = [
|
|
319
|
-
"limit",
|
|
320
|
-
"cursor",
|
|
321
|
-
];
|
|
322
|
-
assertExhaustive(true);
|
|
323
|
-
const API_KEY_LIST_PARAM_KEYS = [
|
|
324
|
-
"limit",
|
|
325
|
-
"cursor",
|
|
326
|
-
"status",
|
|
327
|
-
];
|
|
328
|
-
assertExhaustive(true);
|
|
329
|
-
/**
|
|
330
|
-
* Construct a {@link ValidationError} for a client-side pre-flight check.
|
|
331
|
-
* Uses `status: 0` (matching {@link NetworkError}/{@link TimeoutError}) and
|
|
332
|
-
* `code: "client_validation"` so catch-by-class still works and callers can
|
|
333
|
-
* tell the error originated inside the SDK rather than from the API.
|
|
334
|
-
*/
|
|
335
|
-
function clientValidationError(detail) {
|
|
336
|
-
return new ValidationError({
|
|
337
|
-
status: 0,
|
|
338
|
-
code: ERROR_CODE_CLIENT_VALIDATION,
|
|
339
|
-
title: "Invalid Argument",
|
|
340
|
-
detail,
|
|
341
|
-
});
|
|
342
|
-
}
|
|
343
51
|
function validateRequestOptions(options) {
|
|
344
52
|
if (options === undefined) {
|
|
345
53
|
return;
|
|
@@ -411,171 +119,6 @@ function fillRandomBytes(bytes) {
|
|
|
411
119
|
}
|
|
412
120
|
throw new RuntimeError("globalThis.crypto.getRandomValues is required to generate Idempotency-Key");
|
|
413
121
|
}
|
|
414
|
-
/**
|
|
415
|
-
* Construct a {@link ValidationError} for the case where the API returned
|
|
416
|
-
* a response that parsed as JSON but didn't match the expected schema
|
|
417
|
-
* shape (e.g. 400 with a body that isn't a {@link BatchCreateOutput}).
|
|
418
|
-
*
|
|
419
|
-
* Distinct from {@link clientValidationError} so callers can `.code`-branch
|
|
420
|
-
* between "I passed bad input locally" (`"client_validation"`) and "the
|
|
421
|
-
* server returned a body I can't interpret" (`"unexpected_response"`).
|
|
422
|
-
* Uses `status: 0` by default because a logical shape mismatch is not tied to
|
|
423
|
-
* the HTTP classification. Callers that enforce an observed HTTP contract
|
|
424
|
-
* pass that status explicitly.
|
|
425
|
-
*
|
|
426
|
-
* Threads `request_id` through to {@link QURLError.requestId} when the
|
|
427
|
-
* server-side correlation ID is available — operators debugging
|
|
428
|
-
* "unexpected response" tickets need it on the *error* path too, not
|
|
429
|
-
* just on success/passthrough returns.
|
|
430
|
-
*/
|
|
431
|
-
function unexpectedResponseError(detail, options = {}) {
|
|
432
|
-
// Every detail passed here is an SDK-authored contract message whose dynamic
|
|
433
|
-
// inputs were validated by the caller-side shape guards. Do not pass raw
|
|
434
|
-
// server strings to this helper. Server diagnostics use boundedErrorSnippet.
|
|
435
|
-
const safeRequestId = boundedErrorIdentifier(options.requestId);
|
|
436
|
-
return new ValidationError({
|
|
437
|
-
status: options.status ?? 0,
|
|
438
|
-
code: ERROR_CODE_UNEXPECTED_RESPONSE,
|
|
439
|
-
title: "Unexpected Response",
|
|
440
|
-
detail,
|
|
441
|
-
request_id: safeRequestId,
|
|
442
|
-
});
|
|
443
|
-
}
|
|
444
|
-
function withErrorCause(error, cause) {
|
|
445
|
-
// Match the native Error `cause` descriptor. In particular, do not leak a
|
|
446
|
-
// transport object through Object.keys(), object spread, or JSON logging.
|
|
447
|
-
Object.defineProperty(error, "cause", {
|
|
448
|
-
value: cause,
|
|
449
|
-
writable: true,
|
|
450
|
-
configurable: true,
|
|
451
|
-
});
|
|
452
|
-
return error;
|
|
453
|
-
}
|
|
454
|
-
function isIndependentAbort(error, requestSignal) {
|
|
455
|
-
return (error instanceof Error &&
|
|
456
|
-
(error.name === "AbortError" || error.name === "TimeoutError") &&
|
|
457
|
-
!requestSignal.aborted);
|
|
458
|
-
}
|
|
459
|
-
function isSdkTimeout(error, requestSignal) {
|
|
460
|
-
return (error instanceof Error &&
|
|
461
|
-
requestSignal.aborted &&
|
|
462
|
-
(error.name === "TimeoutError" || error.name === "AbortError"));
|
|
463
|
-
}
|
|
464
|
-
/**
|
|
465
|
-
* Normalize a server-provided problem title/detail into a single-line,
|
|
466
|
-
* UTF-8-safe snippet. Error messages must stay bounded even when a proxy or
|
|
467
|
-
* compromised upstream returns very large structured problem fields.
|
|
468
|
-
*/
|
|
469
|
-
function normalizedErrorSnippet(value) {
|
|
470
|
-
if (typeof value !== "string")
|
|
471
|
-
return undefined;
|
|
472
|
-
// Bound source work before the Unicode regular expressions allocate
|
|
473
|
-
// normalized copies. The response body cap is much larger than one retained
|
|
474
|
-
// diagnostic, so processing the full server string would waste memory.
|
|
475
|
-
const normalized = value
|
|
476
|
-
.slice(0, MAX_ERROR_SNIPPET_SOURCE_CODE_UNITS)
|
|
477
|
-
// Strip control characters plus bidi override/isolate controls that can
|
|
478
|
-
// visually reorder diagnostics. Preserve other formatting characters
|
|
479
|
-
// such as ZWJ/ZWNJ, which are required by legitimate scripts and emoji.
|
|
480
|
-
.replace(/[\p{Cc}\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/gu, " ")
|
|
481
|
-
.trim()
|
|
482
|
-
.replace(/\s+/g, " ");
|
|
483
|
-
return normalized === "" ? undefined : normalized;
|
|
484
|
-
}
|
|
485
|
-
function boundedErrorSnippet(value) {
|
|
486
|
-
const sourceTruncated = typeof value === "string" && value.length > MAX_ERROR_SNIPPET_SOURCE_CODE_UNITS;
|
|
487
|
-
const normalized = normalizedErrorSnippet(value);
|
|
488
|
-
if (normalized === undefined)
|
|
489
|
-
return undefined;
|
|
490
|
-
const contentLimit = sourceTruncated
|
|
491
|
-
? MAX_ERROR_SNIPPET_BYTES - ERROR_SNIPPET_ELLIPSIS_BYTES
|
|
492
|
-
: MAX_ERROR_SNIPPET_BYTES;
|
|
493
|
-
// UTF-8 uses at most three bytes per UTF-16 code unit. Most diagnostics are
|
|
494
|
-
// short ASCII, so avoid an allocation only to prove they fit.
|
|
495
|
-
if (normalized.length * 3 <= contentLimit) {
|
|
496
|
-
return sourceTruncated ? `${normalized}${ERROR_SNIPPET_ELLIPSIS}` : normalized;
|
|
497
|
-
}
|
|
498
|
-
const encoded = TEXT_ENCODER.encode(normalized);
|
|
499
|
-
if (encoded.byteLength <= contentLimit) {
|
|
500
|
-
return sourceTruncated ? `${normalized}${ERROR_SNIPPET_ELLIPSIS}` : normalized;
|
|
501
|
-
}
|
|
502
|
-
let end = MAX_ERROR_SNIPPET_BYTES - ERROR_SNIPPET_ELLIPSIS_BYTES;
|
|
503
|
-
// Do not split a multi-byte UTF-8 sequence at the cap.
|
|
504
|
-
while (end > 0 && (encoded[end] & 0xc0) === 0x80)
|
|
505
|
-
end--;
|
|
506
|
-
return `${TEXT_DECODER.decode(encoded.subarray(0, end))}${ERROR_SNIPPET_ELLIPSIS}`;
|
|
507
|
-
}
|
|
508
|
-
/** Keep server identifiers useful as exact machine-readable discriminants. */
|
|
509
|
-
function boundedErrorIdentifier(value) {
|
|
510
|
-
if (typeof value !== "string" || value.length > MAX_ERROR_SNIPPET_SOURCE_CODE_UNITS) {
|
|
511
|
-
return undefined;
|
|
512
|
-
}
|
|
513
|
-
const normalized = normalizedErrorSnippet(value);
|
|
514
|
-
if (normalized === undefined ||
|
|
515
|
-
normalized !== value ||
|
|
516
|
-
TEXT_ENCODER.encode(value).byteLength > MAX_ERROR_SNIPPET_BYTES) {
|
|
517
|
-
return undefined;
|
|
518
|
-
}
|
|
519
|
-
return value;
|
|
520
|
-
}
|
|
521
|
-
/** Collect at most the first N enumerable own string keys without a full key-array copy. */
|
|
522
|
-
function firstEnumerableOwnKeys(value, limit) {
|
|
523
|
-
const keys = [];
|
|
524
|
-
for (const key in value) {
|
|
525
|
-
if (!Object.prototype.hasOwnProperty.call(value, key))
|
|
526
|
-
continue;
|
|
527
|
-
keys.push(key);
|
|
528
|
-
if (keys.length === limit)
|
|
529
|
-
break;
|
|
530
|
-
}
|
|
531
|
-
return keys;
|
|
532
|
-
}
|
|
533
|
-
/** Bound the structured per-field diagnostics exposed on ValidationError. */
|
|
534
|
-
function boundedInvalidFields(value) {
|
|
535
|
-
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
536
|
-
return undefined;
|
|
537
|
-
const entries = [];
|
|
538
|
-
const retainedKeys = new Set();
|
|
539
|
-
let retainedBytes = 0;
|
|
540
|
-
// Bound inspected entries as well as retained entries. A hostile envelope
|
|
541
|
-
// whose values all have the wrong type must not make diagnostics scan every
|
|
542
|
-
// key in the response body.
|
|
543
|
-
for (const rawKey of firstEnumerableOwnKeys(value, MAX_INVALID_FIELD_ENTRIES)) {
|
|
544
|
-
const rawValue = value[rawKey];
|
|
545
|
-
const key = boundedErrorSnippet(rawKey);
|
|
546
|
-
const fieldDetail = boundedErrorSnippet(rawValue);
|
|
547
|
-
if (key !== undefined && fieldDetail !== undefined && !retainedKeys.has(key)) {
|
|
548
|
-
const entryBytes = TEXT_ENCODER.encode(key).byteLength + TEXT_ENCODER.encode(fieldDetail).byteLength;
|
|
549
|
-
// A later, smaller entry can still fit even when this one cannot.
|
|
550
|
-
if (retainedBytes + entryBytes > MAX_DIAGNOSTIC_COLLECTION_BYTES)
|
|
551
|
-
continue;
|
|
552
|
-
retainedKeys.add(key);
|
|
553
|
-
entries.push([key, fieldDetail]);
|
|
554
|
-
retainedBytes += entryBytes;
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
|
558
|
-
}
|
|
559
|
-
/** Bound server-controlled object keys before forwarding them to a debug sink. */
|
|
560
|
-
function boundedObjectKeys(value) {
|
|
561
|
-
// Stop before normalization so a hostile envelope cannot force work over
|
|
562
|
-
// every enumerable own key. Some early keys may normalize away; bounded
|
|
563
|
-
// diagnostics are more important here than filling every available slot.
|
|
564
|
-
const retained = [];
|
|
565
|
-
let retainedBytes = 0;
|
|
566
|
-
for (const rawKey of firstEnumerableOwnKeys(value, MAX_DEBUG_BODY_KEYS)) {
|
|
567
|
-
const key = boundedErrorSnippet(rawKey);
|
|
568
|
-
if (key === undefined)
|
|
569
|
-
continue;
|
|
570
|
-
const keyBytes = TEXT_ENCODER.encode(key).byteLength;
|
|
571
|
-
// A later, smaller key can still fit even when this one cannot.
|
|
572
|
-
if (retainedBytes + keyBytes > MAX_DIAGNOSTIC_COLLECTION_BYTES)
|
|
573
|
-
continue;
|
|
574
|
-
retained.push(key);
|
|
575
|
-
retainedBytes += keyBytes;
|
|
576
|
-
}
|
|
577
|
-
return retained;
|
|
578
|
-
}
|
|
579
122
|
async function cancelResponseBody(body) {
|
|
580
123
|
if (!body)
|
|
581
124
|
return;
|
|
@@ -586,24 +129,27 @@ async function cancelResponseBody(body) {
|
|
|
586
129
|
// Best-effort resource cleanup must not replace the deterministic SDK error.
|
|
587
130
|
}
|
|
588
131
|
}
|
|
589
|
-
function materializedResponseBodyExceedsLimit(value) {
|
|
590
|
-
if (value.length >
|
|
132
|
+
function materializedResponseBodyExceedsLimit(value, limit) {
|
|
133
|
+
if (value.length > limit)
|
|
591
134
|
return true;
|
|
592
135
|
// Encoding creates another buffer. UTF-8 uses at most three bytes per
|
|
593
136
|
// UTF-16 code unit, so shorter strings are provably safe without that copy.
|
|
594
|
-
return
|
|
595
|
-
TEXT_ENCODER.encode(value).byteLength > MAX_RESPONSE_BODY_BYTES);
|
|
137
|
+
return value.length * 3 > limit && TEXT_ENCODER.encode(value).byteLength > limit;
|
|
596
138
|
}
|
|
597
|
-
function contentLengthExceedsLimit(response) {
|
|
139
|
+
function contentLengthExceedsLimit(response, limit) {
|
|
598
140
|
// 204/205 never carry content, and a 304 Content-Length describes the
|
|
599
141
|
// selected representation rather than a response body. Do not reject those
|
|
600
142
|
// statuses for metadata about bytes that fetch will not deliver.
|
|
601
143
|
if (response.status === 204 || response.status === 205 || response.status === 304)
|
|
602
144
|
return false;
|
|
145
|
+
// Encoded Content-Length describes wire bytes; fetch exposes decoded bytes.
|
|
146
|
+
const encoding = response.headers.get("content-encoding");
|
|
147
|
+
if (encoding && encoding.trim().toLowerCase() !== "identity")
|
|
148
|
+
return false;
|
|
603
149
|
const value = response.headers.get("content-length");
|
|
604
150
|
if (value === null || !/^\d+$/.test(value.trim()))
|
|
605
151
|
return false;
|
|
606
|
-
return Number(value) >
|
|
152
|
+
return Number(value) > limit;
|
|
607
153
|
}
|
|
608
154
|
/** Detect a followed redirect from fetch implementations that expose only the final URL. */
|
|
609
155
|
function responseUrlState(responseUrl, requestUrl) {
|
|
@@ -631,16 +177,16 @@ function responseUrlState(responseUrl, requestUrl) {
|
|
|
631
177
|
* optimization only; streaming byte accounting remains authoritative because
|
|
632
178
|
* that header may be absent or false.
|
|
633
179
|
*/
|
|
634
|
-
async function readBoundedResponseBody(response) {
|
|
180
|
+
async function readBoundedResponseBody(response, limit) {
|
|
635
181
|
// `headers.get` is part of the documented injected-fetch contract. Detect a
|
|
636
182
|
// malformed local shim before it can be mistaken for a retryable body-stream
|
|
637
183
|
// failure and replay an otherwise deterministic GET programming error.
|
|
638
184
|
if (typeof response.headers?.get !== "function") {
|
|
639
185
|
throw new ResponseBodyMaterializationError("Response-like fetch must provide headers.get(name)");
|
|
640
186
|
}
|
|
641
|
-
if (contentLengthExceedsLimit(response)) {
|
|
187
|
+
if (contentLengthExceedsLimit(response, limit)) {
|
|
642
188
|
await cancelResponseBody(response.body);
|
|
643
|
-
throw new ResponseBodyTooLargeError();
|
|
189
|
+
throw new ResponseBodyTooLargeError(limit);
|
|
644
190
|
}
|
|
645
191
|
const body = response.body;
|
|
646
192
|
if (body === null)
|
|
@@ -662,8 +208,8 @@ async function readBoundedResponseBody(response) {
|
|
|
662
208
|
}
|
|
663
209
|
}
|
|
664
210
|
const text = typeof materializedText === "string" ? materializedText : "";
|
|
665
|
-
if (materializedResponseBodyExceedsLimit(text)) {
|
|
666
|
-
throw new ResponseBodyTooLargeError();
|
|
211
|
+
if (materializedResponseBodyExceedsLimit(text, limit)) {
|
|
212
|
+
throw new ResponseBodyTooLargeError(limit);
|
|
667
213
|
}
|
|
668
214
|
if (text !== "" ||
|
|
669
215
|
response.status === 204 ||
|
|
@@ -691,8 +237,8 @@ async function readBoundedResponseBody(response) {
|
|
|
691
237
|
}
|
|
692
238
|
if (serialized === undefined)
|
|
693
239
|
return "";
|
|
694
|
-
if (materializedResponseBodyExceedsLimit(serialized)) {
|
|
695
|
-
throw new ResponseBodyTooLargeError();
|
|
240
|
+
if (materializedResponseBodyExceedsLimit(serialized, limit)) {
|
|
241
|
+
throw new ResponseBodyTooLargeError(limit);
|
|
696
242
|
}
|
|
697
243
|
return serialized;
|
|
698
244
|
}
|
|
@@ -708,14 +254,14 @@ async function readBoundedResponseBody(response) {
|
|
|
708
254
|
throw new ResponseBodyMaterializationError("Response-like fetch body stream must yield Uint8Array chunks");
|
|
709
255
|
}
|
|
710
256
|
const nextTotal = total + value.byteLength;
|
|
711
|
-
if (nextTotal >
|
|
257
|
+
if (nextTotal > limit) {
|
|
712
258
|
try {
|
|
713
259
|
await reader.cancel();
|
|
714
260
|
}
|
|
715
261
|
catch {
|
|
716
262
|
// Preserve the body-limit failure even if stream cancellation fails.
|
|
717
263
|
}
|
|
718
|
-
throw new ResponseBodyTooLargeError();
|
|
264
|
+
throw new ResponseBodyTooLargeError(limit);
|
|
719
265
|
}
|
|
720
266
|
chunks.push(value);
|
|
721
267
|
total = nextTotal;
|
|
@@ -743,133 +289,6 @@ async function readBoundedResponseBody(response) {
|
|
|
743
289
|
throw new InvalidResponseEncodingError();
|
|
744
290
|
}
|
|
745
291
|
}
|
|
746
|
-
// ---- Spec-derived validation helpers ------------------------------------
|
|
747
|
-
// These mirror constraints documented on each request schema in
|
|
748
|
-
// `openapi.yaml` so obvious mistakes fail fast instead of round-tripping
|
|
749
|
-
// to the API and coming back as a generic 400.
|
|
750
|
-
const MAX_TARGET_URL = 2048;
|
|
751
|
-
const MAX_LABEL = 500;
|
|
752
|
-
const MAX_BATCH_ITEMS = 100;
|
|
753
|
-
const MAX_DESCRIPTION = 500;
|
|
754
|
-
const MAX_CUSTOM_DOMAIN = 253;
|
|
755
|
-
const MAX_MAX_SESSIONS = 1000;
|
|
756
|
-
const MAX_API_KEY_NAME = 100;
|
|
757
|
-
const API_KEY_REQUEST_SCOPE_VALUES = [
|
|
758
|
-
"qurl:read",
|
|
759
|
-
"qurl:write",
|
|
760
|
-
"qurl:resolve",
|
|
761
|
-
"qurl:agent",
|
|
762
|
-
];
|
|
763
|
-
assertExhaustive(true);
|
|
764
|
-
const API_KEY_REQUEST_SCOPES = new Set(API_KEY_REQUEST_SCOPE_VALUES);
|
|
765
|
-
/** Narrow a response scope before reusing it in a create/update request. */
|
|
766
|
-
export function isApiKeyRequestScope(scope) {
|
|
767
|
-
return API_KEY_REQUEST_SCOPES.has(scope);
|
|
768
|
-
}
|
|
769
|
-
const CREDENTIAL_TARGET_VALUES = [
|
|
770
|
-
"agent",
|
|
771
|
-
"connector",
|
|
772
|
-
];
|
|
773
|
-
assertExhaustive(true);
|
|
774
|
-
const CREDENTIAL_TARGETS = new Set(CREDENTIAL_TARGET_VALUES);
|
|
775
|
-
const CREDENTIAL_CLAIM_TYPE_VALUES = [
|
|
776
|
-
"connector",
|
|
777
|
-
];
|
|
778
|
-
assertExhaustive(true);
|
|
779
|
-
const CREDENTIAL_CLAIM_TYPES = new Set(CREDENTIAL_CLAIM_TYPE_VALUES);
|
|
780
|
-
// Mirrors CreateApiKeyRequest.expires_in in the service OpenAPI. Composite
|
|
781
|
-
// durations are deliberately invalid on this endpoint. The 24h cap permits
|
|
782
|
-
// 1d, but no positive whole-week duration.
|
|
783
|
-
const CREDENTIAL_DURATION_PATTERN = /^([0-9]+)([smhdw])$/;
|
|
784
|
-
const CREDENTIAL_DURATION_MULTIPLIERS = {
|
|
785
|
-
s: 1n,
|
|
786
|
-
m: 60n,
|
|
787
|
-
h: 3600n,
|
|
788
|
-
d: 86400n,
|
|
789
|
-
w: 604800n,
|
|
790
|
-
};
|
|
791
|
-
const MAX_ENROLLMENT_TOKEN_TTL_SECONDS = 86400n;
|
|
792
|
-
const MAX_ALIAS = 64;
|
|
793
|
-
const MAX_SLUG = 64;
|
|
794
|
-
const MAX_TARGET_PATH = 2048;
|
|
795
|
-
const MAX_TAGS = 10;
|
|
796
|
-
const MAX_TAG_LENGTH = 50;
|
|
797
|
-
const MAX_AUTO_PAGINATION_PAGES = 10_000;
|
|
798
|
-
const MIN_DELEGATED_BATCH_IDEMPOTENCY_KEY = 32;
|
|
799
|
-
const MAX_DELEGATED_MINT_CAPABILITY = 8192;
|
|
800
|
-
const MAX_DELEGATED_ETAG = 96;
|
|
801
|
-
const MAX_DELEGATED_BATCH_ITEMS = 100;
|
|
802
|
-
const DELEGATED_BATCH_ID_PATTERN = /^dqb_[A-Za-z0-9_-]{22}$/;
|
|
803
|
-
const DELEGATED_QURL_ID_PATTERN = /^q_[0-9a-f]{11}$/;
|
|
804
|
-
const STRONG_ETAG_PATTERN = /^"[\x21\x23-\x7e]+"$/;
|
|
805
|
-
const UTC_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
|
|
806
|
-
// Keep these identity contracts aligned with qurl-go and the public API.
|
|
807
|
-
const CONNECTOR_SLUG_PATTERN = /^[a-z][a-z0-9-]{1,62}[a-z0-9]$/;
|
|
808
|
-
// Current service schemas intentionally share this grammar, but keep the
|
|
809
|
-
// mutable alias contract separate from the immutable lookup identity.
|
|
810
|
-
const CONNECTOR_ALIAS_PATTERN = /^[a-z][a-z0-9-]{1,62}[a-z0-9]$/;
|
|
811
|
-
const CONNECTOR_RESOURCE_ID_PATTERN = /^[A-Za-z0-9_-]{122}$/;
|
|
812
|
-
const CONNECTOR_RESOURCE_ID_DER_PREFIX = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE";
|
|
813
|
-
const CONNECTOR_ROUTING_ID_PATTERN = /^c-[a-z2-7]{51}[aq]$/;
|
|
814
|
-
// CreateQurlRequest.target_url pattern is loose (just a URI) but
|
|
815
|
-
// UpdateQurlRequest.tags pattern is specific — enforce it here.
|
|
816
|
-
const TAG_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9 _-]*$/;
|
|
817
|
-
function requireMaxLength(value, field, max) {
|
|
818
|
-
if (value === undefined)
|
|
819
|
-
return;
|
|
820
|
-
// Untyped-JS safety: a non-string `value` would silently pass since
|
|
821
|
-
// `(42).length === undefined > max` is `false` and the bad value would
|
|
822
|
-
// sail through to the wire. Match the typeof guard pattern used by
|
|
823
|
-
// requireValidTags / requireValidTargetUrl.
|
|
824
|
-
if (typeof value !== "string") {
|
|
825
|
-
throw clientValidationError(`${field}: must be a string (got ${value === null ? "null" : typeof value})`);
|
|
826
|
-
}
|
|
827
|
-
if (value.length > max) {
|
|
828
|
-
throw clientValidationError(`${field}: must be ${max} characters or fewer (got ${value.length})`);
|
|
829
|
-
}
|
|
830
|
-
}
|
|
831
|
-
function requireMaxUtf8Bytes(value, field, max) {
|
|
832
|
-
if (value === undefined)
|
|
833
|
-
return;
|
|
834
|
-
if (typeof value !== "string") {
|
|
835
|
-
throw clientValidationError(`${field}: must be a string (got ${value === null ? "null" : typeof value})`);
|
|
836
|
-
}
|
|
837
|
-
const bytes = TEXT_ENCODER.encode(value).byteLength;
|
|
838
|
-
if (bytes > max) {
|
|
839
|
-
throw clientValidationError(`${field}: must be ${max} UTF-8 bytes or fewer (got ${bytes})`);
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
function requireNonEmptyIfPresent(value, field) {
|
|
843
|
-
// Reject `""` so uninitialized form state doesn't reach the wire.
|
|
844
|
-
if (value === "") {
|
|
845
|
-
throw clientValidationError(`${field}: must not be an empty string`);
|
|
846
|
-
}
|
|
847
|
-
}
|
|
848
|
-
function requireMaxSessionsInRange(value, field = "max_sessions") {
|
|
849
|
-
if (value === undefined)
|
|
850
|
-
return;
|
|
851
|
-
if (!Number.isInteger(value) || value < 0 || value > MAX_MAX_SESSIONS) {
|
|
852
|
-
throw clientValidationError(`${field}: must be an integer between 0 and ${MAX_MAX_SESSIONS} (got ${value})`);
|
|
853
|
-
}
|
|
854
|
-
}
|
|
855
|
-
function requireBooleanIfPresent(value, field) {
|
|
856
|
-
if (value === undefined)
|
|
857
|
-
return;
|
|
858
|
-
if (typeof value !== "boolean") {
|
|
859
|
-
throw clientValidationError(`${field}: must be a boolean (got ${describeShape(value)})`);
|
|
860
|
-
}
|
|
861
|
-
}
|
|
862
|
-
function requireListLimitInRange(value, methodName) {
|
|
863
|
-
if (value === undefined || value === null)
|
|
864
|
-
return;
|
|
865
|
-
// All current paginated OpenAPI query params share 1..100. If a future
|
|
866
|
-
// endpoint diverges, add a per-endpoint bound rather than changing this
|
|
867
|
-
// shared guard silently.
|
|
868
|
-
if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > 100) {
|
|
869
|
-
const rendered = typeof value === "string" ? JSON.stringify(value) : String(value);
|
|
870
|
-
throw clientValidationError(`${methodName}: limit: must be an integer between 1 and 100 (got ${rendered})`);
|
|
871
|
-
}
|
|
872
|
-
}
|
|
873
292
|
function appendQuery(path, input, keys, methodName) {
|
|
874
293
|
validateQueryInput(input, keys, methodName);
|
|
875
294
|
const params = new URLSearchParams();
|
|
@@ -905,1330 +324,17 @@ function validateListAllInput(input, keys, methodName) {
|
|
|
905
324
|
// each request revalidates the threaded cursor/filter set before dispatch.
|
|
906
325
|
validateQueryInput(input, keys, methodName);
|
|
907
326
|
}
|
|
327
|
+
function withEnvelopeMetadata(value, meta) {
|
|
328
|
+
return withResponseMetadata(value, boundedErrorIdentifier(meta?.request_id));
|
|
329
|
+
}
|
|
908
330
|
function pageFromMeta(out, meta) {
|
|
909
|
-
return {
|
|
331
|
+
return withEnvelopeMetadata({
|
|
910
332
|
...out,
|
|
911
333
|
next_cursor: meta?.next_cursor,
|
|
912
334
|
has_more: meta?.has_more ?? Boolean(meta?.next_cursor),
|
|
913
335
|
request_id: meta?.request_id,
|
|
914
336
|
page_size: meta?.page_size,
|
|
915
|
-
};
|
|
916
|
-
}
|
|
917
|
-
// Best-effort defence-in-depth against today's access-token grammar. The
|
|
918
|
-
// service remains authoritative because resource IDs and credentials are both
|
|
919
|
-
// opaque and future credential formats may not retain the `at_` prefix. The
|
|
920
|
-
// delimiter requirement cannot collide with current base64url public keys,
|
|
921
|
-
// standard-base64 keys, or base32 CRIDs.
|
|
922
|
-
const PATH_EMBEDDED_ACCESS_TOKEN_RE = /[?&#=:\s]at_/i;
|
|
923
|
-
const PATH_PREFIXED_ACCESS_TOKEN_RE = /\/at_/i;
|
|
924
|
-
const QURL_LINK_URL_RE = /^(?:https?:\/\/)?(?:[a-z0-9-]+\.)*qurl\.link(?::\d{1,5})?(?:\/|[?#])/i;
|
|
925
|
-
// ponytail: today's 22-character token suffix only improves URL-paste diagnostics;
|
|
926
|
-
// other lengths are still rejected as URLs without echoing the input.
|
|
927
|
-
const URL_EMBEDDED_ACCESS_TOKEN_RE = /[?&#=:/]at_[a-z0-9_-]{22}(?:$|[?&#:/])/i;
|
|
928
|
-
// A host without a slash can be a legitimate domain identifier. Require the
|
|
929
|
-
// path boundary so shared validation does not break the domain namespace.
|
|
930
|
-
const PATH_SCHEMELESS_URL_RE = /^[a-z0-9-]+(?:\.[a-z0-9-]+)+(?::\d{1,5})?\//i;
|
|
931
|
-
const QURL_DISPLAY_ID_PREFIX_RE = /^q_/i;
|
|
932
|
-
const MAX_PATH_ID_DECODE_PASSES = 3;
|
|
933
|
-
const MAX_PATH_ID_LENGTH = 4096;
|
|
934
|
-
function isUrlLikePathId(value) {
|
|
935
|
-
return /^https?:\/\//i.test(value) || PATH_SCHEMELESS_URL_RE.test(value);
|
|
936
|
-
}
|
|
937
|
-
const REVOKE_INDIVIDUAL_QURL_RECOVERY = "revoke individual tokens with revokeResourceQurl(crid, qurlId)";
|
|
938
|
-
const DELETE_RESOURCE_RECOVERY = "pass a CRID returned by the API; to revoke one qURL, call revokeResourceQurl(crid, qurlId)";
|
|
939
|
-
const RESOURCE_OR_QURL_ID_PATH_OPTIONS = {
|
|
940
|
-
requireCridUnlessQurlDisplayId: true,
|
|
941
|
-
};
|
|
942
|
-
const QURL_ID_PATH_OPTIONS = {
|
|
943
|
-
rejectBareAccessToken: true,
|
|
944
|
-
accessTokenRecovery: "pass the qURL display ID, not its access token",
|
|
945
|
-
};
|
|
946
|
-
/**
|
|
947
|
-
* Validates that a path-parameter argument is a non-empty string. Some callers
|
|
948
|
-
* pass resource/qURL display IDs, while newer endpoints also pass domains,
|
|
949
|
-
* session IDs, webhook IDs, and API-key IDs, so this intentionally enforces
|
|
950
|
-
* only basic shape plus transport-safety rules and leaves endpoint-specific
|
|
951
|
-
* identifier grammar to the service.
|
|
952
|
-
*/
|
|
953
|
-
function validatePathId(id, method, field = "id", options = {}) {
|
|
954
|
-
if (options.requireCridUnlessQurlDisplayId && typeof id === "string" && !id.startsWith("q_")) {
|
|
955
|
-
requireResourceCrid(id, method, field);
|
|
956
|
-
return;
|
|
957
|
-
}
|
|
958
|
-
// `.trim()` catches whitespace-only and padded IDs before they round-trip as
|
|
959
|
-
// `%20...%20` paths that the server can only reject with a less useful 404.
|
|
960
|
-
// The pre-flight error is more actionable than the 404.
|
|
961
|
-
if (typeof id !== "string") {
|
|
962
|
-
const observedType = id === null ? "null" : typeof id;
|
|
963
|
-
throw clientValidationError(`${method}: ${field} is required (got ${observedType})`);
|
|
964
|
-
}
|
|
965
|
-
if (id.trim() === "") {
|
|
966
|
-
throw clientValidationError(`${method}: ${field} is required`);
|
|
967
|
-
}
|
|
968
|
-
if (id.trim() !== id) {
|
|
969
|
-
throw clientValidationError(`${method}: ${field} must not include leading or trailing whitespace`);
|
|
970
|
-
}
|
|
971
|
-
if (id.length > MAX_PATH_ID_LENGTH) {
|
|
972
|
-
throw clientValidationError(`${method}: ${field} must be ${MAX_PATH_ID_LENGTH} characters or fewer`);
|
|
973
|
-
}
|
|
974
|
-
const decodedIds = decodedPathIdForms(id);
|
|
975
|
-
// URL resolvers and intermediaries can normalize raw or pre-encoded dot
|
|
976
|
-
// segments before routing. Reject every decoded form we inspected so an
|
|
977
|
-
// item operation cannot be retargeted to a collection endpoint. Embedded
|
|
978
|
-
// `a/../b` remains an opaque single segment because the later URL builder
|
|
979
|
-
// percent-encodes its slash.
|
|
980
|
-
if (decodedIds.some((value) => value === "." || value === "..")) {
|
|
981
|
-
throw clientValidationError(`${method}: ${field} is an invalid URL path segment`);
|
|
982
|
-
}
|
|
983
|
-
// `/` is deliberately outside the general delimiter class so a URL path
|
|
984
|
-
// such as `/at_a_glance` is not misread as a credential. For non-URL input,
|
|
985
|
-
// `/at_` remains a strong pasted-path signal. Fragment, query, header, and
|
|
986
|
-
// pasted `Bearer at_...` / `token:at_...` forms are also strong signals.
|
|
987
|
-
// Bare `at_...` values are rejected only for resource/qURL identifiers:
|
|
988
|
-
// those current formats have fixed non-colliding prefixes, while domain,
|
|
989
|
-
// webhook, session, and API-key IDs are deliberately opaque/open contracts.
|
|
990
|
-
// Full URLs and delimiter/path-prefixed credentials remain rejected in
|
|
991
|
-
// every namespace.
|
|
992
|
-
const containsUrl = decodedIds.some(isUrlLikePathId);
|
|
993
|
-
const containsAccessToken = decodedIds.some((value) => {
|
|
994
|
-
const valueIsUrl = isUrlLikePathId(value);
|
|
995
|
-
return ((QURL_LINK_URL_RE.test(value) &&
|
|
996
|
-
(PATH_EMBEDDED_ACCESS_TOKEN_RE.test(value) || PATH_PREFIXED_ACCESS_TOKEN_RE.test(value))) ||
|
|
997
|
-
(valueIsUrl
|
|
998
|
-
? URL_EMBEDDED_ACCESS_TOKEN_RE.test(value)
|
|
999
|
-
: PATH_EMBEDDED_ACCESS_TOKEN_RE.test(value) || PATH_PREFIXED_ACCESS_TOKEN_RE.test(value)) ||
|
|
1000
|
-
(options.rejectBareAccessToken === true && value.slice(0, 3).toLowerCase() === "at_"));
|
|
1001
|
-
});
|
|
1002
|
-
if (containsAccessToken) {
|
|
1003
|
-
const recovery = options.accessTokenRecovery ?? "pass the identifier returned by the API";
|
|
1004
|
-
throw clientValidationError(`${method}: ${field} must not contain an access token; ${recovery}`);
|
|
1005
|
-
}
|
|
1006
|
-
// A full target/qURL is a common argument mix-up, but it is distinct from
|
|
1007
|
-
// passing an access-token credential. Diagnose it accurately without
|
|
1008
|
-
// echoing the URL, which may itself contain sensitive query parameters.
|
|
1009
|
-
if (containsUrl) {
|
|
1010
|
-
throw clientValidationError(`${method}: ${field} must be an identifier, not a URL`);
|
|
1011
|
-
}
|
|
1012
|
-
if (options.rejectQurlDisplayId === true &&
|
|
1013
|
-
decodedIds.some((value) => QURL_DISPLAY_ID_PREFIX_RE.test(value))) {
|
|
1014
|
-
throw clientValidationError(`${method}: ${field} must not be a qURL display ID; ${REVOKE_INDIVIDUAL_QURL_RECOVERY}`);
|
|
1015
|
-
}
|
|
1016
|
-
}
|
|
1017
|
-
function decodedPathIdForms(id) {
|
|
1018
|
-
// Probe repeatedly encoded input too. Callers sometimes pre-encode a copied
|
|
1019
|
-
// link before passing it through a URL builder that encodes it again. Three
|
|
1020
|
-
// passes cover the common accidental cases without allowing hostile input
|
|
1021
|
-
// to create unbounded work. More deeply encoded or malformed-percent input
|
|
1022
|
-
// deliberately fails open to the authoritative service after safe segment
|
|
1023
|
-
// encoding; this heuristic is not an identifier parser.
|
|
1024
|
-
const decodedIds = [id];
|
|
1025
|
-
let decodedId = id;
|
|
1026
|
-
for (let pass = 0; pass < MAX_PATH_ID_DECODE_PASSES; pass += 1) {
|
|
1027
|
-
try {
|
|
1028
|
-
const next = decodeURIComponent(decodedId);
|
|
1029
|
-
if (next === decodedId)
|
|
1030
|
-
break;
|
|
1031
|
-
decodedIds.push(next);
|
|
1032
|
-
decodedId = next;
|
|
1033
|
-
}
|
|
1034
|
-
catch {
|
|
1035
|
-
// Malformed percent escapes remain opaque and are left to the service.
|
|
1036
|
-
break;
|
|
1037
|
-
}
|
|
1038
|
-
}
|
|
1039
|
-
return decodedIds;
|
|
1040
|
-
}
|
|
1041
|
-
function requireConnectorSlug(slug, method) {
|
|
1042
|
-
if (typeof slug !== "string" || !CONNECTOR_SLUG_PATTERN.test(slug)) {
|
|
1043
|
-
throw clientValidationError(`${method}: slug must be 3-64 lowercase alphanumeric or hyphen characters, start with a letter, and end alphanumeric`);
|
|
1044
|
-
}
|
|
1045
|
-
}
|
|
1046
|
-
function classifyConnectorMutationFailure(operation, error) {
|
|
1047
|
-
if (!(error instanceof QURLError)) {
|
|
1048
|
-
throw new ConnectorResourceOutcomeUnknownError(new RuntimeError("Unexpected failure after Connector mutation dispatch", { cause: error }));
|
|
1049
|
-
}
|
|
1050
|
-
// Most authoritative 4xx responses prove that the mutation was rejected.
|
|
1051
|
-
// bootstrap_key_consumed is the exception: the service returns it from the
|
|
1052
|
-
// consume phase after the resource may have been bound or found.
|
|
1053
|
-
// Callers perform argument/runtime preflight outside their mutation try
|
|
1054
|
-
// blocks; every other surfaced failure therefore follows dispatch or a
|
|
1055
|
-
// nominal success whose resource contract could not be consumed, so callers
|
|
1056
|
-
// must reconcile before retrying. Ensure's exact 201 + valid resource but
|
|
1057
|
-
// missing found_existing is handled after this classifier: the row proves
|
|
1058
|
-
// the selected resource, while only its required metadata is unavailable.
|
|
1059
|
-
if (operation === "ensure" && error.status === 409 && error.code === "bootstrap_key_consumed") {
|
|
1060
|
-
throw new ConnectorResourceOutcomeUnknownError(error);
|
|
1061
|
-
}
|
|
1062
|
-
if (error.status >= 400 && error.status < 500) {
|
|
1063
|
-
throw error;
|
|
1064
|
-
}
|
|
1065
|
-
throw new ConnectorResourceOutcomeUnknownError(error);
|
|
1066
|
-
}
|
|
1067
|
-
function decodeCanonicalBase64Url(value) {
|
|
1068
|
-
if (!CONNECTOR_RESOURCE_ID_PATTERN.test(value) ||
|
|
1069
|
-
!value.startsWith(CONNECTOR_RESOURCE_ID_DER_PREFIX)) {
|
|
1070
|
-
return undefined;
|
|
1071
|
-
}
|
|
1072
|
-
try {
|
|
1073
|
-
const padded = value.replace(/-/g, "+").replace(/_/g, "/") + "==";
|
|
1074
|
-
const binary = atob(padded);
|
|
1075
|
-
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
1076
|
-
const canonical = btoa(String.fromCharCode(...bytes))
|
|
1077
|
-
.replace(/\+/g, "-")
|
|
1078
|
-
.replace(/\//g, "_")
|
|
1079
|
-
.replace(/=+$/, "");
|
|
1080
|
-
return canonical === value ? bytes : undefined;
|
|
1081
|
-
}
|
|
1082
|
-
catch {
|
|
1083
|
-
return undefined;
|
|
1084
|
-
}
|
|
1085
|
-
}
|
|
1086
|
-
function requireConnectorSubtleCrypto(method) {
|
|
1087
|
-
if (!globalThis.crypto?.subtle) {
|
|
1088
|
-
throw new RuntimeError(`${method}: requires the Web Crypto SubtleCrypto API`);
|
|
1089
|
-
}
|
|
1090
|
-
}
|
|
1091
|
-
function requireResourceCrid(crid, method, field = "id") {
|
|
1092
|
-
if (parseCrid(crid))
|
|
1093
|
-
return;
|
|
1094
|
-
// Keep actionable paste errors without inspecting valid CRIDs twice.
|
|
1095
|
-
validatePathId(crid, method, field, {
|
|
1096
|
-
rejectBareAccessToken: true,
|
|
1097
|
-
rejectQurlDisplayId: true,
|
|
1098
|
-
accessTokenRecovery: method === "delete" || method === "deleteResource"
|
|
1099
|
-
? DELETE_RESOURCE_RECOVERY
|
|
1100
|
-
: "pass a CRID returned by the API",
|
|
1101
|
-
});
|
|
1102
|
-
throw clientValidationError(`${method}: requires a valid CRID`);
|
|
1103
|
-
}
|
|
1104
|
-
async function importsAsConnectorPublicKey(der) {
|
|
1105
|
-
try {
|
|
1106
|
-
const keyData = new ArrayBuffer(der.byteLength);
|
|
1107
|
-
new Uint8Array(keyData).set(der);
|
|
1108
|
-
await globalThis.crypto.subtle.importKey("spki", keyData, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
|
|
1109
|
-
return true;
|
|
1110
|
-
}
|
|
1111
|
-
catch {
|
|
1112
|
-
return false;
|
|
1113
|
-
}
|
|
1114
|
-
}
|
|
1115
|
-
function containsControlCharacter(value) {
|
|
1116
|
-
return Array.from(value).some((character) => {
|
|
1117
|
-
const codePoint = character.codePointAt(0);
|
|
1118
|
-
return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);
|
|
1119
|
-
});
|
|
1120
|
-
}
|
|
1121
|
-
async function verifyResourceIdentity(resource, method, expectedCrid) {
|
|
1122
|
-
if (!resource || typeof resource.resource_id !== "string") {
|
|
1123
|
-
throw unexpectedResponseError(`${method}: response has missing or invalid resource_id`);
|
|
1124
|
-
}
|
|
1125
|
-
const resourceKey = decodeCanonicalBase64Url(resource.resource_id);
|
|
1126
|
-
requireConnectorSubtleCrypto(method);
|
|
1127
|
-
if (!resourceKey || !(await importsAsConnectorPublicKey(resourceKey))) {
|
|
1128
|
-
throw unexpectedResponseError(`${method}: response has missing or invalid resource_id`);
|
|
1129
|
-
}
|
|
1130
|
-
if (expectedCrid !== undefined && resource.crid !== expectedCrid) {
|
|
1131
|
-
throw unexpectedResponseError(`${method}: response crid does not match the request`);
|
|
1132
|
-
}
|
|
1133
|
-
if (!(await cridKeyMatches(resource.crid, resourceKey))) {
|
|
1134
|
-
throw unexpectedResponseError(`${method}: response has missing, invalid, or public-key-mismatched crid`);
|
|
1135
|
-
}
|
|
1136
|
-
}
|
|
1137
|
-
async function parseConnectorResource(client, value, method, expectation = {}) {
|
|
1138
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
1139
|
-
throw unexpectedResponseError(`${method}: response is missing connector resource data`);
|
|
1140
|
-
}
|
|
1141
|
-
const resource = value;
|
|
1142
|
-
await verifyResourceIdentity(resource, method, expectation.crid);
|
|
1143
|
-
if (typeof resource.connector_routing_id !== "string" ||
|
|
1144
|
-
!CONNECTOR_ROUTING_ID_PATTERN.test(resource.connector_routing_id)) {
|
|
1145
|
-
throw unexpectedResponseError(`${method}: response has missing or invalid connector_routing_id`);
|
|
1146
|
-
}
|
|
1147
|
-
if (typeof resource.knock_resource_id !== "string" ||
|
|
1148
|
-
resource.knock_resource_id.trim() === "" ||
|
|
1149
|
-
resource.knock_resource_id.trim() !== resource.knock_resource_id ||
|
|
1150
|
-
containsControlCharacter(resource.knock_resource_id)) {
|
|
1151
|
-
throw unexpectedResponseError(`${method}: response has missing or invalid knock_resource_id`);
|
|
1152
|
-
}
|
|
1153
|
-
if (resource.resource_id === resource.knock_resource_id ||
|
|
1154
|
-
resource.connector_routing_id === resource.knock_resource_id) {
|
|
1155
|
-
throw unexpectedResponseError(`${method}: response cross-wires resource identity, routing, and admission values`);
|
|
1156
|
-
}
|
|
1157
|
-
if (resource.type !== "tunnel") {
|
|
1158
|
-
throw unexpectedResponseError(`${method}: response resource type is not tunnel`);
|
|
1159
|
-
}
|
|
1160
|
-
if (typeof resource.slug !== "string" || !CONNECTOR_SLUG_PATTERN.test(resource.slug)) {
|
|
1161
|
-
throw unexpectedResponseError(`${method}: response has missing or invalid slug`);
|
|
1162
|
-
}
|
|
1163
|
-
if (expectation.slug !== undefined && resource.slug !== expectation.slug) {
|
|
1164
|
-
throw unexpectedResponseError(`${method}: response slug does not match the request`);
|
|
1165
|
-
}
|
|
1166
|
-
if (resource.alias !== undefined &&
|
|
1167
|
-
resource.alias !== null &&
|
|
1168
|
-
(typeof resource.alias !== "string" || !CONNECTOR_ALIAS_PATTERN.test(resource.alias))) {
|
|
1169
|
-
// The public API intentionally gives mutable aliases and immutable slugs the
|
|
1170
|
-
// same canonical wire grammar (domain aliasPattern/slugPattern). Keeping
|
|
1171
|
-
// response validation aligned avoids accepting a row the service itself
|
|
1172
|
-
// could not create or update.
|
|
1173
|
-
throw unexpectedResponseError(`${method}: response has invalid alias`);
|
|
1174
|
-
}
|
|
1175
|
-
if (resource.status === "revoked") {
|
|
1176
|
-
if (expectation.revokedIsLifecycleError !== true) {
|
|
1177
|
-
throw unexpectedResponseError(`${method}: active-only operation returned a revoked connector resource`);
|
|
1178
|
-
}
|
|
1179
|
-
throw new QURLError({
|
|
1180
|
-
status: 0,
|
|
1181
|
-
code: ERROR_CODE_CONNECTOR_RESOURCE_REVOKED,
|
|
1182
|
-
title: "Connector Resource Revoked",
|
|
1183
|
-
detail: `${method}: qURL Connector resource is revoked`,
|
|
1184
|
-
});
|
|
1185
|
-
}
|
|
1186
|
-
if (resource.status !== "active") {
|
|
1187
|
-
throw unexpectedResponseError(`${method}: response has invalid resource status`);
|
|
1188
|
-
}
|
|
1189
|
-
return new ConnectorResource(client, resource, CONNECTOR_RESOURCE_CONSTRUCTOR_TOKEN);
|
|
1190
|
-
}
|
|
1191
|
-
function requireValidTags(tags) {
|
|
1192
|
-
// null tolerance for untyped-JS callers — treated as "don't touch."
|
|
1193
|
-
// Duplicates pass through to the server (authoritative on dedupe policy).
|
|
1194
|
-
if (tags === undefined || tags === null)
|
|
1195
|
-
return;
|
|
1196
|
-
if (!Array.isArray(tags)) {
|
|
1197
|
-
throw clientValidationError(`tags: must be an array of strings (got ${typeof tags})`);
|
|
1198
|
-
}
|
|
1199
|
-
if (tags.length > MAX_TAGS) {
|
|
1200
|
-
throw clientValidationError(`tags: max ${MAX_TAGS} items allowed (got ${tags.length})`);
|
|
1201
|
-
}
|
|
1202
|
-
const errors = [];
|
|
1203
|
-
for (let i = 0; i < tags.length; i++) {
|
|
1204
|
-
const tag = tags[i];
|
|
1205
|
-
// Per-element type guard for untyped-JS callers. Without this, a
|
|
1206
|
-
// non-string element would silently pass (`.length` on a number is
|
|
1207
|
-
// `undefined`; `TAG_PATTERN.test(42)` coerces to `"42"`) or TypeError
|
|
1208
|
-
// (`null.length`).
|
|
1209
|
-
if (typeof tag !== "string") {
|
|
1210
|
-
errors.push(`tags[${i}]: must be a string (got ${tag === null ? "null" : typeof tag})`);
|
|
1211
|
-
continue;
|
|
1212
|
-
}
|
|
1213
|
-
if (tag.length < 1 || tag.length > MAX_TAG_LENGTH) {
|
|
1214
|
-
errors.push(`tags[${i}]: must be 1-${MAX_TAG_LENGTH} characters (got ${tag.length})`);
|
|
1215
|
-
continue;
|
|
1216
|
-
}
|
|
1217
|
-
if (!TAG_PATTERN.test(tag)) {
|
|
1218
|
-
errors.push(`tags[${i}]: must start with an alphanumeric and contain only letters, numbers, spaces, underscores, or hyphens`);
|
|
1219
|
-
}
|
|
1220
|
-
}
|
|
1221
|
-
if (errors.length > 0) {
|
|
1222
|
-
throw clientValidationError(errors.join("; "));
|
|
1223
|
-
}
|
|
1224
|
-
}
|
|
1225
|
-
function describeShape(value) {
|
|
1226
|
-
if (value === null)
|
|
1227
|
-
return "null";
|
|
1228
|
-
if (Array.isArray(value))
|
|
1229
|
-
return "array";
|
|
1230
|
-
return typeof value;
|
|
1231
|
-
}
|
|
1232
|
-
const ACCESS_POLICY_LIST_FIELDS = [
|
|
1233
|
-
"ip_allowlist",
|
|
1234
|
-
"ip_denylist",
|
|
1235
|
-
"geo_allowlist",
|
|
1236
|
-
"geo_denylist",
|
|
1237
|
-
];
|
|
1238
|
-
const ACCESS_POLICY_STRING_FIELDS = [
|
|
1239
|
-
"user_agent_allow_regex",
|
|
1240
|
-
"user_agent_deny_regex",
|
|
1241
|
-
];
|
|
1242
|
-
const AI_AGENT_POLICY_LIST_FIELDS = [
|
|
1243
|
-
"deny_categories",
|
|
1244
|
-
"allow_categories",
|
|
1245
|
-
];
|
|
1246
|
-
function requireValidAccessPolicy(policy) {
|
|
1247
|
-
// null/undefined → "don't touch". Server is authoritative on CIDR /
|
|
1248
|
-
// ISO-3166 / regex grammar; this guard catches only the shape errors
|
|
1249
|
-
// an untyped-JS caller is likely to make (e.g. `geo_allowlist: "US"`
|
|
1250
|
-
// instead of `["US"]`) so they get a structured ValidationError
|
|
1251
|
-
// instead of a server 400 they have to debug.
|
|
1252
|
-
if (policy === undefined || policy === null)
|
|
1253
|
-
return;
|
|
1254
|
-
if (typeof policy !== "object" || Array.isArray(policy)) {
|
|
1255
|
-
throw clientValidationError(`access_policy: must be an object (got ${describeShape(policy)})`);
|
|
1256
|
-
}
|
|
1257
|
-
for (const field of ACCESS_POLICY_LIST_FIELDS) {
|
|
1258
|
-
const value = policy[field];
|
|
1259
|
-
if (value === undefined)
|
|
1260
|
-
continue;
|
|
1261
|
-
if (!Array.isArray(value)) {
|
|
1262
|
-
throw clientValidationError(`access_policy.${field}: must be an array (got ${describeShape(value)})`);
|
|
1263
|
-
}
|
|
1264
|
-
}
|
|
1265
|
-
for (const field of ACCESS_POLICY_STRING_FIELDS) {
|
|
1266
|
-
const value = policy[field];
|
|
1267
|
-
if (value === undefined)
|
|
1268
|
-
continue;
|
|
1269
|
-
if (typeof value !== "string") {
|
|
1270
|
-
throw clientValidationError(`access_policy.${field}: must be a string (got ${describeShape(value)})`);
|
|
1271
|
-
}
|
|
1272
|
-
}
|
|
1273
|
-
const aip = policy.ai_agent_policy;
|
|
1274
|
-
if (aip !== undefined) {
|
|
1275
|
-
if (aip === null || typeof aip !== "object" || Array.isArray(aip)) {
|
|
1276
|
-
throw clientValidationError(`access_policy.ai_agent_policy: must be an object (got ${describeShape(aip)})`);
|
|
1277
|
-
}
|
|
1278
|
-
if (aip.block_all !== undefined && typeof aip.block_all !== "boolean") {
|
|
1279
|
-
throw clientValidationError(`access_policy.ai_agent_policy.block_all: must be a boolean (got ${describeShape(aip.block_all)})`);
|
|
1280
|
-
}
|
|
1281
|
-
for (const field of AI_AGENT_POLICY_LIST_FIELDS) {
|
|
1282
|
-
const value = aip[field];
|
|
1283
|
-
if (value === undefined)
|
|
1284
|
-
continue;
|
|
1285
|
-
if (!Array.isArray(value)) {
|
|
1286
|
-
throw clientValidationError(`access_policy.ai_agent_policy.${field}: must be an array (got ${describeShape(value)})`);
|
|
1287
|
-
}
|
|
1288
|
-
}
|
|
1289
|
-
}
|
|
1290
|
-
}
|
|
1291
|
-
function validateCreateDelegatedQurlBatchInput(input) {
|
|
1292
|
-
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
1293
|
-
throw clientValidationError(`createDelegatedQurlBatch: input must be an object (got ${describeShape(input)})`);
|
|
1294
|
-
}
|
|
1295
|
-
const typed = input;
|
|
1296
|
-
requireNoUnknownFields(typed, DELEGATED_BATCH_INPUT_KEYS, "createDelegatedQurlBatch");
|
|
1297
|
-
if (typeof typed.mint_capability !== "string" ||
|
|
1298
|
-
typed.mint_capability.length < 1 ||
|
|
1299
|
-
typed.mint_capability.length > MAX_DELEGATED_MINT_CAPABILITY) {
|
|
1300
|
-
throw clientValidationError(`createDelegatedQurlBatch: mint_capability must be a string of 1-${MAX_DELEGATED_MINT_CAPABILITY} characters`);
|
|
1301
|
-
}
|
|
1302
|
-
if (!Array.isArray(typed.grants) ||
|
|
1303
|
-
typed.grants.length < 1 ||
|
|
1304
|
-
typed.grants.length > MAX_DELEGATED_BATCH_ITEMS) {
|
|
1305
|
-
throw clientValidationError(`createDelegatedQurlBatch: grants must contain 1-${MAX_DELEGATED_BATCH_ITEMS} items`);
|
|
1306
|
-
}
|
|
1307
|
-
for (const [index, grant] of typed.grants.entries()) {
|
|
1308
|
-
const field = `createDelegatedQurlBatch: grants[${index}]`;
|
|
1309
|
-
if (typeof grant !== "object" || grant === null || Array.isArray(grant)) {
|
|
1310
|
-
throw clientValidationError(`${field}: must be an object (got ${describeShape(grant)})`);
|
|
1311
|
-
}
|
|
1312
|
-
requireNoUnknownFields(grant, DELEGATED_BATCH_GRANT_KEYS, field);
|
|
1313
|
-
requireMaxUtf8Bytes(grant.label, `${field}.label`, MAX_LABEL);
|
|
1314
|
-
requireBooleanIfPresent(grant.one_time_use, `${field}.one_time_use`);
|
|
1315
|
-
requireMaxSessionsInRange(grant.max_sessions, `${field}.max_sessions`);
|
|
1316
|
-
for (const durationField of ["expires_in", "session_duration"]) {
|
|
1317
|
-
const value = grant[durationField];
|
|
1318
|
-
if (value !== undefined && (typeof value !== "string" || value.length === 0)) {
|
|
1319
|
-
throw clientValidationError(`${field}.${durationField}: must be a non-empty string`);
|
|
1320
|
-
}
|
|
1321
|
-
}
|
|
1322
|
-
if (grant.access_policy !== undefined) {
|
|
1323
|
-
requireValidAccessPolicy(grant.access_policy);
|
|
1324
|
-
}
|
|
1325
|
-
}
|
|
1326
|
-
}
|
|
1327
|
-
function validateDelegatedBatchCreateOptions(options) {
|
|
1328
|
-
if (typeof options !== "object" || options === null || Array.isArray(options)) {
|
|
1329
|
-
throw clientValidationError("createDelegatedQurlBatch: options are required");
|
|
1330
|
-
}
|
|
1331
|
-
requireNoUnknownFields(options, DELEGATED_BATCH_CREATE_OPTION_KEYS, "createDelegatedQurlBatch options");
|
|
1332
|
-
const key = options.idempotencyKey;
|
|
1333
|
-
if (typeof key !== "string" ||
|
|
1334
|
-
key.length < MIN_DELEGATED_BATCH_IDEMPOTENCY_KEY ||
|
|
1335
|
-
key.length > MAX_IDEMPOTENCY_KEY ||
|
|
1336
|
-
!/^[!-~]+$/.test(key)) {
|
|
1337
|
-
throw clientValidationError(`createDelegatedQurlBatch: idempotencyKey must contain ${MIN_DELEGATED_BATCH_IDEMPOTENCY_KEY}-${MAX_IDEMPOTENCY_KEY} visible ASCII characters`);
|
|
1338
|
-
}
|
|
1339
|
-
}
|
|
1340
|
-
function validateGetDelegatedQurlBatchOptions(options) {
|
|
1341
|
-
if (options === undefined)
|
|
1342
|
-
return;
|
|
1343
|
-
if (typeof options !== "object" || options === null || Array.isArray(options)) {
|
|
1344
|
-
throw clientValidationError(`getDelegatedQurlBatch: options must be an object (got ${describeShape(options)})`);
|
|
1345
|
-
}
|
|
1346
|
-
requireNoUnknownFields(options, DELEGATED_BATCH_GET_OPTION_KEYS, "getDelegatedQurlBatch options");
|
|
1347
|
-
const etag = options.etag;
|
|
1348
|
-
if (etag !== undefined &&
|
|
1349
|
-
(typeof etag !== "string" ||
|
|
1350
|
-
etag.length > MAX_DELEGATED_ETAG ||
|
|
1351
|
-
!STRONG_ETAG_PATTERN.test(etag))) {
|
|
1352
|
-
throw clientValidationError("getDelegatedQurlBatch: etag must be a valid strong ETag");
|
|
1353
|
-
}
|
|
1354
|
-
}
|
|
1355
|
-
function delegatedResponseError(status, detail, requestId) {
|
|
1356
|
-
return unexpectedResponseError(`Delegated qURL batch ${detail}`, { status, requestId });
|
|
1357
|
-
}
|
|
1358
|
-
function delegatedResponseObject(value, status, label, requestId) {
|
|
1359
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
1360
|
-
throw delegatedResponseError(status, `${label} is not an object`, requestId);
|
|
1361
|
-
}
|
|
1362
|
-
return value;
|
|
1363
|
-
}
|
|
1364
|
-
function delegatedEnvelope(envelope, status) {
|
|
1365
|
-
if (typeof envelope.meta !== "object" || envelope.meta === null || Array.isArray(envelope.meta)) {
|
|
1366
|
-
throw delegatedResponseError(status, "response meta is not an object");
|
|
1367
|
-
}
|
|
1368
|
-
const meta = envelope.meta;
|
|
1369
|
-
if (typeof meta.request_id !== "string") {
|
|
1370
|
-
throw delegatedResponseError(status, "response meta has invalid request_id");
|
|
1371
|
-
}
|
|
1372
|
-
const data = delegatedResponseObject(envelope.data, status, "response data", meta.request_id);
|
|
1373
|
-
return { data, requestId: meta.request_id };
|
|
1374
|
-
}
|
|
1375
|
-
function requiredDelegatedHeader(envelope, name, status, requestId) {
|
|
1376
|
-
const headers = envelope.__http_headers;
|
|
1377
|
-
if (!headers || typeof headers.get !== "function") {
|
|
1378
|
-
throw delegatedResponseError(status, "response headers are unavailable", requestId);
|
|
1379
|
-
}
|
|
1380
|
-
const value = headers.get(name);
|
|
1381
|
-
if (value === null) {
|
|
1382
|
-
throw delegatedResponseError(status, `response is missing ${name}`, requestId);
|
|
1383
|
-
}
|
|
1384
|
-
return value;
|
|
1385
|
-
}
|
|
1386
|
-
function hasDelegatedNoStore(value) {
|
|
1387
|
-
if (value === null)
|
|
1388
|
-
return false;
|
|
1389
|
-
const directives = new Set(value.split(",").map((directive) => directive.trim().split("=", 1)[0].toLowerCase()));
|
|
1390
|
-
return directives.has("private") && directives.has("no-store");
|
|
1391
|
-
}
|
|
1392
|
-
function delegatedResponseHeaders(envelope, status, requestId) {
|
|
1393
|
-
const cacheControl = envelope.__http_headers?.get("Cache-Control") ?? null;
|
|
1394
|
-
if (cacheControl !== null) {
|
|
1395
|
-
if (!hasDelegatedNoStore(cacheControl)) {
|
|
1396
|
-
throw delegatedResponseError(status, "response has invalid Cache-Control", requestId);
|
|
1397
|
-
}
|
|
1398
|
-
}
|
|
1399
|
-
else if (status !== 304) {
|
|
1400
|
-
requiredDelegatedHeader(envelope, "Cache-Control", status, requestId);
|
|
1401
|
-
}
|
|
1402
|
-
const etag = envelope.__http_headers?.get("ETag") ?? undefined;
|
|
1403
|
-
if (etag !== undefined && (etag.length > MAX_DELEGATED_ETAG || !STRONG_ETAG_PATTERN.test(etag))) {
|
|
1404
|
-
throw delegatedResponseError(status, "response has invalid strong ETag", requestId);
|
|
1405
|
-
}
|
|
1406
|
-
const rawRetryAfter = envelope.__http_headers?.get("Retry-After") ?? "";
|
|
1407
|
-
const trimmed = rawRetryAfter.trim();
|
|
1408
|
-
const seconds = /^\d+$/.test(trimmed)
|
|
1409
|
-
? Number(trimmed)
|
|
1410
|
-
: /^[A-Z][a-z]{2}, \d{2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} GMT$/.test(trimmed)
|
|
1411
|
-
? Math.ceil((Date.parse(trimmed) - Date.now()) / 1000)
|
|
1412
|
-
: NaN;
|
|
1413
|
-
const retryAfter = Number.isSafeInteger(seconds) && seconds > 0 && seconds <= RETRY_AFTER_PARSE_LIMIT_S
|
|
1414
|
-
? seconds
|
|
1415
|
-
: undefined;
|
|
1416
|
-
return { etag, retryAfter };
|
|
1417
|
-
}
|
|
1418
|
-
function isUtcTimestamp(value) {
|
|
1419
|
-
return (typeof value === "string" &&
|
|
1420
|
-
UTC_TIMESTAMP_PATTERN.test(value) &&
|
|
1421
|
-
Number.isFinite(Date.parse(value)));
|
|
1422
|
-
}
|
|
1423
|
-
function delegatedBatchBase(data, status, requestId, expectedBatchId) {
|
|
1424
|
-
if (typeof data.batch_id !== "string" ||
|
|
1425
|
-
!DELEGATED_BATCH_ID_PATTERN.test(data.batch_id) ||
|
|
1426
|
-
(expectedBatchId !== undefined && data.batch_id !== expectedBatchId)) {
|
|
1427
|
-
throw delegatedResponseError(status, "response has invalid batch_id", requestId);
|
|
1428
|
-
}
|
|
1429
|
-
if (!Number.isInteger(data.item_count) ||
|
|
1430
|
-
Number(data.item_count) < 1 ||
|
|
1431
|
-
Number(data.item_count) > MAX_DELEGATED_BATCH_ITEMS) {
|
|
1432
|
-
throw delegatedResponseError(status, "response has invalid item_count", requestId);
|
|
1433
|
-
}
|
|
1434
|
-
if (!isUtcTimestamp(data.submitted_at)) {
|
|
1435
|
-
throw delegatedResponseError(status, "response has invalid submitted_at", requestId);
|
|
1436
|
-
}
|
|
1437
|
-
return {
|
|
1438
|
-
batchId: data.batch_id,
|
|
1439
|
-
itemCount: Number(data.item_count),
|
|
1440
|
-
submittedAt: data.submitted_at,
|
|
1441
|
-
};
|
|
1442
|
-
}
|
|
1443
|
-
function validateDelegatedLocation(value, baseUrl, batchId, status, requestId) {
|
|
1444
|
-
let location;
|
|
1445
|
-
try {
|
|
1446
|
-
const base = new URL(`${baseUrl}/`);
|
|
1447
|
-
const relative = value.startsWith("/v1/delegated-qurl-batches/")
|
|
1448
|
-
? `${base.pathname.replace(/\/$/, "")}${value}`
|
|
1449
|
-
: value;
|
|
1450
|
-
location = new URL(relative, base);
|
|
1451
|
-
}
|
|
1452
|
-
catch {
|
|
1453
|
-
throw delegatedResponseError(status, "response has invalid Location", requestId);
|
|
1454
|
-
}
|
|
1455
|
-
const expected = new URL(`${baseUrl}/v1/delegated-qurl-batches/${batchId}`);
|
|
1456
|
-
if (location.username !== "" ||
|
|
1457
|
-
location.password !== "" ||
|
|
1458
|
-
location.search !== "" ||
|
|
1459
|
-
location.hash !== "" ||
|
|
1460
|
-
location.href !== expected.href) {
|
|
1461
|
-
throw delegatedResponseError(status, "response has invalid Location", requestId);
|
|
1462
|
-
}
|
|
1463
|
-
return location.href;
|
|
1464
|
-
}
|
|
1465
|
-
function validateDelegatedQurlLink(value) {
|
|
1466
|
-
if (typeof value !== "string")
|
|
1467
|
-
return false;
|
|
1468
|
-
try {
|
|
1469
|
-
const link = new URL(value);
|
|
1470
|
-
return (link.protocol === "https:" &&
|
|
1471
|
-
link.username === "" &&
|
|
1472
|
-
link.password === "" &&
|
|
1473
|
-
link.hash.length > 1);
|
|
1474
|
-
}
|
|
1475
|
-
catch {
|
|
1476
|
-
return false;
|
|
1477
|
-
}
|
|
1478
|
-
}
|
|
1479
|
-
function validateDelegatedQurl(value, expectedQurlId, status, requestId) {
|
|
1480
|
-
const data = delegatedResponseObject(value, status, "response data", requestId);
|
|
1481
|
-
if (data.qurl_id !== expectedQurlId) {
|
|
1482
|
-
throw delegatedResponseError(status, "response has invalid qurl_id", requestId);
|
|
1483
|
-
}
|
|
1484
|
-
if (data.status !== "active" &&
|
|
1485
|
-
data.status !== "consumed" &&
|
|
1486
|
-
data.status !== "expired" &&
|
|
1487
|
-
data.status !== "revoked") {
|
|
1488
|
-
throw delegatedResponseError(status, "response has invalid status", requestId);
|
|
1489
|
-
}
|
|
1490
|
-
if (!isUtcTimestamp(data.expires_at) ||
|
|
1491
|
-
typeof data.one_time_use !== "boolean" ||
|
|
1492
|
-
!Number.isInteger(data.max_sessions) ||
|
|
1493
|
-
Number(data.max_sessions) < 0 ||
|
|
1494
|
-
!Number.isInteger(data.session_duration) ||
|
|
1495
|
-
Number(data.session_duration) <= 0 ||
|
|
1496
|
-
(data.label !== undefined && typeof data.label !== "string") ||
|
|
1497
|
-
(data.access_policy !== undefined &&
|
|
1498
|
-
(typeof data.access_policy !== "object" ||
|
|
1499
|
-
data.access_policy === null ||
|
|
1500
|
-
Array.isArray(data.access_policy)))) {
|
|
1501
|
-
throw delegatedResponseError(status, "response has invalid qURL metadata", requestId);
|
|
1502
|
-
}
|
|
1503
|
-
return { ...data, request_id: requestId };
|
|
1504
|
-
}
|
|
1505
|
-
function validateDelegatedBatchResults(value, itemCount, batchStatus, status, requestId) {
|
|
1506
|
-
if (!Array.isArray(value) || value.length !== itemCount) {
|
|
1507
|
-
throw delegatedResponseError(status, "terminal response has invalid results", requestId);
|
|
1508
|
-
}
|
|
1509
|
-
let succeeded = 0;
|
|
1510
|
-
const qurlIds = new Set();
|
|
1511
|
-
const links = new Set();
|
|
1512
|
-
const results = value.map((raw, index) => {
|
|
1513
|
-
const item = delegatedResponseObject(raw, status, `result ${index}`, requestId);
|
|
1514
|
-
if (item.index !== index) {
|
|
1515
|
-
throw delegatedResponseError(status, `result ${index} has invalid index`, requestId);
|
|
1516
|
-
}
|
|
1517
|
-
if (item.status === "succeeded") {
|
|
1518
|
-
if (item.error !== undefined && item.error !== null) {
|
|
1519
|
-
throw delegatedResponseError(status, `result ${index} has conflicting fields`, requestId);
|
|
1520
|
-
}
|
|
1521
|
-
const qurl = delegatedResponseObject(item.qurl, status, `result ${index} qurl`, requestId);
|
|
1522
|
-
if (typeof qurl.qurl_id !== "string" ||
|
|
1523
|
-
!DELEGATED_QURL_ID_PATTERN.test(qurl.qurl_id) ||
|
|
1524
|
-
!validateDelegatedQurlLink(qurl.qurl_link) ||
|
|
1525
|
-
!isUtcTimestamp(qurl.expires_at) ||
|
|
1526
|
-
qurlIds.has(qurl.qurl_id) ||
|
|
1527
|
-
links.has(qurl.qurl_link)) {
|
|
1528
|
-
throw delegatedResponseError(status, `result ${index} has invalid qurl`, requestId);
|
|
1529
|
-
}
|
|
1530
|
-
qurlIds.add(qurl.qurl_id);
|
|
1531
|
-
links.add(qurl.qurl_link);
|
|
1532
|
-
succeeded++;
|
|
1533
|
-
return {
|
|
1534
|
-
index,
|
|
1535
|
-
status: "succeeded",
|
|
1536
|
-
qurl: {
|
|
1537
|
-
qurl_id: qurl.qurl_id,
|
|
1538
|
-
qurl_link: qurl.qurl_link,
|
|
1539
|
-
expires_at: qurl.expires_at,
|
|
1540
|
-
},
|
|
1541
|
-
};
|
|
1542
|
-
}
|
|
1543
|
-
if (item.status !== "failed" || (item.qurl !== undefined && item.qurl !== null)) {
|
|
1544
|
-
throw delegatedResponseError(status, `result ${index} has invalid status`, requestId);
|
|
1545
|
-
}
|
|
1546
|
-
const error = delegatedResponseObject(item.error, status, `result ${index} error`, requestId);
|
|
1547
|
-
if (typeof error.code !== "string" ||
|
|
1548
|
-
error.code.length === 0 ||
|
|
1549
|
-
typeof error.message !== "string") {
|
|
1550
|
-
throw delegatedResponseError(status, `result ${index} has invalid error`, requestId);
|
|
1551
|
-
}
|
|
1552
|
-
return {
|
|
1553
|
-
index,
|
|
1554
|
-
status: "failed",
|
|
1555
|
-
error: { code: error.code, message: error.message },
|
|
1556
|
-
};
|
|
1557
|
-
});
|
|
1558
|
-
if ((batchStatus === "succeeded" && succeeded !== itemCount) ||
|
|
1559
|
-
(batchStatus === "failed" && succeeded !== 0) ||
|
|
1560
|
-
(batchStatus === "partially_failed" && (succeeded === 0 || succeeded === itemCount))) {
|
|
1561
|
-
throw delegatedResponseError(status, "terminal status does not match results", requestId);
|
|
1562
|
-
}
|
|
1563
|
-
return results;
|
|
1564
|
-
}
|
|
1565
|
-
function classifyDelegatedBatchCreateFailure(error, batchId) {
|
|
1566
|
-
if (!(error instanceof QURLError)) {
|
|
1567
|
-
throw new DelegatedBatchOutcomeUnknownError(new RuntimeError("Unexpected failure after delegated batch dispatch", { cause: error }), batchId);
|
|
1568
|
-
}
|
|
1569
|
-
if ((error.status >= 400 && error.status < 500 && error.status !== 408) || error.status === 501) {
|
|
1570
|
-
throw error;
|
|
1571
|
-
}
|
|
1572
|
-
throw new DelegatedBatchOutcomeUnknownError(error, batchId);
|
|
1573
|
-
}
|
|
1574
|
-
// `format: uri` in the OpenAPI spec allows schemes the SDK doesn't
|
|
1575
|
-
// usefully support (`ftp://`, `file://`, `javascript:`, …). This is a
|
|
1576
|
-
// cheap client-side sanity check — the server is still the
|
|
1577
|
-
// authoritative validator (e.g. it rejects localhost, cloud metadata,
|
|
1578
|
-
// and private-range hosts; the SDK doesn't need to duplicate that).
|
|
1579
|
-
const ALLOWED_URL_SCHEMES = ["http://", "https://"];
|
|
1580
|
-
function requireValidHttpUrl(value, field) {
|
|
1581
|
-
// Three checks in priority order: type guard, scheme, length.
|
|
1582
|
-
// Stop at the first failure so a non-string URL doesn't
|
|
1583
|
-
// produce both "must be a string" AND "must be ≤ 2048 characters"
|
|
1584
|
-
// (the length check on a non-string would also typeof-fail and
|
|
1585
|
-
// duplicate the type message). The collect-all loop in
|
|
1586
|
-
// validateCreateInput runs each FIELD's validator independently — within
|
|
1587
|
-
// a single URL field itself we want fail-fast.
|
|
1588
|
-
if (typeof value !== "string") {
|
|
1589
|
-
throw clientValidationError(`${field}: must be a string (got ${value === null ? "null" : typeof value})`);
|
|
1590
|
-
}
|
|
1591
|
-
if (!ALLOWED_URL_SCHEMES.some((scheme) => value.startsWith(scheme))) {
|
|
1592
|
-
// Truncate to keep the error message compact and avoid
|
|
1593
|
-
// pathologically long schemes from filling logs.
|
|
1594
|
-
const repr = JSON.stringify(value).slice(0, 40);
|
|
1595
|
-
throw clientValidationError(`${field}: must start with http:// or https:// (got ${repr})`);
|
|
1596
|
-
}
|
|
1597
|
-
if (value.length > MAX_TARGET_URL) {
|
|
1598
|
-
throw clientValidationError(`${field}: must be ${MAX_TARGET_URL} characters or fewer (got ${value.length})`);
|
|
1599
|
-
}
|
|
1600
|
-
}
|
|
1601
|
-
function requireValidTargetUrl(target_url) {
|
|
1602
|
-
requireValidHttpUrl(target_url, "target_url");
|
|
1603
|
-
}
|
|
1604
|
-
function validateCreateInput(input) {
|
|
1605
|
-
// Untyped-JS guard: surface as ValidationError instead of raw TypeError.
|
|
1606
|
-
if (typeof input !== "object" || input === null) {
|
|
1607
|
-
throw clientValidationError(`create: input must be an object (got ${input === null ? "null" : typeof input})`);
|
|
1608
|
-
}
|
|
1609
|
-
// Collect-all: run every validator and aggregate the messages so a
|
|
1610
|
-
// caller fixing multiple bad fields sees them all at once instead
|
|
1611
|
-
// of fix-re-run-repeat. Matches the documented batchCreate UX
|
|
1612
|
-
// ("All failures are collected into a single ValidationError")
|
|
1613
|
-
// and mirrors the requireValidTags collect-all pattern.
|
|
1614
|
-
const errors = [];
|
|
1615
|
-
const collect = (fn) => {
|
|
1616
|
-
try {
|
|
1617
|
-
fn();
|
|
1618
|
-
}
|
|
1619
|
-
catch (err) {
|
|
1620
|
-
if (err instanceof ValidationError) {
|
|
1621
|
-
errors.push(err.detail);
|
|
1622
|
-
return;
|
|
1623
|
-
}
|
|
1624
|
-
throw err;
|
|
1625
|
-
}
|
|
1626
|
-
};
|
|
1627
|
-
collect(() => requireNoUnknownFields(input, CREATE_FIELD_KEYS, "create"));
|
|
1628
|
-
const resourceType = input.type;
|
|
1629
|
-
if (resourceType !== undefined && typeof resourceType !== "string") {
|
|
1630
|
-
errors.push(`type: must be a string (got ${resourceType === null ? "null" : typeof resourceType})`);
|
|
1631
|
-
}
|
|
1632
|
-
collect(() => {
|
|
1633
|
-
if (input.target_url === undefined) {
|
|
1634
|
-
if (resourceType === undefined || resourceType === "url") {
|
|
1635
|
-
throw clientValidationError("target_url: is required for url qURLs");
|
|
1636
|
-
}
|
|
1637
|
-
return;
|
|
1638
|
-
}
|
|
1639
|
-
requireValidTargetUrl(input.target_url);
|
|
1640
|
-
});
|
|
1641
|
-
collect(() => requireMaxLength(input.label, "label", MAX_LABEL));
|
|
1642
|
-
collect(() => requireNonEmptyIfPresent(input.label, "label"));
|
|
1643
|
-
collect(() => requireMaxLength(input.custom_domain, "custom_domain", MAX_CUSTOM_DOMAIN));
|
|
1644
|
-
collect(() => requireNonEmptyIfPresent(input.custom_domain, "custom_domain"));
|
|
1645
|
-
collect(() => requireBooleanIfPresent(input.private, "private"));
|
|
1646
|
-
collect(() => requireBooleanIfPresent(input.one_time_use, "one_time_use"));
|
|
1647
|
-
collect(() => requireMaxSessionsInRange(input.max_sessions));
|
|
1648
|
-
collect(() => requireNonEmptyIfPresent(input.expires_in, "expires_in"));
|
|
1649
|
-
collect(() => requireNonEmptyIfPresent(input.session_duration, "session_duration"));
|
|
1650
|
-
collect(() => requireValidAccessPolicy(input.access_policy));
|
|
1651
|
-
if (errors.length > 0) {
|
|
1652
|
-
// Inner `; ` is contractual (paired with batchCreate's outer ` | `).
|
|
1653
|
-
// Future field-level validators must not aggregate via `; ` internally.
|
|
1654
|
-
throw clientValidationError(errors.join("; "));
|
|
1655
|
-
}
|
|
1656
|
-
// Intentional omission: duration *grammar* (`expires_in`,
|
|
1657
|
-
// `session_duration`) is server-authoritative. Empty strings are
|
|
1658
|
-
// rejected up front (see collect() calls above) but unit/whitespace
|
|
1659
|
-
// edge cases ("24hh", "1w 3d") surface as a clean server 400 — a
|
|
1660
|
-
// client-side regex would just create a drift surface against the
|
|
1661
|
-
// server's rego/duration parser.
|
|
1662
|
-
//
|
|
1663
|
-
// `access_policy` contents (CIDRs, ISO-3166 codes, regex patterns)
|
|
1664
|
-
// are pass-through for the same reason. The shape-guard above only
|
|
1665
|
-
// catches structural mistakes (string-instead-of-array on list fields)
|
|
1666
|
-
// an untyped-JS caller is likely to make.
|
|
1667
|
-
}
|
|
1668
|
-
function validateQurlTokenOptions(input) {
|
|
1669
|
-
if (input === undefined)
|
|
1670
|
-
return;
|
|
1671
|
-
requireMaxLength(input.label, "label", MAX_LABEL);
|
|
1672
|
-
requireNonEmptyIfPresent(input.label, "label");
|
|
1673
|
-
requireNonEmptyIfPresent(input.expires_in, "expires_in");
|
|
1674
|
-
requireNonEmptyIfPresent(input.session_duration, "session_duration");
|
|
1675
|
-
// Mirror the service-documented size cap, but keep host-safety grammar
|
|
1676
|
-
// (single leading slash, no scheme/host/backslash/etc.) and the tunnel-only
|
|
1677
|
-
// gate server-authoritative (`invalid_target_path`); a client-side regex
|
|
1678
|
-
// would create a drift surface. Keep max-before-empty consistent with label;
|
|
1679
|
-
// empty strings pass the length check and are rejected by the next guard.
|
|
1680
|
-
requireMaxUtf8Bytes(input.target_path, "target_path", MAX_TARGET_PATH);
|
|
1681
|
-
requireNonEmptyIfPresent(input.target_path, "target_path");
|
|
1682
|
-
requireBooleanIfPresent(input.one_time_use, "one_time_use");
|
|
1683
|
-
requireMaxSessionsInRange(input.max_sessions);
|
|
1684
|
-
requireValidAccessPolicy(input.access_policy);
|
|
1685
|
-
}
|
|
1686
|
-
function validateResourceQurlUpdateInput(input) {
|
|
1687
|
-
requireMaxLength(input.label, "label", MAX_LABEL);
|
|
1688
|
-
requireNonEmptyIfPresent(input.label, "label");
|
|
1689
|
-
requireNonEmptyIfPresent(input.extend_by, "extend_by");
|
|
1690
|
-
requireNonEmptyIfPresent(input.expires_at, "expires_at");
|
|
1691
|
-
requireNonEmptyIfPresent(input.session_duration, "session_duration");
|
|
1692
|
-
requireMaxSessionsInRange(input.max_sessions);
|
|
1693
|
-
requireValidAccessPolicy(input.access_policy);
|
|
1694
|
-
if (input.extend_by !== undefined && input.expires_at !== undefined) {
|
|
1695
|
-
throw clientValidationError("updateResourceQurl: `extend_by` and `expires_at` are mutually exclusive — provide at most one");
|
|
1696
|
-
}
|
|
1697
|
-
}
|
|
1698
|
-
function requireObjectInput(input, method) {
|
|
1699
|
-
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
1700
|
-
throw clientValidationError(`${method}: input must be an object (got ${input === null ? "null" : typeof input})`);
|
|
1701
|
-
}
|
|
1702
|
-
}
|
|
1703
|
-
function requireNonEmptyStringField(input, field, method) {
|
|
1704
|
-
const value = input[field];
|
|
1705
|
-
if (typeof value !== "string" || value.length === 0) {
|
|
1706
|
-
throw clientValidationError(`${method}: ${field} must be a non-empty string`);
|
|
1707
|
-
}
|
|
1708
|
-
}
|
|
1709
|
-
function requireNonEmptyArrayField(input, field, method) {
|
|
1710
|
-
const value = input[field];
|
|
1711
|
-
if (!Array.isArray(value) || value.length === 0) {
|
|
1712
|
-
throw clientValidationError(`${method}: ${field} must be a non-empty array`);
|
|
1713
|
-
}
|
|
1714
|
-
}
|
|
1715
|
-
function requireStringArrayElements(value, field, method) {
|
|
1716
|
-
if (!Array.isArray(value)) {
|
|
1717
|
-
throw clientValidationError(`${method}: ${field} must be a non-empty array`);
|
|
1718
|
-
}
|
|
1719
|
-
value.forEach((entry, index) => {
|
|
1720
|
-
if (typeof entry !== "string" || entry.length === 0) {
|
|
1721
|
-
throw clientValidationError(`${method}: ${field}[${index}] must be a non-empty string`);
|
|
1722
|
-
}
|
|
1723
|
-
});
|
|
1724
|
-
}
|
|
1725
|
-
function requireAtLeastOneField(input, fields, method) {
|
|
1726
|
-
if (!fields.some((field) => input[field] !== undefined)) {
|
|
1727
|
-
throw clientValidationError(`${method}: at least one field (${fields.join(", ")}) must be provided`);
|
|
1728
|
-
}
|
|
1729
|
-
}
|
|
1730
|
-
function requireNoUnknownFields(input, fields, method) {
|
|
1731
|
-
const unknown = Object.keys(input).filter((field) => !fields.includes(field));
|
|
1732
|
-
if (unknown.length > 0) {
|
|
1733
|
-
const rendered = unknown.map((field) => `"${field}"`).join(", ");
|
|
1734
|
-
const label = unknown.length === 1 ? "unknown field" : "unknown fields";
|
|
1735
|
-
throw clientValidationError(`${method}: ${label} ${rendered}`);
|
|
1736
|
-
}
|
|
1737
|
-
}
|
|
1738
|
-
function normalizePatchFields(input, fields, options = {}) {
|
|
1739
|
-
const preserveNull = new Set(options.preserveNullFields ?? []);
|
|
1740
|
-
const normalized = {};
|
|
1741
|
-
for (const key of fields) {
|
|
1742
|
-
const value = input[key];
|
|
1743
|
-
if (value !== undefined && (value !== null || preserveNull.has(key))) {
|
|
1744
|
-
normalized[key] = value;
|
|
1745
|
-
}
|
|
1746
|
-
}
|
|
1747
|
-
return normalized;
|
|
1748
|
-
}
|
|
1749
|
-
function requireConsumerDecision(id, version, allowNew = false) {
|
|
1750
|
-
if (typeof id !== "string" ||
|
|
1751
|
-
(!/^[a-f0-9]{64}$/.test(id) && !(allowNew && id === "new")) ||
|
|
1752
|
-
!Number.isSafeInteger(version) ||
|
|
1753
|
-
version < (allowNew ? 0 : 1))
|
|
1754
|
-
throw clientValidationError("consumer decision: invalid id or expected version");
|
|
1755
|
-
}
|
|
1756
|
-
function validateResourceWriteFields(input, options = {}) {
|
|
1757
|
-
const errors = [];
|
|
1758
|
-
const collect = (fn) => {
|
|
1759
|
-
try {
|
|
1760
|
-
fn();
|
|
1761
|
-
}
|
|
1762
|
-
catch (err) {
|
|
1763
|
-
if (err instanceof ValidationError) {
|
|
1764
|
-
errors.push(err.detail);
|
|
1765
|
-
return;
|
|
1766
|
-
}
|
|
1767
|
-
throw err;
|
|
1768
|
-
}
|
|
1769
|
-
};
|
|
1770
|
-
collect(() => {
|
|
1771
|
-
if (input.private !== undefined && typeof input.private !== "boolean") {
|
|
1772
|
-
throw clientValidationError("private: must be a boolean");
|
|
1773
|
-
}
|
|
1774
|
-
if (input.consumer_access !== undefined) {
|
|
1775
|
-
const policy = input.consumer_access;
|
|
1776
|
-
requireObjectInput(policy, "consumer_access");
|
|
1777
|
-
const p = policy;
|
|
1778
|
-
requireNoUnknownFields(p, ["mode", "version", "contract_id"], "consumer_access");
|
|
1779
|
-
if (!["keys", "invite_only", "email_capture", "review", "contract"].includes(p.mode) ||
|
|
1780
|
-
!Number.isSafeInteger(p.version) ||
|
|
1781
|
-
p.version < 1) {
|
|
1782
|
-
throw clientValidationError("consumer_access: invalid mode or version");
|
|
1783
|
-
}
|
|
1784
|
-
if (p.mode === "contract") {
|
|
1785
|
-
if (typeof p.contract_id !== "string" ||
|
|
1786
|
-
!p.contract_id.trim() ||
|
|
1787
|
-
new TextEncoder().encode(p.contract_id).length > 128) {
|
|
1788
|
-
throw clientValidationError("consumer_access: contract mode requires a bounded contract_id");
|
|
1789
|
-
}
|
|
1790
|
-
}
|
|
1791
|
-
else if (p.contract_id !== undefined) {
|
|
1792
|
-
throw clientValidationError("consumer_access: contract_id requires contract mode");
|
|
1793
|
-
}
|
|
1794
|
-
if (input.private === false)
|
|
1795
|
-
throw clientValidationError("consumer_access: requires a private resource");
|
|
1796
|
-
if (options.requireUrlTarget) {
|
|
1797
|
-
if (p.version !== 1 || input.private !== true)
|
|
1798
|
-
throw clientValidationError("consumer_access: creation requires private true and version one");
|
|
1799
|
-
}
|
|
1800
|
-
else if (options.allowAliasClear) {
|
|
1801
|
-
const expected = input.expected_consumer_access_version;
|
|
1802
|
-
if (!Number.isSafeInteger(expected) ||
|
|
1803
|
-
expected < 0 ||
|
|
1804
|
-
p.version !== expected + 1 ||
|
|
1805
|
-
Object.keys(input).some((key) => key !== "consumer_access" && key !== "expected_consumer_access_version")) {
|
|
1806
|
-
throw clientValidationError("consumer_access: update requires its expected version and no metadata changes");
|
|
1807
|
-
}
|
|
1808
|
-
}
|
|
1809
|
-
}
|
|
1810
|
-
else if (input.expected_consumer_access_version !== undefined) {
|
|
1811
|
-
throw clientValidationError("expected_consumer_access_version: requires consumer_access");
|
|
1812
|
-
}
|
|
1813
|
-
});
|
|
1814
|
-
collect(() => {
|
|
1815
|
-
const fields = ["allowed_device_keys_add", "allowed_device_keys_remove"];
|
|
1816
|
-
for (const field of fields) {
|
|
1817
|
-
const keys = input[field];
|
|
1818
|
-
if (keys === undefined)
|
|
1819
|
-
continue;
|
|
1820
|
-
requireStringArrayElements(keys, field, "updateResource");
|
|
1821
|
-
if (keys.length > 256 || new Set(keys).size !== keys.length) {
|
|
1822
|
-
throw clientValidationError(`${field}: must contain at most 256 unique keys`);
|
|
1823
|
-
}
|
|
1824
|
-
for (const key of keys) {
|
|
1825
|
-
let canonical = false;
|
|
1826
|
-
try {
|
|
1827
|
-
canonical = key.length === 44 && atob(key).length === 32 && btoa(atob(key)) === key;
|
|
1828
|
-
}
|
|
1829
|
-
catch {
|
|
1830
|
-
/* Invalid base64 is rejected below. */
|
|
1831
|
-
}
|
|
1832
|
-
if (!canonical) {
|
|
1833
|
-
throw clientValidationError(`${field}: keys must be canonical padded base64 of 32 bytes`);
|
|
1834
|
-
}
|
|
1835
|
-
}
|
|
1836
|
-
if (input.custom_domain !== undefined) {
|
|
1837
|
-
throw clientValidationError(`${field}: update separately from custom_domain`);
|
|
1838
|
-
}
|
|
1839
|
-
}
|
|
1840
|
-
const added = new Set(input.allowed_device_keys_add);
|
|
1841
|
-
if (input.allowed_device_keys_remove?.some((key) => added.has(key))) {
|
|
1842
|
-
throw clientValidationError("allowed device key additions and removals must not overlap");
|
|
1843
|
-
}
|
|
1844
|
-
});
|
|
1845
|
-
const resourceType = input.type;
|
|
1846
|
-
if (resourceType !== undefined && typeof resourceType !== "string") {
|
|
1847
|
-
errors.push(`type: must be a string (got ${resourceType === null ? "null" : typeof resourceType})`);
|
|
1848
|
-
}
|
|
1849
|
-
collect(() => {
|
|
1850
|
-
if (input.target_url === undefined) {
|
|
1851
|
-
if (options.requireUrlTarget && (resourceType === undefined || resourceType === "url")) {
|
|
1852
|
-
throw clientValidationError("target_url: is required for url resources");
|
|
1853
|
-
}
|
|
1854
|
-
return;
|
|
1855
|
-
}
|
|
1856
|
-
requireValidTargetUrl(input.target_url);
|
|
1857
|
-
});
|
|
1858
|
-
collect(() => requireMaxLength(input.description, "description", MAX_DESCRIPTION));
|
|
1859
|
-
collect(() => requireMaxLength(input.custom_domain, "custom_domain", MAX_CUSTOM_DOMAIN));
|
|
1860
|
-
collect(() => {
|
|
1861
|
-
if (options.allowCustomDomainClear && input.custom_domain === "")
|
|
1862
|
-
return;
|
|
1863
|
-
requireNonEmptyIfPresent(input.custom_domain, "custom_domain");
|
|
1864
|
-
});
|
|
1865
|
-
collect(() => requireValidTags(input.tags));
|
|
1866
|
-
collect(() => {
|
|
1867
|
-
if (input.alias !== undefined) {
|
|
1868
|
-
if (input.alias === null) {
|
|
1869
|
-
if (!options.allowAliasClear) {
|
|
1870
|
-
throw clientValidationError("alias: must be a string (got null)");
|
|
1871
|
-
}
|
|
1872
|
-
}
|
|
1873
|
-
else {
|
|
1874
|
-
requireMaxLength(input.alias, "alias", MAX_ALIAS);
|
|
1875
|
-
requireNonEmptyIfPresent(input.alias, "alias");
|
|
1876
|
-
}
|
|
1877
|
-
}
|
|
1878
|
-
});
|
|
1879
|
-
collect(() => {
|
|
1880
|
-
if (input.slug === undefined)
|
|
1881
|
-
return;
|
|
1882
|
-
requireMaxLength(input.slug, "slug", MAX_SLUG);
|
|
1883
|
-
requireNonEmptyIfPresent(input.slug, "slug");
|
|
1884
|
-
});
|
|
1885
|
-
collect(() => {
|
|
1886
|
-
if (options.validateFindOrCreate &&
|
|
1887
|
-
input.find_or_create !== undefined &&
|
|
1888
|
-
typeof input.find_or_create !== "boolean") {
|
|
1889
|
-
throw clientValidationError(`find_or_create: must be a boolean (got ${describeShape(input.find_or_create)})`);
|
|
1890
|
-
}
|
|
1891
|
-
});
|
|
1892
|
-
collect(() => {
|
|
1893
|
-
if (options.validatePreserveHost &&
|
|
1894
|
-
input.preserve_host !== undefined &&
|
|
1895
|
-
typeof input.preserve_host !== "boolean") {
|
|
1896
|
-
throw clientValidationError(`preserve_host: must be a boolean (got ${describeShape(input.preserve_host)})`);
|
|
1897
|
-
}
|
|
1898
|
-
});
|
|
1899
|
-
if (errors.length > 0) {
|
|
1900
|
-
throw clientValidationError(errors.join("; "));
|
|
1901
|
-
}
|
|
1902
|
-
}
|
|
1903
|
-
function validateWebhookWriteFields(input, method, requiredFields = {}) {
|
|
1904
|
-
if (requiredFields.url || input.url !== undefined) {
|
|
1905
|
-
requireNonEmptyStringField(input, "url", method);
|
|
1906
|
-
requireValidHttpUrl(input.url, "url");
|
|
1907
|
-
}
|
|
1908
|
-
if (requiredFields.events || input.events !== undefined) {
|
|
1909
|
-
requireNonEmptyArrayField(input, "events", method);
|
|
1910
|
-
requireStringArrayElements(input.events, "events", method);
|
|
1911
|
-
}
|
|
1912
|
-
requireMaxLength(input.description, "description", MAX_DESCRIPTION);
|
|
1913
|
-
if (input.status !== undefined) {
|
|
1914
|
-
requireNonEmptyStringField(input, "status", method);
|
|
1915
|
-
}
|
|
1916
|
-
}
|
|
1917
|
-
function validateApiKeyWriteFields(input, method, requiredFields = {}) {
|
|
1918
|
-
if (requiredFields.name || input.name !== undefined) {
|
|
1919
|
-
requireNonEmptyStringField(input, "name", method);
|
|
1920
|
-
requireMaxLength(input.name, "name", MAX_API_KEY_NAME);
|
|
1921
|
-
}
|
|
1922
|
-
if (requiredFields.scopes || input.scopes !== undefined) {
|
|
1923
|
-
requireNonEmptyArrayField(input, "scopes", method);
|
|
1924
|
-
requireStringArrayElements(input.scopes, "scopes", method);
|
|
1925
|
-
const unsupported = input.scopes.find((scope) => !API_KEY_REQUEST_SCOPES.has(scope));
|
|
1926
|
-
if (unsupported !== undefined) {
|
|
1927
|
-
throw clientValidationError(`${method}: scopes contains an unsupported permission ${JSON.stringify(unsupported)}; this SDK accepts ${API_KEY_REQUEST_SCOPE_VALUES.join(", ")}`);
|
|
1928
|
-
}
|
|
1929
|
-
}
|
|
1930
|
-
}
|
|
1931
|
-
function validateEnrollmentTokenFields(input, method) {
|
|
1932
|
-
if (input.target !== undefined &&
|
|
1933
|
-
(typeof input.target !== "string" || !CREDENTIAL_TARGETS.has(input.target))) {
|
|
1934
|
-
throw clientValidationError(`${method}: target must be 'agent' or 'connector'`);
|
|
1935
|
-
}
|
|
1936
|
-
let claimCount = 0;
|
|
1937
|
-
if (input.claims !== undefined) {
|
|
1938
|
-
if (!Array.isArray(input.claims)) {
|
|
1939
|
-
throw clientValidationError(`${method}: claims must be an array`);
|
|
1940
|
-
}
|
|
1941
|
-
if (input.claims.length > 1) {
|
|
1942
|
-
throw clientValidationError(`${method}: claims must contain at most one claim`);
|
|
1943
|
-
}
|
|
1944
|
-
claimCount = input.claims.length;
|
|
1945
|
-
for (const [index, claim] of input.claims.entries()) {
|
|
1946
|
-
if (!claim || typeof claim !== "object" || Array.isArray(claim)) {
|
|
1947
|
-
throw clientValidationError(`${method}: claims[${index}] must be an object`);
|
|
1948
|
-
}
|
|
1949
|
-
requireNoUnknownFields(claim, ["type", "id"], `${method}: claims[${index}]`);
|
|
1950
|
-
const record = claim;
|
|
1951
|
-
if (typeof record.type !== "string" || !CREDENTIAL_CLAIM_TYPES.has(record.type)) {
|
|
1952
|
-
throw clientValidationError(`${method}: claims[${index}].type must be 'connector'`);
|
|
1953
|
-
}
|
|
1954
|
-
if (typeof record.id !== "string" || !CONNECTOR_SLUG_PATTERN.test(record.id)) {
|
|
1955
|
-
throw clientValidationError(`${method}: claims[${index}].id is not a valid connector slug; use 3-64 lowercase letters, digits, or hyphens, starting with a letter and ending with a letter or digit`);
|
|
1956
|
-
}
|
|
1957
|
-
}
|
|
1958
|
-
}
|
|
1959
|
-
// CreateApiKeyRequest.target permits an agent with zero or one connector claim.
|
|
1960
|
-
if (input.target === "connector" && claimCount !== 1) {
|
|
1961
|
-
throw clientValidationError(`${method}: target 'connector' requires exactly one claim`);
|
|
1962
|
-
}
|
|
1963
|
-
if (input.expires_in !== undefined) {
|
|
1964
|
-
if (typeof input.expires_in !== "string") {
|
|
1965
|
-
throw clientValidationError(`${method}: expires_in must be a duration string`);
|
|
1966
|
-
}
|
|
1967
|
-
const match = CREDENTIAL_DURATION_PATTERN.exec(input.expires_in);
|
|
1968
|
-
if (!match) {
|
|
1969
|
-
throw clientValidationError(`${method}: expires_in must match <integer><s|m|h|d|w>`);
|
|
1970
|
-
}
|
|
1971
|
-
const seconds = BigInt(match[1]) * CREDENTIAL_DURATION_MULTIPLIERS[match[2]];
|
|
1972
|
-
if (seconds <= 0n || seconds > MAX_ENROLLMENT_TOKEN_TTL_SECONDS) {
|
|
1973
|
-
throw clientValidationError(`${method}: expires_in must be greater than zero and at most 24h`);
|
|
1974
|
-
}
|
|
1975
|
-
}
|
|
1976
|
-
}
|
|
1977
|
-
/**
|
|
1978
|
-
* Per-entry shape guard for {@link BatchItemResult}. Verifies every
|
|
1979
|
-
* non-optional field on the branch of the discriminated union selected
|
|
1980
|
-
* by the `success` discriminant. Protects consumers who narrow on
|
|
1981
|
-
* `success` and then access `resource_id` / `qurl_link` / `error` as
|
|
1982
|
-
* guaranteed-present — if the API ever omits one, we trip the guard
|
|
1983
|
-
* rather than returning `undefined` where the type says `string`.
|
|
1984
|
-
*
|
|
1985
|
-
* Returns `null` when the entry is valid, or a human-readable reason
|
|
1986
|
-
* string describing the *specific* field that's missing or wrong. The
|
|
1987
|
-
* reason never echoes the entry's values — only field names and shape
|
|
1988
|
-
* descriptors — so the caller can safely surface it in error messages
|
|
1989
|
-
* that may end up in observability pipelines. Contrast with the outer
|
|
1990
|
-
* guard's intentional silence about response-body content for the
|
|
1991
|
-
* same info-leak reason.
|
|
1992
|
-
*/
|
|
1993
|
-
function batchItemResultValidationReason(entry) {
|
|
1994
|
-
if (!entry || typeof entry !== "object")
|
|
1995
|
-
return "not an object";
|
|
1996
|
-
const e = entry;
|
|
1997
|
-
// `Number.isInteger` rejects NaN, Infinity, and floats; combined
|
|
1998
|
-
// with `>= 0`, the check rules out pathological proxy bodies that
|
|
1999
|
-
// would attribute to a useless index. `index` is informational, so
|
|
2000
|
-
// this isn't a correctness hazard, but tightening the constraint
|
|
2001
|
-
// matches the same posture as the count integer guards above.
|
|
2002
|
-
if (!Number.isInteger(e.index) || e.index < 0) {
|
|
2003
|
-
return "missing/invalid 'index' (expected non-negative integer)";
|
|
2004
|
-
}
|
|
2005
|
-
if (e.success === true) {
|
|
2006
|
-
if (typeof e.resource_id !== "string") {
|
|
2007
|
-
return "success-branch missing required field 'resource_id' (expected string)";
|
|
2008
|
-
}
|
|
2009
|
-
if (typeof e.qurl_link !== "string") {
|
|
2010
|
-
return "success-branch missing required field 'qurl_link' (expected string)";
|
|
2011
|
-
}
|
|
2012
|
-
if (typeof e.qurl_site !== "string") {
|
|
2013
|
-
return "success-branch missing required field 'qurl_site' (expected string)";
|
|
2014
|
-
}
|
|
2015
|
-
return null;
|
|
2016
|
-
}
|
|
2017
|
-
if (e.success === false) {
|
|
2018
|
-
if (!e.error || typeof e.error !== "object") {
|
|
2019
|
-
return "failure-branch missing required field 'error' (expected object)";
|
|
2020
|
-
}
|
|
2021
|
-
const err = e.error;
|
|
2022
|
-
if (typeof err.code !== "string") {
|
|
2023
|
-
return "failure-branch missing required field 'error.code' (expected string)";
|
|
2024
|
-
}
|
|
2025
|
-
if (typeof err.message !== "string") {
|
|
2026
|
-
return "failure-branch missing required field 'error.message' (expected string)";
|
|
2027
|
-
}
|
|
2028
|
-
return null;
|
|
2029
|
-
}
|
|
2030
|
-
return "missing/invalid 'success' discriminant (expected boolean)";
|
|
2031
|
-
}
|
|
2032
|
-
// ---- Portal-verb helpers (mirrors qurl-go) -------------------------------
|
|
2033
|
-
// Client-side guardrails for the portal flow (protectUrl → createPortal →
|
|
2034
|
-
// enterPortal), kept in lockstep with qurl-go's option validation and
|
|
2035
|
-
// qurl-python's port of it.
|
|
2036
|
-
/**
|
|
2037
|
-
* Client-side floor for portal lifetimes, mirroring qurl-go's `ValidFor`.
|
|
2038
|
-
* The LayerV API remains the source of truth for account limits; there is
|
|
2039
|
-
* intentionally no SDK-side maximum because an account may allow
|
|
2040
|
-
* longer-lived portals.
|
|
2041
|
-
*/
|
|
2042
|
-
const MIN_PORTAL_VALID_FOR_SECONDS = 60;
|
|
2043
|
-
/**
|
|
2044
|
-
* Client-side floor for session durations, mirroring qurl-go's
|
|
2045
|
-
* `WithSessionDuration` (at least one second; zero is rejected rather than
|
|
2046
|
-
* treated as the server default).
|
|
2047
|
-
*/
|
|
2048
|
-
const MIN_SESSION_DURATION_SECONDS = 1;
|
|
2049
|
-
/**
|
|
2050
|
-
* Shape of qurl-go's offline signed-fragment links: three or more
|
|
2051
|
-
* dot-separated base64url parts (<version>.<claims>.<secret>.<sig>).
|
|
2052
|
-
* Detected only to give those links a precise error in extractAccessToken.
|
|
2053
|
-
*
|
|
2054
|
-
* @internal Exported for in-package tests; not re-exported from index.ts.
|
|
2055
|
-
*/
|
|
2056
|
-
export const SIGNED_FRAGMENT_RE = /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+){2,}$/;
|
|
2057
|
-
// Access-token grammar accepted by enterPortal (base64url charset).
|
|
2058
|
-
const ACCESS_TOKEN_RE = /^[A-Za-z0-9_-]+$/;
|
|
2059
|
-
/**
|
|
2060
|
-
* Serialize a duration option to the API's h/m/s duration grammar.
|
|
2061
|
-
*
|
|
2062
|
-
* Strings pass through unvalidated — the server is authoritative, same as
|
|
2063
|
-
* the REST-shaped `expires_in` fields. Numbers are milliseconds and get
|
|
2064
|
-
* qurl-go's client-side guardrails: whole seconds only and at least
|
|
2065
|
-
* `minimumSeconds` (checked in that order so e.g. 30.5s reports "at least
|
|
2066
|
-
* 60s" in every SDK). Serialized with hours as the largest unit so all API
|
|
2067
|
-
* duration fields use the same grammar across SDKs.
|
|
2068
|
-
*/
|
|
2069
|
-
function serializeApiDuration(value, field, minimumSeconds) {
|
|
2070
|
-
if (value === undefined)
|
|
2071
|
-
return undefined;
|
|
2072
|
-
if (typeof value === "string") {
|
|
2073
|
-
if (value.trim() === "") {
|
|
2074
|
-
throw clientValidationError(`${field}: must be a non-empty duration string`);
|
|
2075
|
-
}
|
|
2076
|
-
return value;
|
|
2077
|
-
}
|
|
2078
|
-
if (typeof value === "number") {
|
|
2079
|
-
// NaN fails no comparison, so an explicit finite check keeps the
|
|
2080
|
-
// error actionable instead of a misleading "whole seconds" report.
|
|
2081
|
-
if (!Number.isFinite(value)) {
|
|
2082
|
-
throw clientValidationError(`${field}: must be a finite number of milliseconds`);
|
|
2083
|
-
}
|
|
2084
|
-
if (value < minimumSeconds * 1000) {
|
|
2085
|
-
throw clientValidationError(`${field}: must be at least ${minimumSeconds}s`);
|
|
2086
|
-
}
|
|
2087
|
-
if (value % 1000 !== 0) {
|
|
2088
|
-
throw clientValidationError(`${field}: must be whole seconds`);
|
|
2089
|
-
}
|
|
2090
|
-
const seconds = value / 1000;
|
|
2091
|
-
if (seconds % 3600 === 0)
|
|
2092
|
-
return `${seconds / 3600}h`;
|
|
2093
|
-
if (seconds % 60 === 0)
|
|
2094
|
-
return `${seconds / 60}m`;
|
|
2095
|
-
return `${seconds}s`;
|
|
2096
|
-
}
|
|
2097
|
-
throw clientValidationError(`${field}: must be a duration string (e.g. "5m") or a number of milliseconds`);
|
|
2098
|
-
}
|
|
2099
|
-
/**
|
|
2100
|
-
* Portal-verb target URL validation (qurl-go `ProtectURL` parity). On top of
|
|
2101
|
-
* the shared scheme/length check, the URL must carry a host and must parse,
|
|
2102
|
-
* and embedded credentials (userinfo) are rejected. The added checks never
|
|
2103
|
-
* echo the URL — a userinfo URL contains credentials that must stay out of
|
|
2104
|
-
* logs and error messages. The REST-shaped compatibility layer keeps its
|
|
2105
|
-
* original prefix-only check so its accepted inputs don't change.
|
|
2106
|
-
*/
|
|
2107
|
-
function requirePortalTargetUrl(targetUrl) {
|
|
2108
|
-
requireValidTargetUrl(targetUrl);
|
|
2109
|
-
const url = targetUrl; // requireValidTargetUrl only returns for http(s) strings
|
|
2110
|
-
// WHATWG URL normalizes away an empty authority ("https:///x" parses
|
|
2111
|
-
// with host "x"), so the host-presence check runs on the raw string,
|
|
2112
|
-
// mirroring qurl-go's url.Parse which rejects authority-less URLs.
|
|
2113
|
-
const afterScheme = url.slice(url.indexOf("://") + 3);
|
|
2114
|
-
if (afterScheme === "" || "/?#".includes(afterScheme[0])) {
|
|
2115
|
-
throw clientValidationError("target_url: must include a host");
|
|
2116
|
-
}
|
|
2117
|
-
let parsed;
|
|
2118
|
-
try {
|
|
2119
|
-
parsed = new URL(url);
|
|
2120
|
-
}
|
|
2121
|
-
catch {
|
|
2122
|
-
throw clientValidationError("target_url: malformed URL");
|
|
2123
|
-
}
|
|
2124
|
-
if (parsed.username !== "" || parsed.password !== "") {
|
|
2125
|
-
throw clientValidationError("target_url: must not include embedded credentials (userinfo)");
|
|
2126
|
-
}
|
|
2127
|
-
}
|
|
2128
|
-
/**
|
|
2129
|
-
* Extract the access token from a qURL link, or pass a bare token through.
|
|
2130
|
-
*
|
|
2131
|
-
* Platform-issued links carry the access token in the URL fragment
|
|
2132
|
-
* (`https://qurl.link/#at_...`). Error messages deliberately never echo the
|
|
2133
|
-
* input — qURL links are credentials and must stay out of logs.
|
|
2134
|
-
*/
|
|
2135
|
-
function extractAccessToken(qurlLink) {
|
|
2136
|
-
if (typeof qurlLink !== "string" || qurlLink.trim() === "") {
|
|
2137
|
-
throw clientValidationError("qurlLink: must be a non-empty string");
|
|
2138
|
-
}
|
|
2139
|
-
const hashIndex = qurlLink.indexOf("#");
|
|
2140
|
-
const token = hashIndex >= 0 ? qurlLink.slice(hashIndex + 1) : qurlLink;
|
|
2141
|
-
if (SIGNED_FRAGMENT_RE.test(token)) {
|
|
2142
|
-
throw clientValidationError("qurlLink: this is a signed qURL link (offline fragment format), which cannot be " +
|
|
2143
|
-
"opened through the API resolver — enterPortal only accepts platform links " +
|
|
2144
|
-
"(https://qurl.link/#at_...) or bare access tokens");
|
|
2145
|
-
}
|
|
2146
|
-
if (token === "" || !ACCESS_TOKEN_RE.test(token)) {
|
|
2147
|
-
throw clientValidationError("qurlLink: no access token found — pass a platform qURL link " +
|
|
2148
|
-
"(https://qurl.link/#at_...) or a bare access token");
|
|
2149
|
-
}
|
|
2150
|
-
return token;
|
|
2151
|
-
}
|
|
2152
|
-
/**
|
|
2153
|
-
* Parse an RFC 3339 timestamp from the API into a `Date`, or `undefined`
|
|
2154
|
-
* when absent or unparseable. The portal surface exposes `Date` (matching
|
|
2155
|
-
* qurl-go's `time.Time` and qurl-python's `datetime`); the REST-shaped
|
|
2156
|
-
* layer keeps raw wire strings.
|
|
2157
|
-
*/
|
|
2158
|
-
function parseApiDate(value) {
|
|
2159
|
-
if (typeof value !== "string" || value === "")
|
|
2160
|
-
return undefined;
|
|
2161
|
-
const parsed = new Date(value);
|
|
2162
|
-
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
|
|
2163
|
-
}
|
|
2164
|
-
/**
|
|
2165
|
-
* Parse a {@link Portal} from a create-portal API response. `resource_id`
|
|
2166
|
-
* and `qurl_link` are required, mirroring qurl-go's fail-closed response
|
|
2167
|
-
* check; everything else is optional because the two mint endpoints differ
|
|
2168
|
-
* in which ancillary fields they return.
|
|
2169
|
-
*/
|
|
2170
|
-
async function parsePortal(data, method, expectedCrid) {
|
|
2171
|
-
if (typeof data?.resource_id !== "string" || data.resource_id.trim() === "") {
|
|
2172
|
-
throw unexpectedResponseError(`${method}: response is missing resource_id`);
|
|
2173
|
-
}
|
|
2174
|
-
if (typeof data.qurl_link !== "string" || data.qurl_link.trim() === "") {
|
|
2175
|
-
throw unexpectedResponseError(`${method}: response is missing qurl_link`);
|
|
2176
|
-
}
|
|
2177
|
-
await verifyResourceIdentity(data, method, expectedCrid);
|
|
2178
|
-
return {
|
|
2179
|
-
crid: data.crid,
|
|
2180
|
-
resourcePublicKey: data.resource_id,
|
|
2181
|
-
link: data.qurl_link,
|
|
2182
|
-
site: data.qurl_site,
|
|
2183
|
-
expiresAt: parseApiDate(data.expires_at),
|
|
2184
|
-
// Same empty-string → absent normalization the REST-shaped layer's
|
|
2185
|
-
// consumers apply to qurl_id.
|
|
2186
|
-
qurlId: data.qurl_id || undefined,
|
|
2187
|
-
label: data.label,
|
|
2188
|
-
};
|
|
2189
|
-
}
|
|
2190
|
-
/**
|
|
2191
|
-
* Validate portal options and build the create-portal wire body. The service
|
|
2192
|
-
* requires a JSON object even when every option is omitted; `{}` selects the
|
|
2193
|
-
* owner's plan defaults and matches qurl-go's zero-value request struct.
|
|
2194
|
-
*/
|
|
2195
|
-
function buildCreatePortalBody(opts, method) {
|
|
2196
|
-
requireObjectInput(opts, method);
|
|
2197
|
-
requireNoUnknownFields(opts, CREATE_PORTAL_OPTION_KEYS, method);
|
|
2198
|
-
// null → omitted, via the SDK-wide normalizePatchFields convention.
|
|
2199
|
-
const { validFor, label, oneTimeUse, maxSessions, sessionDuration, targetPath } = normalizePatchFields(opts, CREATE_PORTAL_OPTION_KEYS);
|
|
2200
|
-
if (label !== undefined) {
|
|
2201
|
-
requireMaxLength(label, "label", MAX_LABEL);
|
|
2202
|
-
// qurl-go's WithLabel rejects blank labels; the REST-shaped mintLink
|
|
2203
|
-
// keeps its permissive whitespace behavior, so the check lives here.
|
|
2204
|
-
if (label.trim() === "") {
|
|
2205
|
-
throw clientValidationError("label: must not be empty");
|
|
2206
|
-
}
|
|
2207
|
-
}
|
|
2208
|
-
requireBooleanIfPresent(oneTimeUse, "oneTimeUse");
|
|
2209
|
-
requireMaxSessionsInRange(maxSessions, "maxSessions");
|
|
2210
|
-
requireMaxUtf8Bytes(targetPath, "targetPath", MAX_TARGET_PATH);
|
|
2211
|
-
requireNonEmptyIfPresent(targetPath, "targetPath");
|
|
2212
|
-
if (targetPath !== undefined && method === "createPortalForUrl") {
|
|
2213
|
-
throw clientValidationError("targetPath: requires an existing resource");
|
|
2214
|
-
}
|
|
2215
|
-
const body = {};
|
|
2216
|
-
const expiresIn = serializeApiDuration(validFor, "validFor", MIN_PORTAL_VALID_FOR_SECONDS);
|
|
2217
|
-
if (expiresIn !== undefined)
|
|
2218
|
-
body.expires_in = expiresIn;
|
|
2219
|
-
if (label !== undefined)
|
|
2220
|
-
body.label = label;
|
|
2221
|
-
if (oneTimeUse !== undefined)
|
|
2222
|
-
body.one_time_use = oneTimeUse;
|
|
2223
|
-
// Explicit 0 means unlimited and must survive body construction.
|
|
2224
|
-
if (maxSessions !== undefined)
|
|
2225
|
-
body.max_sessions = maxSessions;
|
|
2226
|
-
const wireSessionDuration = serializeApiDuration(sessionDuration, "sessionDuration", MIN_SESSION_DURATION_SECONDS);
|
|
2227
|
-
if (wireSessionDuration !== undefined)
|
|
2228
|
-
body.session_duration = wireSessionDuration;
|
|
2229
|
-
if (targetPath !== undefined)
|
|
2230
|
-
body.target_path = targetPath;
|
|
2231
|
-
return body;
|
|
337
|
+
}, meta);
|
|
2232
338
|
}
|
|
2233
339
|
/** qURL API client. */
|
|
2234
340
|
export class QURLClient {
|
|
@@ -2236,6 +342,7 @@ export class QURLClient {
|
|
|
2236
342
|
apiKey;
|
|
2237
343
|
fetchFn;
|
|
2238
344
|
maxRetries;
|
|
345
|
+
maxResponseBodyBytes;
|
|
2239
346
|
timeout;
|
|
2240
347
|
userAgent;
|
|
2241
348
|
debugFn;
|
|
@@ -2249,6 +356,15 @@ export class QURLClient {
|
|
|
2249
356
|
if (/[\r\n]/.test(options.apiKey)) {
|
|
2250
357
|
throw clientValidationError("apiKey: must not contain CR/LF characters");
|
|
2251
358
|
}
|
|
359
|
+
const responseLimit = options.maxResponseBodyBytes === undefined
|
|
360
|
+
? MAX_RESPONSE_BODY_BYTES
|
|
361
|
+
: options.maxResponseBodyBytes;
|
|
362
|
+
if (!Number.isSafeInteger(responseLimit) ||
|
|
363
|
+
responseLimit < 1 ||
|
|
364
|
+
responseLimit > MAX_CONFIGURABLE_RESPONSE_BODY_BYTES) {
|
|
365
|
+
throw clientValidationError("maxResponseBodyBytes: must be an integer from 1 through 16777216 bytes");
|
|
366
|
+
}
|
|
367
|
+
this.maxResponseBodyBytes = responseLimit;
|
|
2252
368
|
this.apiKey = options.apiKey;
|
|
2253
369
|
const rawBaseUrl = stripTrailingSlashes(options.baseUrl ?? DEFAULT_BASE_URL);
|
|
2254
370
|
// Parse the URL so the loopback exemption matches on the hostname,
|
|
@@ -2345,21 +461,21 @@ export class QURLClient {
|
|
|
2345
461
|
branch: "non-array",
|
|
2346
462
|
qurls_type: typeof qurls,
|
|
2347
463
|
});
|
|
2348
|
-
return rest;
|
|
464
|
+
return copyResponseMetadata(raw, rest);
|
|
2349
465
|
}
|
|
2350
466
|
// null is semantically "no tokens" — silent drop is fine, but surface
|
|
2351
467
|
// via debug so operators spot it if the API starts emitting null.
|
|
2352
468
|
if (qurls === null) {
|
|
2353
469
|
this.log("mapQurlsField: 'qurls' was null; dropping", { branch: "null" });
|
|
2354
|
-
return rest;
|
|
470
|
+
return copyResponseMetadata(raw, rest);
|
|
2355
471
|
}
|
|
2356
472
|
if (qurls === undefined) {
|
|
2357
473
|
// Symmetric with null / non-array branches.
|
|
2358
474
|
this.log("mapQurlsField: 'qurls' was undefined; dropping", { branch: "undefined" });
|
|
2359
|
-
return rest;
|
|
475
|
+
return copyResponseMetadata(raw, rest);
|
|
2360
476
|
}
|
|
2361
477
|
if (!rest.access_tokens) {
|
|
2362
|
-
return { ...rest, access_tokens: qurls };
|
|
478
|
+
return copyResponseMetadata(raw, { ...rest, access_tokens: qurls });
|
|
2363
479
|
}
|
|
2364
480
|
// Server-side bug — drop `qurls` rather than merge. If the arrays
|
|
2365
481
|
// disagree, no consistent reconciliation exists; a silent merge
|
|
@@ -2369,7 +485,7 @@ export class QURLClient {
|
|
|
2369
485
|
qurls_count: qurls.length,
|
|
2370
486
|
access_tokens_count: rest.access_tokens.length,
|
|
2371
487
|
});
|
|
2372
|
-
return rest;
|
|
488
|
+
return copyResponseMetadata(raw, rest);
|
|
2373
489
|
}
|
|
2374
490
|
/**
|
|
2375
491
|
* Validate the response envelope from POST /v1/qurls/batch and return
|
|
@@ -2393,9 +509,9 @@ export class QURLClient {
|
|
|
2393
509
|
* is malformed, additional shape errors don't help debug the upstream
|
|
2394
510
|
* issue.
|
|
2395
511
|
*/
|
|
2396
|
-
validateBatchCreateResponse(
|
|
2397
|
-
const result = envelope.data;
|
|
2398
|
-
const statusSuffix =
|
|
512
|
+
validateBatchCreateResponse(response, requestItemCount) {
|
|
513
|
+
const result = response.envelope.data;
|
|
514
|
+
const statusSuffix = ` (HTTP ${response.status})`;
|
|
2399
515
|
// Thread the server's request_id into the shape-guard error so
|
|
2400
516
|
// operators debugging "unexpected response" tickets have the
|
|
2401
517
|
// correlation handle on the error path, mirroring the success-
|
|
@@ -2405,7 +521,7 @@ export class QURLClient {
|
|
|
2405
521
|
// .requestId property AND the message string so a stack trace
|
|
2406
522
|
// pasted into a support ticket carries the correlation handle
|
|
2407
523
|
// without a follow-up round-trip.
|
|
2408
|
-
const requestId = boundedErrorIdentifier(envelope.meta?.request_id);
|
|
524
|
+
const requestId = boundedErrorIdentifier(response.envelope.meta?.request_id);
|
|
2409
525
|
const requestIdSuffix = requestId !== undefined ? ` [request_id=${requestId}]` : "";
|
|
2410
526
|
// `Number.isInteger` rejects NaN, Infinity, and floats. Combined
|
|
2411
527
|
// with `>= 0`, this rules out pathological proxy bodies (e.g.
|
|
@@ -2469,7 +585,7 @@ export class QURLClient {
|
|
|
2469
585
|
pageCount += 1;
|
|
2470
586
|
const page = await loadPage({ ...input, cursor });
|
|
2471
587
|
for (const item of itemsFromPage(page)) {
|
|
2472
|
-
yield item;
|
|
588
|
+
yield copyResponseMetadata(page, item);
|
|
2473
589
|
}
|
|
2474
590
|
cursor = page.next_cursor;
|
|
2475
591
|
if (page.has_more === false) {
|
|
@@ -2496,7 +612,9 @@ export class QURLClient {
|
|
|
2496
612
|
* {@link ProtectedResource.createPortal}:
|
|
2497
613
|
*
|
|
2498
614
|
* ```ts
|
|
2499
|
-
* const resource = await client.protectUrl("https://internal.example.com/dashboard"
|
|
615
|
+
* const resource = await client.protectUrl("https://internal.example.com/dashboard", {
|
|
616
|
+
* name: "Team dashboard",
|
|
617
|
+
* });
|
|
2500
618
|
* const portal = await resource.createPortal({ validFor: "5m" });
|
|
2501
619
|
* share(portal.link);
|
|
2502
620
|
* ```
|
|
@@ -2514,6 +632,8 @@ export class QURLClient {
|
|
|
2514
632
|
// SDK-wide normalizePatchFields convention.
|
|
2515
633
|
const normalized = normalizePatchFields(opts, PROTECT_URL_OPTION_KEYS);
|
|
2516
634
|
const body = { target_url: targetUrl };
|
|
635
|
+
if (normalized.name !== undefined)
|
|
636
|
+
body.name = normalized.name;
|
|
2517
637
|
if (normalized.private !== undefined)
|
|
2518
638
|
body.private = normalized.private;
|
|
2519
639
|
if (normalized.consumerAccess !== undefined) {
|
|
@@ -2532,13 +652,13 @@ export class QURLClient {
|
|
|
2532
652
|
const response = await this.rawRequest("POST", "/v1/resources", body, {
|
|
2533
653
|
requestOptions,
|
|
2534
654
|
});
|
|
2535
|
-
if (response.
|
|
655
|
+
if (response.status === 204)
|
|
2536
656
|
throw unexpectedResponseError("protectUrl: expected a resource response body");
|
|
2537
|
-
const resource = response.data;
|
|
657
|
+
const resource = withEnvelopeMetadata(response.envelope.data, response.envelope.meta);
|
|
2538
658
|
await verifyResourceIdentity(resource, "protectUrl");
|
|
2539
659
|
if (normalized.consumerAccess &&
|
|
2540
660
|
(resource.private !== true ||
|
|
2541
|
-
!sameConsumerPolicy(resource.consumer_access, normalized.consumerAccess, response.meta?.found_existing === true))) {
|
|
661
|
+
!sameConsumerPolicy(resource.consumer_access, normalized.consumerAccess, response.envelope.meta?.found_existing === true))) {
|
|
2542
662
|
throw unexpectedResponseError("protectUrl: private policy was not confirmed");
|
|
2543
663
|
}
|
|
2544
664
|
return new ProtectedResource(this, resource.crid,
|
|
@@ -2565,9 +685,20 @@ export class QURLClient {
|
|
|
2565
685
|
requireResourceCrid(id, "resourceByCrid");
|
|
2566
686
|
return new ProtectedResource(this, id);
|
|
2567
687
|
}
|
|
2568
|
-
/**
|
|
2569
|
-
|
|
688
|
+
/**
|
|
689
|
+
* Find or create the active qURL Connector resource for an immutable slug.
|
|
690
|
+
*
|
|
691
|
+
* `name` is the public name recipients see. The service requires it when the
|
|
692
|
+
* slug is new (`400 name_required` otherwise) and ignores it on reuse.
|
|
693
|
+
*
|
|
694
|
+
* @param slug - Immutable owner-scoped Connector slug.
|
|
695
|
+
* @param requestOptions - Per-call request options.
|
|
696
|
+
* @param consumerAccess - Private consumer policy for a new resource.
|
|
697
|
+
* @param name - Public resource name; required when the slug is new.
|
|
698
|
+
*/
|
|
699
|
+
async ensureConnectorResource(slug, requestOptions, consumerAccess, name) {
|
|
2570
700
|
requireConnectorSlug(slug, "ensureConnectorResource");
|
|
701
|
+
requireResourceNameIfPresent(name);
|
|
2571
702
|
if (consumerAccess !== undefined) {
|
|
2572
703
|
validateResourceWriteFields({ consumer_access: consumerAccess, private: true });
|
|
2573
704
|
if (consumerAccess.version !== 1)
|
|
@@ -2587,52 +718,52 @@ export class QURLClient {
|
|
|
2587
718
|
type: "tunnel",
|
|
2588
719
|
slug,
|
|
2589
720
|
find_or_create: true,
|
|
721
|
+
...(name !== undefined ? { name } : {}),
|
|
2590
722
|
...(consumerAccess ? { private: true, consumer_access: consumerAccess } : {}),
|
|
2591
723
|
}, { requestOptions, retry: false, generateIdempotencyKey: false });
|
|
2592
724
|
// The find-or-create endpoint returns 201 for both newly-created
|
|
2593
725
|
// and found-existing rows; meta.found_existing distinguishes the arms.
|
|
2594
|
-
if (response.
|
|
2595
|
-
throw unexpectedResponseError(`ensureConnectorResource: expected HTTP 201, got ${response.
|
|
726
|
+
if (response.status !== 201) {
|
|
727
|
+
throw unexpectedResponseError(`ensureConnectorResource: expected HTTP 201, got ${response.status ?? "unknown"}`);
|
|
2596
728
|
}
|
|
2597
729
|
if (consumerAccess &&
|
|
2598
|
-
(response.data.private !== true ||
|
|
2599
|
-
!sameConsumerPolicy(response.data.consumer_access, consumerAccess, response.meta?.found_existing === true))) {
|
|
730
|
+
(response.envelope.data.private !== true ||
|
|
731
|
+
!sameConsumerPolicy(response.envelope.data.consumer_access, consumerAccess, response.envelope.meta?.found_existing === true))) {
|
|
2600
732
|
throw unexpectedResponseError("ensureConnectorResource: private policy was not confirmed");
|
|
2601
733
|
}
|
|
2602
|
-
resource = await parseConnectorResource(this, response.data, "ensureConnectorResource", {
|
|
734
|
+
resource = await parseConnectorResource(this, response.envelope.data, "ensureConnectorResource", {
|
|
2603
735
|
slug,
|
|
2604
736
|
});
|
|
2605
737
|
}
|
|
2606
738
|
catch (error) {
|
|
2607
739
|
classifyConnectorMutationFailure("ensure", error);
|
|
2608
740
|
}
|
|
2609
|
-
if (response.meta !== undefined &&
|
|
2610
|
-
response.meta !== null &&
|
|
2611
|
-
(typeof response.meta !== "object" || Array.isArray(response.meta))) {
|
|
741
|
+
if (response.envelope.meta !== undefined &&
|
|
742
|
+
response.envelope.meta !== null &&
|
|
743
|
+
(typeof response.envelope.meta !== "object" || Array.isArray(response.envelope.meta))) {
|
|
2612
744
|
classifyConnectorMutationFailure("ensure", unexpectedResponseError("ensureConnectorResource: response has invalid meta"));
|
|
2613
745
|
}
|
|
2614
|
-
const foundExisting = response.meta
|
|
2615
|
-
?.found_existing;
|
|
746
|
+
const foundExisting = response.envelope.meta?.found_existing;
|
|
2616
747
|
if (foundExisting === undefined || foundExisting === null) {
|
|
2617
748
|
throw unexpectedResponseError("ensureConnectorResource: response is missing meta.found_existing; the validated resource exists, so reconcile by slug before retrying");
|
|
2618
749
|
}
|
|
2619
750
|
if (typeof foundExisting !== "boolean") {
|
|
2620
751
|
classifyConnectorMutationFailure("ensure", unexpectedResponseError("ensureConnectorResource: response has invalid meta.found_existing"));
|
|
2621
752
|
}
|
|
2622
|
-
return { resource, foundExisting };
|
|
753
|
+
return withEnvelopeMetadata({ resource: withEnvelopeMetadata(resource, response.envelope.meta), foundExisting }, response.envelope.meta);
|
|
2623
754
|
}
|
|
2624
755
|
/** Fetch a qURL Connector resource by its CRID. */
|
|
2625
756
|
async getConnectorResource(crid) {
|
|
2626
757
|
requireResourceCrid(crid, "getConnectorResource");
|
|
2627
758
|
requireConnectorSubtleCrypto("getConnectorResource");
|
|
2628
|
-
const { data,
|
|
2629
|
-
if (
|
|
2630
|
-
throw unexpectedResponseError(`getConnectorResource: expected HTTP 200, got ${
|
|
759
|
+
const { envelope: { data, meta }, status: httpStatus, } = await this.rawRequest("GET", `/v1/resources/${encodeURIComponent(crid)}`, undefined, { retry: false });
|
|
760
|
+
if (httpStatus !== 200) {
|
|
761
|
+
throw unexpectedResponseError(`getConnectorResource: expected HTTP 200, got ${httpStatus ?? "unknown"}`);
|
|
2631
762
|
}
|
|
2632
|
-
return parseConnectorResource(this, data?.resource, "getConnectorResource", {
|
|
763
|
+
return withEnvelopeMetadata(await parseConnectorResource(this, data?.resource, "getConnectorResource", {
|
|
2633
764
|
crid,
|
|
2634
765
|
revokedIsLifecycleError: true,
|
|
2635
|
-
});
|
|
766
|
+
}), meta);
|
|
2636
767
|
}
|
|
2637
768
|
/** Fetch the single active qURL Connector resource for an immutable slug. */
|
|
2638
769
|
async getConnectorResourceBySlug(slug) {
|
|
@@ -2642,9 +773,9 @@ export class QURLClient {
|
|
|
2642
773
|
requireConnectorSubtleCrypto("getConnectorResourceBySlug");
|
|
2643
774
|
// The slug point lookup is intrinsically active-only. It also
|
|
2644
775
|
// rejects combining `slug` with `status`, so this query must stay slug-only.
|
|
2645
|
-
const { data,
|
|
2646
|
-
if (
|
|
2647
|
-
throw unexpectedResponseError(`getConnectorResourceBySlug: expected HTTP 200, got ${
|
|
776
|
+
const { envelope: { data, meta }, status: httpStatus, } = await this.rawRequest("GET", `/v1/resources?slug=${encodeURIComponent(slug)}`, undefined, { retry: false });
|
|
777
|
+
if (httpStatus !== 200) {
|
|
778
|
+
throw unexpectedResponseError(`getConnectorResourceBySlug: expected HTTP 200, got ${httpStatus ?? "unknown"}`);
|
|
2648
779
|
}
|
|
2649
780
|
if (!Array.isArray(data)) {
|
|
2650
781
|
throw unexpectedResponseError("getConnectorResourceBySlug: response has missing or invalid data");
|
|
@@ -2665,7 +796,7 @@ export class QURLClient {
|
|
|
2665
796
|
detail: `getConnectorResourceBySlug: expected one active resource, got ${data.length}`,
|
|
2666
797
|
});
|
|
2667
798
|
}
|
|
2668
|
-
return parseConnectorResource(this, data[0], "getConnectorResourceBySlug", { slug });
|
|
799
|
+
return withEnvelopeMetadata(await parseConnectorResource(this, data[0], "getConnectorResourceBySlug", { slug }), meta);
|
|
2669
800
|
}
|
|
2670
801
|
/**
|
|
2671
802
|
* Revoke a qURL Connector resource by CRID.
|
|
@@ -2716,7 +847,7 @@ export class QURLClient {
|
|
|
2716
847
|
requireResourceCrid(resourceId, "createPortal");
|
|
2717
848
|
const body = buildCreatePortalBody(opts, "createPortal");
|
|
2718
849
|
const data = await this.request("POST", `/v1/resources/${encodeURIComponent(resourceId)}/qurls`, body, requestOptions);
|
|
2719
|
-
return parsePortal(data, "createPortal", resourceId);
|
|
850
|
+
return parsePortal(data, "createPortal", resourceId, body.target_path);
|
|
2720
851
|
}
|
|
2721
852
|
/**
|
|
2722
853
|
* Protect `targetUrl` and mint a portal in one API call.
|
|
@@ -2739,10 +870,10 @@ export class QURLClient {
|
|
|
2739
870
|
};
|
|
2740
871
|
const data = await this.request("POST", "/v1/qurls", body, requestOptions);
|
|
2741
872
|
const portal = await parsePortal(data, "createPortalForUrl");
|
|
2742
|
-
return {
|
|
873
|
+
return copyResponseMetadata(data, {
|
|
2743
874
|
portal,
|
|
2744
|
-
resource: new ProtectedResource(this, portal.crid, targetUrl),
|
|
2745
|
-
};
|
|
875
|
+
resource: copyResponseMetadata(data, new ProtectedResource(this, portal.crid, targetUrl)),
|
|
876
|
+
});
|
|
2746
877
|
}
|
|
2747
878
|
/**
|
|
2748
879
|
* Open a qURL link programmatically and return the reachable resource.
|
|
@@ -2772,11 +903,11 @@ export class QURLClient {
|
|
|
2772
903
|
if (typeof resolved?.target_url !== "string" || resolved.target_url === "") {
|
|
2773
904
|
throw unexpectedResponseError("enterPortal: access was granted but the API returned no resource URL");
|
|
2774
905
|
}
|
|
2775
|
-
return {
|
|
906
|
+
return copyResponseMetadata(resolved, {
|
|
2776
907
|
resourceUrl: resolved.target_url,
|
|
2777
908
|
openSeconds: resolved.access_grant?.expires_in ?? 0,
|
|
2778
909
|
resourcePublicKey: resolved.resource_id,
|
|
2779
|
-
};
|
|
910
|
+
});
|
|
2780
911
|
}
|
|
2781
912
|
// --- Public API ---
|
|
2782
913
|
/**
|
|
@@ -2800,6 +931,8 @@ export class QURLClient {
|
|
|
2800
931
|
* populated `BatchCreateOutput` body in this case; whitelisted via
|
|
2801
932
|
* `passthroughStatuses` so the structured per-item errors aren't
|
|
2802
933
|
* swallowed by the generic error path.
|
|
934
|
+
* HTTP 400 is the documented all-item validation failure. HTTP 422 is
|
|
935
|
+
* not a batch passthrough status and follows normal error handling.
|
|
2803
936
|
*
|
|
2804
937
|
* Callers that only catch thrown errors will **silently miss partial
|
|
2805
938
|
* failures**. Always branch on `result.failed > 0` before treating the
|
|
@@ -2890,11 +1023,11 @@ export class QURLClient {
|
|
|
2890
1023
|
// from the envelope and propagate it into the returned
|
|
2891
1024
|
// BatchCreateOutput — consumers filing support tickets on partial
|
|
2892
1025
|
// or total batch failures need the correlation ID.
|
|
2893
|
-
const
|
|
1026
|
+
const response = await this.rawRequest("POST", "/v1/qurls/batch", input, {
|
|
2894
1027
|
passthroughStatuses: BATCH_PASSTHROUGH_STATUSES,
|
|
2895
1028
|
requestOptions: options,
|
|
2896
1029
|
});
|
|
2897
|
-
const result = this.validateBatchCreateResponse(
|
|
1030
|
+
const result = this.validateBatchCreateResponse(response, input.items.length);
|
|
2898
1031
|
// Attach the server request_id from the envelope meta without
|
|
2899
1032
|
// mutating `result` (which is the parsed JSON body). Mirrors the
|
|
2900
1033
|
// non-mutating style used elsewhere (e.g. `mapQurlsField`). In the
|
|
@@ -2911,7 +1044,7 @@ export class QURLClient {
|
|
|
2911
1044
|
// as the source, and `meta.request_id` is the canonical envelope
|
|
2912
1045
|
// field. The data-side variant would be a wire-format duplication
|
|
2913
1046
|
// we don't want to silently honor.
|
|
2914
|
-
const requestId = envelope.meta?.request_id;
|
|
1047
|
+
const requestId = response.envelope.meta?.request_id;
|
|
2915
1048
|
// Both-fields-present and data-only-present are both worth surfacing
|
|
2916
1049
|
// via debug — the former because meta wins silently, the latter
|
|
2917
1050
|
// because it suggests an envelope-shape transition. Data-side value
|
|
@@ -2933,7 +1066,7 @@ export class QURLClient {
|
|
|
2933
1066
|
});
|
|
2934
1067
|
}
|
|
2935
1068
|
}
|
|
2936
|
-
return requestId !== undefined ? { ...result, request_id: requestId } : result;
|
|
1069
|
+
return withEnvelopeMetadata(requestId !== undefined ? { ...result, request_id: requestId } : result, response.envelope.meta);
|
|
2937
1070
|
}
|
|
2938
1071
|
/**
|
|
2939
1072
|
* Start one durable delegated qURL batch. The SDK sends this mutation once.
|
|
@@ -2946,7 +1079,7 @@ export class QURLClient {
|
|
|
2946
1079
|
let dispatched = false;
|
|
2947
1080
|
let acceptedBatchId;
|
|
2948
1081
|
try {
|
|
2949
|
-
const
|
|
1082
|
+
const response = await this.rawRequest("POST", "/v1/delegated-qurl-batches", input, {
|
|
2950
1083
|
requestOptions: options,
|
|
2951
1084
|
retry: false,
|
|
2952
1085
|
generateIdempotencyKey: false,
|
|
@@ -2955,25 +1088,25 @@ export class QURLClient {
|
|
|
2955
1088
|
dispatched = true;
|
|
2956
1089
|
},
|
|
2957
1090
|
});
|
|
2958
|
-
const status =
|
|
1091
|
+
const status = response.status ?? 0;
|
|
2959
1092
|
if (status !== 202) {
|
|
2960
1093
|
throw delegatedResponseError(status, "create returned an unexpected success status");
|
|
2961
1094
|
}
|
|
2962
|
-
const rawData = envelope.data;
|
|
1095
|
+
const rawData = response.envelope.data;
|
|
2963
1096
|
if (typeof rawData === "object" && rawData !== null && !Array.isArray(rawData)) {
|
|
2964
1097
|
const candidate = rawData.batch_id;
|
|
2965
1098
|
if (typeof candidate === "string" && DELEGATED_BATCH_ID_PATTERN.test(candidate)) {
|
|
2966
1099
|
acceptedBatchId = candidate;
|
|
2967
1100
|
}
|
|
2968
1101
|
}
|
|
2969
|
-
const { data, requestId } = delegatedEnvelope(
|
|
1102
|
+
const { data, requestId } = delegatedEnvelope(response, status);
|
|
2970
1103
|
const base = delegatedBatchBase(data, status, requestId);
|
|
2971
1104
|
if (data.status !== "queued" || base.itemCount !== input.grants.length) {
|
|
2972
1105
|
throw delegatedResponseError(status, "acceptance data is inconsistent", requestId);
|
|
2973
1106
|
}
|
|
2974
|
-
const responseHeaders = delegatedResponseHeaders(
|
|
2975
|
-
const location = validateDelegatedLocation(requiredDelegatedHeader(
|
|
2976
|
-
return {
|
|
1107
|
+
const responseHeaders = delegatedResponseHeaders(response, status, requestId);
|
|
1108
|
+
const location = validateDelegatedLocation(requiredDelegatedHeader(response, "Location", status, requestId), this.baseUrl, base.batchId, status, requestId);
|
|
1109
|
+
return withEnvelopeMetadata({
|
|
2977
1110
|
http_status: 202,
|
|
2978
1111
|
batch_id: base.batchId,
|
|
2979
1112
|
status: "queued",
|
|
@@ -2983,7 +1116,7 @@ export class QURLClient {
|
|
|
2983
1116
|
retry_after: responseHeaders.retryAfter,
|
|
2984
1117
|
location,
|
|
2985
1118
|
request_id: requestId,
|
|
2986
|
-
};
|
|
1119
|
+
}, response.envelope.meta);
|
|
2987
1120
|
}
|
|
2988
1121
|
catch (error) {
|
|
2989
1122
|
if (!dispatched)
|
|
@@ -3000,41 +1133,41 @@ export class QURLClient {
|
|
|
3000
1133
|
throw clientValidationError("getDelegatedQurlBatch: batchId must match dqb_ plus 22 URL-safe characters");
|
|
3001
1134
|
}
|
|
3002
1135
|
validateGetDelegatedQurlBatchOptions(options);
|
|
3003
|
-
const
|
|
1136
|
+
const response = await this.rawRequest("GET", `/v1/delegated-qurl-batches/${encodeURIComponent(batchId)}`, undefined, {
|
|
3004
1137
|
passthroughStatuses: DELEGATED_BATCH_PASSTHROUGH_STATUSES,
|
|
3005
1138
|
allowEmptyPassthroughBody: true,
|
|
3006
1139
|
ifNoneMatch: options?.etag,
|
|
3007
1140
|
retry: false,
|
|
3008
1141
|
captureResponseHeaders: true,
|
|
3009
1142
|
});
|
|
3010
|
-
const status =
|
|
1143
|
+
const status = response.status ?? 0;
|
|
3011
1144
|
if (status === 304) {
|
|
3012
|
-
if (
|
|
1145
|
+
if (response.bodyEmpty !== true || options?.etag === undefined) {
|
|
3013
1146
|
throw delegatedResponseError(status, "304 response is inconsistent");
|
|
3014
1147
|
}
|
|
3015
|
-
const responseHeaders = delegatedResponseHeaders(
|
|
1148
|
+
const responseHeaders = delegatedResponseHeaders(response, status);
|
|
3016
1149
|
if (responseHeaders.etag !== undefined && responseHeaders.etag !== options.etag) {
|
|
3017
1150
|
throw delegatedResponseError(status, "304 response changed the ETag");
|
|
3018
1151
|
}
|
|
3019
|
-
return {
|
|
1152
|
+
return withEnvelopeMetadata({
|
|
3020
1153
|
http_status: 304,
|
|
3021
1154
|
etag: responseHeaders.etag ?? options.etag,
|
|
3022
1155
|
...(responseHeaders.retryAfter === undefined
|
|
3023
1156
|
? {}
|
|
3024
1157
|
: { retry_after: responseHeaders.retryAfter }),
|
|
3025
|
-
};
|
|
1158
|
+
}, response.envelope.meta);
|
|
3026
1159
|
}
|
|
3027
1160
|
if (status !== 200 && status !== 202) {
|
|
3028
1161
|
throw delegatedResponseError(status, "read returned an unexpected success status");
|
|
3029
1162
|
}
|
|
3030
|
-
const { data, requestId } = delegatedEnvelope(
|
|
1163
|
+
const { data, requestId } = delegatedEnvelope(response, status);
|
|
3031
1164
|
const base = delegatedBatchBase(data, status, requestId, batchId);
|
|
3032
1165
|
if (status === 202) {
|
|
3033
1166
|
if (data.status !== "queued" && data.status !== "running") {
|
|
3034
1167
|
throw delegatedResponseError(status, "pending data has a terminal status", requestId);
|
|
3035
1168
|
}
|
|
3036
|
-
const responseHeaders = delegatedResponseHeaders(
|
|
3037
|
-
return {
|
|
1169
|
+
const responseHeaders = delegatedResponseHeaders(response, status, requestId);
|
|
1170
|
+
return withEnvelopeMetadata({
|
|
3038
1171
|
http_status: 202,
|
|
3039
1172
|
batch_id: base.batchId,
|
|
3040
1173
|
status: data.status,
|
|
@@ -3043,7 +1176,7 @@ export class QURLClient {
|
|
|
3043
1176
|
etag: responseHeaders.etag,
|
|
3044
1177
|
retry_after: responseHeaders.retryAfter,
|
|
3045
1178
|
request_id: requestId,
|
|
3046
|
-
};
|
|
1179
|
+
}, response.envelope.meta);
|
|
3047
1180
|
}
|
|
3048
1181
|
if (data.status !== "succeeded" &&
|
|
3049
1182
|
data.status !== "partially_failed" &&
|
|
@@ -3079,17 +1212,17 @@ export class QURLClient {
|
|
|
3079
1212
|
// This is the caller's only copy of the bearer links. Do not discard a
|
|
3080
1213
|
// valid terminal body because an intermediary removed a cache validator;
|
|
3081
1214
|
// qurl-service owns the required no-store response policy.
|
|
3082
|
-
const cacheControl =
|
|
1215
|
+
const cacheControl = response.headers?.get("Cache-Control") ?? null;
|
|
3083
1216
|
if (!hasDelegatedNoStore(cacheControl)) {
|
|
3084
1217
|
this.log("delegated batch terminal response is missing private, no-store", {
|
|
3085
1218
|
batch_id: base.batchId,
|
|
3086
1219
|
});
|
|
3087
1220
|
}
|
|
3088
|
-
const etag =
|
|
1221
|
+
const etag = response.headers?.get("ETag") ?? undefined;
|
|
3089
1222
|
const terminalEtag = etag !== undefined && etag.length <= MAX_DELEGATED_ETAG && STRONG_ETAG_PATTERN.test(etag)
|
|
3090
1223
|
? etag
|
|
3091
1224
|
: undefined;
|
|
3092
|
-
return {
|
|
1225
|
+
return withEnvelopeMetadata({
|
|
3093
1226
|
http_status: 200,
|
|
3094
1227
|
batch_id: base.batchId,
|
|
3095
1228
|
status: data.status,
|
|
@@ -3099,20 +1232,20 @@ export class QURLClient {
|
|
|
3099
1232
|
results,
|
|
3100
1233
|
...(terminalEtag === undefined ? {} : { etag: terminalEtag }),
|
|
3101
1234
|
request_id: requestId,
|
|
3102
|
-
};
|
|
1235
|
+
}, response.envelope.meta);
|
|
3103
1236
|
}
|
|
3104
1237
|
/** Read owner-visible metadata for one delegated qURL. */
|
|
3105
1238
|
async getDelegatedQurl(qurlId) {
|
|
3106
1239
|
if (typeof qurlId !== "string" || !DELEGATED_QURL_ID_PATTERN.test(qurlId)) {
|
|
3107
1240
|
throw clientValidationError("getDelegatedQurl: qurlId must match q_ plus 11 lowercase hexadecimal characters");
|
|
3108
1241
|
}
|
|
3109
|
-
const
|
|
3110
|
-
const status =
|
|
1242
|
+
const response = await this.rawRequest("GET", `/v1/delegated-qurls/${encodeURIComponent(qurlId)}`);
|
|
1243
|
+
const status = response.status ?? 0;
|
|
3111
1244
|
if (status !== 200) {
|
|
3112
1245
|
throw delegatedResponseError(status, "read returned an unexpected success status");
|
|
3113
1246
|
}
|
|
3114
|
-
const { data, requestId } = delegatedEnvelope(
|
|
3115
|
-
return validateDelegatedQurl(data, qurlId, status, requestId);
|
|
1247
|
+
const { data, requestId } = delegatedEnvelope(response, status);
|
|
1248
|
+
return withEnvelopeMetadata(validateDelegatedQurl(data, qurlId, status, requestId), response.envelope.meta);
|
|
3116
1249
|
}
|
|
3117
1250
|
/**
|
|
3118
1251
|
* Revoke one delegated qURL. DELETE is never retried automatically. A 503
|
|
@@ -3150,7 +1283,7 @@ export class QURLClient {
|
|
|
3150
1283
|
*/
|
|
3151
1284
|
async list(input = {}) {
|
|
3152
1285
|
const path = appendQuery("/v1/qurls", input, LIST_PARAM_KEYS, "list");
|
|
3153
|
-
const { data, meta } = await this.rawRequest("GET", path);
|
|
1286
|
+
const { envelope: { data, meta }, } = await this.rawRequest("GET", path);
|
|
3154
1287
|
return pageFromMeta({
|
|
3155
1288
|
// Defensive: map in case API includes nested tokens on list items in the future.
|
|
3156
1289
|
qurls: (data ?? []).map((raw) => this.mapQurlsField(raw)),
|
|
@@ -3331,13 +1464,31 @@ export class QURLClient {
|
|
|
3331
1464
|
}
|
|
3332
1465
|
return this.request("POST", "/v1/resolve", body, options);
|
|
3333
1466
|
}
|
|
1467
|
+
/** Get the caller's account and credential identity. */
|
|
1468
|
+
async getIdentity() {
|
|
1469
|
+
const identity = await this.request("GET", "/v1/me");
|
|
1470
|
+
if (!identity ||
|
|
1471
|
+
typeof identity !== "object" ||
|
|
1472
|
+
Array.isArray(identity) ||
|
|
1473
|
+
typeof identity.owner_id !== "string" ||
|
|
1474
|
+
!identity.owner_id ||
|
|
1475
|
+
typeof identity.auth_type !== "string" ||
|
|
1476
|
+
!identity.auth_type ||
|
|
1477
|
+
(identity.api_key !== undefined &&
|
|
1478
|
+
(!identity.api_key ||
|
|
1479
|
+
typeof identity.api_key !== "object" ||
|
|
1480
|
+
Array.isArray(identity.api_key)))) {
|
|
1481
|
+
throw unexpectedResponseError("getIdentity: invalid caller identity response");
|
|
1482
|
+
}
|
|
1483
|
+
return identity;
|
|
1484
|
+
}
|
|
3334
1485
|
/** Get quota and usage information. */
|
|
3335
1486
|
async getQuota() {
|
|
3336
1487
|
return this.request("GET", "/v1/quota");
|
|
3337
1488
|
}
|
|
3338
1489
|
/** List resources from the `/v1/resources` API. */
|
|
3339
1490
|
async listResources(input = {}) {
|
|
3340
|
-
const { data, meta } = await this.rawRequest("GET", appendQuery("/v1/resources", input, RESOURCE_LIST_PARAM_KEYS, "listResources"));
|
|
1491
|
+
const { envelope: { data, meta }, } = await this.rawRequest("GET", appendQuery("/v1/resources", input, RESOURCE_LIST_PARAM_KEYS, "listResources"));
|
|
3341
1492
|
return pageFromMeta({ resources: data ?? [] }, meta);
|
|
3342
1493
|
}
|
|
3343
1494
|
/**
|
|
@@ -3385,6 +1536,31 @@ export class QURLClient {
|
|
|
3385
1536
|
});
|
|
3386
1537
|
return this.request("PATCH", `/v1/resources/${encodeURIComponent(id)}`, validationInput, options);
|
|
3387
1538
|
}
|
|
1539
|
+
/** Start Stripe Connect onboarding for this publisher. LayerV collects 3%. */
|
|
1540
|
+
async connectPublisherStripe() {
|
|
1541
|
+
const result = await this.request("POST", "/v1/payments/connect");
|
|
1542
|
+
let target;
|
|
1543
|
+
try {
|
|
1544
|
+
target = new URL(result.url);
|
|
1545
|
+
}
|
|
1546
|
+
catch {
|
|
1547
|
+
throw clientValidationError("invalid Stripe connection URL");
|
|
1548
|
+
}
|
|
1549
|
+
if (target.protocol !== "https:" ||
|
|
1550
|
+
target.host !== "connect.stripe.com" ||
|
|
1551
|
+
target.username ||
|
|
1552
|
+
target.password)
|
|
1553
|
+
throw clientValidationError("invalid Stripe connection URL");
|
|
1554
|
+
return result;
|
|
1555
|
+
}
|
|
1556
|
+
/** Read this publisher's connected account and the fixed application fee. */
|
|
1557
|
+
async publisherStripeAccount() {
|
|
1558
|
+
const result = await this.request("GET", "/v1/payments/account");
|
|
1559
|
+
if ((result.account_id !== "" && !/^acct_[A-Za-z0-9]{1,128}$/.test(result.account_id)) ||
|
|
1560
|
+
result.layerv_fee_percent !== 3)
|
|
1561
|
+
throw clientValidationError("invalid Stripe account response");
|
|
1562
|
+
return result;
|
|
1563
|
+
}
|
|
3388
1564
|
/** Send a publisher-only invitation. Mail delivery does not prove enrollment. */
|
|
3389
1565
|
async inviteConsumer(id, email) {
|
|
3390
1566
|
requireResourceCrid(id, "inviteConsumer");
|
|
@@ -3395,12 +1571,12 @@ export class QURLClient {
|
|
|
3395
1571
|
}
|
|
3396
1572
|
async listConsumerInvitations(id, input = {}) {
|
|
3397
1573
|
requireResourceCrid(id, "listConsumerInvitations");
|
|
3398
|
-
const { data, meta } = await this.rawRequest("GET", appendQuery(`/v1/resources/${encodeURIComponent(id)}/invitations`, input, ["cursor", "limit"], "listConsumerInvitations"));
|
|
1574
|
+
const { envelope: { data, meta }, } = await this.rawRequest("GET", appendQuery(`/v1/resources/${encodeURIComponent(id)}/invitations`, input, ["cursor", "limit"], "listConsumerInvitations"));
|
|
3399
1575
|
return pageFromMeta({ invitations: data ?? [] }, meta);
|
|
3400
1576
|
}
|
|
3401
1577
|
async listConsumerGrants(id, input = {}) {
|
|
3402
1578
|
requireResourceCrid(id, "listConsumerGrants");
|
|
3403
|
-
const { data, meta } = await this.rawRequest("GET", appendQuery(`/v1/resources/${encodeURIComponent(id)}/consumer-grants`, input, ["cursor", "limit"], "listConsumerGrants"));
|
|
1579
|
+
const { envelope: { data, meta }, } = await this.rawRequest("GET", appendQuery(`/v1/resources/${encodeURIComponent(id)}/consumer-grants`, input, ["cursor", "limit"], "listConsumerGrants"));
|
|
3404
1580
|
return pageFromMeta({ grants: data ?? [] }, meta);
|
|
3405
1581
|
}
|
|
3406
1582
|
async resendConsumerInvitation(id, invitationId, expectedVersion) {
|
|
@@ -3536,7 +1712,7 @@ export class QURLClient {
|
|
|
3536
1712
|
if (typeof wire.expires_at === "string" && expiresAt === undefined) {
|
|
3537
1713
|
throw unexpectedResponseError("shareResource: response has invalid expires_at");
|
|
3538
1714
|
}
|
|
3539
|
-
return new ShareLink({
|
|
1715
|
+
return new ShareLink(copyResponseMetadata(data, {
|
|
3540
1716
|
// Repair transport-only edge whitespace on the one-time credential;
|
|
3541
1717
|
// ancillary identifiers fail closed instead of being normalized.
|
|
3542
1718
|
link: data.qurl.trim(),
|
|
@@ -3546,7 +1722,7 @@ export class QURLClient {
|
|
|
3546
1722
|
expiresAt,
|
|
3547
1723
|
expiresInSeconds: typeof wire.expires_in_seconds === "number" ? wire.expires_in_seconds : undefined,
|
|
3548
1724
|
singleUse: typeof wire.single_use === "boolean" ? wire.single_use : undefined,
|
|
3549
|
-
});
|
|
1725
|
+
}));
|
|
3550
1726
|
}
|
|
3551
1727
|
/** Revoke a specific qURL token on a resource. */
|
|
3552
1728
|
async revokeResourceQurl(id, qurlId) {
|
|
@@ -3572,7 +1748,7 @@ export class QURLClient {
|
|
|
3572
1748
|
let request_id;
|
|
3573
1749
|
let page_size;
|
|
3574
1750
|
for await (const session of this.paginateAll("listResourceSessions", {}, async ({ cursor }) => {
|
|
3575
|
-
const { data, meta } = await this.rawRequest("GET", appendQuery(`/v1/resources/${encodeURIComponent(id)}/sessions`, { paged: "true", cursor }, ["paged", "cursor"], "listResourceSessions"));
|
|
1751
|
+
const { envelope: { data, meta }, } = await this.rawRequest("GET", appendQuery(`/v1/resources/${encodeURIComponent(id)}/sessions`, { paged: "true", cursor }, ["paged", "cursor"], "listResourceSessions"));
|
|
3576
1752
|
if (meta?.has_more && !meta.next_cursor) {
|
|
3577
1753
|
throw unexpectedResponseError("Session page has no continuation cursor", {
|
|
3578
1754
|
requestId: meta.request_id,
|
|
@@ -3580,11 +1756,11 @@ export class QURLClient {
|
|
|
3580
1756
|
}
|
|
3581
1757
|
request_id = meta?.request_id;
|
|
3582
1758
|
page_size = meta?.page_size;
|
|
3583
|
-
return { ...meta, sessions: data ?? [] };
|
|
1759
|
+
return withEnvelopeMetadata({ ...meta, sessions: data ?? [] }, meta);
|
|
3584
1760
|
}, (page) => page.sessions)) {
|
|
3585
1761
|
sessions.push(session);
|
|
3586
1762
|
}
|
|
3587
|
-
return { sessions, request_id, has_more: false, page_size };
|
|
1763
|
+
return withEnvelopeMetadata({ sessions, request_id, has_more: false, page_size }, { request_id });
|
|
3588
1764
|
}
|
|
3589
1765
|
/**
|
|
3590
1766
|
* End every session page. This is not atomic: a failed page throws after earlier
|
|
@@ -3597,8 +1773,8 @@ export class QURLClient {
|
|
|
3597
1773
|
let terminated = 0;
|
|
3598
1774
|
let request_id;
|
|
3599
1775
|
for await (const count of this.paginateAll("terminateAllResourceSessions", {}, async ({ cursor }) => {
|
|
3600
|
-
const { data, meta,
|
|
3601
|
-
if (
|
|
1776
|
+
const { envelope: { data, meta }, status: httpStatus, } = await this.rawRequest("DELETE", appendQuery(path, { paged: "true", cursor }, ["paged", "cursor"], "terminateAllResourceSessions"));
|
|
1777
|
+
if (httpStatus === 204) {
|
|
3602
1778
|
throw unexpectedResponseError(`Unexpected 204 No Content from DELETE ${path}; expected response body`, { status: 204, requestId: meta?.request_id });
|
|
3603
1779
|
}
|
|
3604
1780
|
if (meta?.has_more && !meta.next_cursor) {
|
|
@@ -3606,22 +1782,19 @@ export class QURLClient {
|
|
|
3606
1782
|
requestId: meta.request_id,
|
|
3607
1783
|
});
|
|
3608
1784
|
}
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
});
|
|
3613
|
-
}
|
|
3614
|
-
else if (!Number.isSafeInteger(data.terminated) || data.terminated < 0) {
|
|
3615
|
-
throw unexpectedResponseError("terminateAllResourceSessions: invalid terminated count", {
|
|
3616
|
-
requestId: meta?.request_id,
|
|
3617
|
-
});
|
|
1785
|
+
const count = data?.terminated;
|
|
1786
|
+
if (typeof count !== "number" || !Number.isSafeInteger(count) || count < 0) {
|
|
1787
|
+
throw unexpectedResponseError("terminateAllResourceSessions: missing or invalid terminated count; inspect sessions before retrying", { requestId: meta?.request_id });
|
|
3618
1788
|
}
|
|
3619
1789
|
request_id = meta?.request_id;
|
|
3620
|
-
return { ...meta, counts: [
|
|
1790
|
+
return withEnvelopeMetadata({ ...meta, counts: [count] }, meta);
|
|
3621
1791
|
}, (page) => page.counts)) {
|
|
3622
1792
|
terminated += count;
|
|
1793
|
+
if (!Number.isSafeInteger(terminated)) {
|
|
1794
|
+
throw unexpectedResponseError("terminateAllResourceSessions: total exceeds safe integer range; inspect sessions before retrying", { requestId: request_id });
|
|
1795
|
+
}
|
|
3623
1796
|
}
|
|
3624
|
-
return { terminated, request_id };
|
|
1797
|
+
return withEnvelopeMetadata({ terminated, request_id }, { request_id });
|
|
3625
1798
|
}
|
|
3626
1799
|
/** Terminate a specific resource session. */
|
|
3627
1800
|
async terminateResourceSession(id, sessionId) {
|
|
@@ -3631,7 +1804,7 @@ export class QURLClient {
|
|
|
3631
1804
|
}
|
|
3632
1805
|
/** List connector installations. */
|
|
3633
1806
|
async listConnectorInstallations(input = {}) {
|
|
3634
|
-
const { data, meta } = await this.rawRequest("GET", appendQuery("/v1/connectors/installations", input, CONNECTOR_INSTALLATION_LIST_PARAM_KEYS, "listConnectorInstallations"));
|
|
1807
|
+
const { envelope: { data, meta }, } = await this.rawRequest("GET", appendQuery("/v1/connectors/installations", input, CONNECTOR_INSTALLATION_LIST_PARAM_KEYS, "listConnectorInstallations"));
|
|
3635
1808
|
return pageFromMeta({ installations: data ?? [] }, meta);
|
|
3636
1809
|
}
|
|
3637
1810
|
/**
|
|
@@ -3689,7 +1862,7 @@ export class QURLClient {
|
|
|
3689
1862
|
async listBillingInvoices(input = {}) {
|
|
3690
1863
|
// This endpoint nests the list in `data.invoices` unlike the other list
|
|
3691
1864
|
// endpoints, which return `data` as the array directly.
|
|
3692
|
-
const { data, meta } = await this.rawRequest("GET", appendQuery("/v1/billing/invoices", input, BILLING_INVOICE_LIST_PARAM_KEYS, "listBillingInvoices"));
|
|
1865
|
+
const { envelope: { data, meta }, } = await this.rawRequest("GET", appendQuery("/v1/billing/invoices", input, BILLING_INVOICE_LIST_PARAM_KEYS, "listBillingInvoices"));
|
|
3693
1866
|
return pageFromMeta({ invoices: data?.invoices ?? [] }, meta);
|
|
3694
1867
|
}
|
|
3695
1868
|
/**
|
|
@@ -3714,7 +1887,7 @@ export class QURLClient {
|
|
|
3714
1887
|
}
|
|
3715
1888
|
/** List custom domains. */
|
|
3716
1889
|
async listDomains(input = {}) {
|
|
3717
|
-
const { data, meta } = await this.rawRequest("GET", appendQuery("/v1/domains", input, DOMAIN_LIST_PARAM_KEYS, "listDomains"));
|
|
1890
|
+
const { envelope: { data, meta }, } = await this.rawRequest("GET", appendQuery("/v1/domains", input, DOMAIN_LIST_PARAM_KEYS, "listDomains"));
|
|
3718
1891
|
return pageFromMeta({ domains: data ?? [] }, meta);
|
|
3719
1892
|
}
|
|
3720
1893
|
/**
|
|
@@ -3749,7 +1922,7 @@ export class QURLClient {
|
|
|
3749
1922
|
}
|
|
3750
1923
|
/** List webhooks. */
|
|
3751
1924
|
async listWebhooks(input = {}) {
|
|
3752
|
-
const { data, meta } = await this.rawRequest("GET", appendQuery("/v1/webhooks", input, WEBHOOK_LIST_PARAM_KEYS, "listWebhooks"));
|
|
1925
|
+
const { envelope: { data, meta }, } = await this.rawRequest("GET", appendQuery("/v1/webhooks", input, WEBHOOK_LIST_PARAM_KEYS, "listWebhooks"));
|
|
3753
1926
|
return pageFromMeta({ webhooks: data ?? [] }, meta);
|
|
3754
1927
|
}
|
|
3755
1928
|
/**
|
|
@@ -3806,7 +1979,7 @@ export class QURLClient {
|
|
|
3806
1979
|
/** List delivery attempts for a webhook. */
|
|
3807
1980
|
async listWebhookDeliveries(id, input = {}) {
|
|
3808
1981
|
validatePathId(id, "listWebhookDeliveries");
|
|
3809
|
-
const { data, meta } = await this.rawRequest("GET", appendQuery(`/v1/webhooks/${encodeURIComponent(id)}/deliveries`, input, WEBHOOK_DELIVERY_LIST_PARAM_KEYS, "listWebhookDeliveries"));
|
|
1982
|
+
const { envelope: { data, meta }, } = await this.rawRequest("GET", appendQuery(`/v1/webhooks/${encodeURIComponent(id)}/deliveries`, input, WEBHOOK_DELIVERY_LIST_PARAM_KEYS, "listWebhookDeliveries"));
|
|
3810
1983
|
return pageFromMeta({ deliveries: data ?? [] }, meta);
|
|
3811
1984
|
}
|
|
3812
1985
|
/**
|
|
@@ -3820,6 +1993,87 @@ export class QURLClient {
|
|
|
3820
1993
|
validateListAllInput(input, WEBHOOK_DELIVERY_LIST_PARAM_KEYS, "listAllWebhookDeliveries");
|
|
3821
1994
|
return this.paginateAll("listAllWebhookDeliveries", input, (pageInput) => this.listWebhookDeliveries(id, pageInput), (page) => page.deliveries);
|
|
3822
1995
|
}
|
|
1996
|
+
/**
|
|
1997
|
+
* Bind an external identity using the provider's trusted OAuth JWT as the
|
|
1998
|
+
* client's bearer credential. Persist the request key before calling, then
|
|
1999
|
+
* securely store the returned one-time plaintext key without logging it.
|
|
2000
|
+
* Retry a lost response within 24 hours with the same key and body.
|
|
2001
|
+
*/
|
|
2002
|
+
async createExternalIdentityBinding(input, options) {
|
|
2003
|
+
const method = "createExternalIdentityBinding";
|
|
2004
|
+
requireObjectInput(input, method);
|
|
2005
|
+
requireNoUnknownFields(input, ["provider", "external_id", "display_name", "rotate_existing"], method);
|
|
2006
|
+
if (!["slack", "discord", "teams"].includes(input.provider)) {
|
|
2007
|
+
throw clientValidationError(`${method}: provider must be slack, discord, or teams`);
|
|
2008
|
+
}
|
|
2009
|
+
if (typeof input.external_id !== "string" ||
|
|
2010
|
+
input.external_id.length > 256 ||
|
|
2011
|
+
!/^[\x21-\x22\x24-\x7e]+$/.test(input.external_id)) {
|
|
2012
|
+
throw clientValidationError(`${method}: external_id must contain 1-256 visible ASCII characters without #`);
|
|
2013
|
+
}
|
|
2014
|
+
if (input.display_name !== undefined &&
|
|
2015
|
+
(typeof input.display_name !== "string" ||
|
|
2016
|
+
[...input.display_name].length > 100 ||
|
|
2017
|
+
/[\uD800-\uDFFF]/u.test(input.display_name))) {
|
|
2018
|
+
throw clientValidationError(`${method}: display_name must contain at most 100 Unicode characters`);
|
|
2019
|
+
}
|
|
2020
|
+
requireBooleanIfPresent(input.rotate_existing, `${method}.rotate_existing`);
|
|
2021
|
+
validateRequestOptions(options);
|
|
2022
|
+
if (!options?.idempotencyKey || options.idempotencyKey.length < 32) {
|
|
2023
|
+
throw clientValidationError(`${method}: a persisted idempotencyKey of 32-256 characters is required`);
|
|
2024
|
+
}
|
|
2025
|
+
const body = {
|
|
2026
|
+
provider: input.provider,
|
|
2027
|
+
external_id: input.external_id,
|
|
2028
|
+
...(input.display_name !== undefined ? { display_name: input.display_name } : {}),
|
|
2029
|
+
...(input.rotate_existing !== undefined ? { rotate_existing: input.rotate_existing } : {}),
|
|
2030
|
+
};
|
|
2031
|
+
const raw = await this.rawRequest("POST", "/v1/external-identity-bindings", body, {
|
|
2032
|
+
requestOptions: options,
|
|
2033
|
+
generateIdempotencyKey: false,
|
|
2034
|
+
});
|
|
2035
|
+
// This operation returns a flat document, unlike the data/meta endpoints.
|
|
2036
|
+
const result = raw.envelope;
|
|
2037
|
+
const expectedScopes = body.provider === "discord"
|
|
2038
|
+
? ["qurl:read", "qurl:write"]
|
|
2039
|
+
: ["qurl:read", "qurl:write", "qurl:agent"];
|
|
2040
|
+
if (raw.status !== 201 ||
|
|
2041
|
+
typeof result.binding_id !== "string" ||
|
|
2042
|
+
!/^eib_[A-Za-z0-9]{11}$/.test(result.binding_id) ||
|
|
2043
|
+
result.provider !== body.provider ||
|
|
2044
|
+
result.external_id !== body.external_id ||
|
|
2045
|
+
!result.api_key ||
|
|
2046
|
+
typeof result.api_key !== "object" ||
|
|
2047
|
+
Array.isArray(result.api_key) ||
|
|
2048
|
+
typeof result.api_key.key_id !== "string" ||
|
|
2049
|
+
!/^key_[A-Za-z0-9]{12}$/.test(result.api_key.key_id) ||
|
|
2050
|
+
typeof result.api_key.plaintext !== "string" ||
|
|
2051
|
+
!/^lv_(?:live|test)_[A-Za-z0-9_-]{43}$/.test(result.api_key.plaintext) ||
|
|
2052
|
+
result.api_key.key_prefix !== result.api_key.plaintext.slice(0, 12) ||
|
|
2053
|
+
!Array.isArray(result.scopes) ||
|
|
2054
|
+
result.scopes.length !== expectedScopes.length ||
|
|
2055
|
+
!expectedScopes.every((scope) => result.scopes.includes(scope)) ||
|
|
2056
|
+
!isUtcTimestamp(result.created_at) ||
|
|
2057
|
+
(result.display_name !== undefined &&
|
|
2058
|
+
(typeof result.display_name !== "string" ||
|
|
2059
|
+
[...result.display_name].length > 100 ||
|
|
2060
|
+
/[\uD800-\uDFFF]/u.test(result.display_name)))) {
|
|
2061
|
+
throw unexpectedResponseError(`${method}: invalid binding response; reconcile with the same request key before retrying`);
|
|
2062
|
+
}
|
|
2063
|
+
return withEnvelopeMetadata({
|
|
2064
|
+
binding_id: result.binding_id,
|
|
2065
|
+
provider: result.provider,
|
|
2066
|
+
external_id: result.external_id,
|
|
2067
|
+
...(result.display_name !== undefined ? { display_name: result.display_name } : {}),
|
|
2068
|
+
api_key: {
|
|
2069
|
+
key_id: result.api_key.key_id,
|
|
2070
|
+
key_prefix: result.api_key.key_prefix,
|
|
2071
|
+
plaintext: result.api_key.plaintext,
|
|
2072
|
+
},
|
|
2073
|
+
scopes: result.scopes,
|
|
2074
|
+
created_at: result.created_at,
|
|
2075
|
+
}, raw.envelope.meta);
|
|
2076
|
+
}
|
|
3823
2077
|
/** Create a new API key. */
|
|
3824
2078
|
async createApiKey(input, options) {
|
|
3825
2079
|
requireObjectInput(input, "createApiKey");
|
|
@@ -3858,7 +2112,7 @@ export class QURLClient {
|
|
|
3858
2112
|
}
|
|
3859
2113
|
/** List API keys. */
|
|
3860
2114
|
async listApiKeys(input = {}) {
|
|
3861
|
-
const { data, meta } = await this.rawRequest("GET", appendQuery("/v1/api-keys", input, API_KEY_LIST_PARAM_KEYS, "listApiKeys"));
|
|
2115
|
+
const { envelope: { data, meta }, } = await this.rawRequest("GET", appendQuery("/v1/api-keys", input, API_KEY_LIST_PARAM_KEYS, "listApiKeys"));
|
|
3862
2116
|
return pageFromMeta({ api_keys: data ?? [] }, meta);
|
|
3863
2117
|
}
|
|
3864
2118
|
/**
|
|
@@ -3912,19 +2166,19 @@ export class QURLClient {
|
|
|
3912
2166
|
* this page and emits a debug log rather than pretending it fetched all pages.
|
|
3913
2167
|
*/
|
|
3914
2168
|
async listAccessCodes() {
|
|
3915
|
-
const { data, meta } = await this.rawRequest("GET", "/v1/access-codes");
|
|
2169
|
+
const { envelope: { data, meta }, } = await this.rawRequest("GET", "/v1/access-codes");
|
|
3916
2170
|
if (meta?.has_more || meta?.next_cursor) {
|
|
3917
2171
|
this.log("listAccessCodes: pagination metadata surfaced on unpaginated endpoint", {
|
|
3918
2172
|
has_more: meta.has_more,
|
|
3919
2173
|
next_cursor: meta.next_cursor,
|
|
3920
2174
|
});
|
|
3921
2175
|
}
|
|
3922
|
-
return {
|
|
2176
|
+
return withEnvelopeMetadata({
|
|
3923
2177
|
access_codes: data ?? [],
|
|
3924
2178
|
request_id: meta?.request_id,
|
|
3925
2179
|
has_more: false,
|
|
3926
2180
|
page_size: meta?.page_size,
|
|
3927
|
-
};
|
|
2181
|
+
}, meta);
|
|
3928
2182
|
}
|
|
3929
2183
|
/** Revoke an access code. */
|
|
3930
2184
|
async revokeAccessCode(id) {
|
|
@@ -3935,13 +2189,13 @@ export class QURLClient {
|
|
|
3935
2189
|
async request(method, path, body, requestOptions) {
|
|
3936
2190
|
// List methods call rawRequest directly so 204 can degrade to an empty page.
|
|
3937
2191
|
// Body-returning endpoints declare 200 JSON today, so this path fails closed.
|
|
3938
|
-
const { data,
|
|
2192
|
+
const { envelope: { data, meta }, status: httpStatus, } = await this.rawRequest(method, path, body, {
|
|
3939
2193
|
requestOptions,
|
|
3940
2194
|
});
|
|
3941
|
-
if (
|
|
2195
|
+
if (httpStatus === 204) {
|
|
3942
2196
|
throw unexpectedResponseError(`Unexpected 204 No Content from ${method} ${path}; expected response body`);
|
|
3943
2197
|
}
|
|
3944
|
-
return data;
|
|
2198
|
+
return withEnvelopeMetadata(data, meta);
|
|
3945
2199
|
}
|
|
3946
2200
|
/**
|
|
3947
2201
|
* Require the service's exact 204 response with no response bytes exposed by
|
|
@@ -3952,9 +2206,9 @@ export class QURLClient {
|
|
|
3952
2206
|
const response = await this.rawRequest("DELETE", path, undefined, {
|
|
3953
2207
|
allowEmptySuccessBody: true,
|
|
3954
2208
|
});
|
|
3955
|
-
if (response.
|
|
3956
|
-
const status = response.
|
|
3957
|
-
const bodyShape = response.
|
|
2209
|
+
if (response.status !== 204 || response.bodyEmpty !== true) {
|
|
2210
|
+
const status = response.status ?? 0;
|
|
2211
|
+
const bodyShape = response.bodyEmpty ? "with an empty body" : "with response bytes";
|
|
3958
2212
|
throw unexpectedResponseError(`Unexpected response from DELETE; expected empty HTTP 204, received HTTP ${status} ${bodyShape}. ` +
|
|
3959
2213
|
"The delete may already have been applied; reconcile resource state before retrying.", { status });
|
|
3960
2214
|
}
|
|
@@ -4023,7 +2277,8 @@ export class QURLClient {
|
|
|
4023
2277
|
// request bound: roughly `timeout * (maxRetries + 1) +
|
|
4024
2278
|
// sum(retryDelay)`.
|
|
4025
2279
|
onDispatch?.();
|
|
4026
|
-
|
|
2280
|
+
// Native edge fetch requires the global receiver, not this client.
|
|
2281
|
+
response = await this.fetchFn.call(globalThis, url, {
|
|
4027
2282
|
method,
|
|
4028
2283
|
headers,
|
|
4029
2284
|
body: serializedBody,
|
|
@@ -4071,14 +2326,14 @@ export class QURLClient {
|
|
|
4071
2326
|
}
|
|
4072
2327
|
let responseBody;
|
|
4073
2328
|
try {
|
|
4074
|
-
responseBody = await readBoundedResponseBody(response);
|
|
2329
|
+
responseBody = await readBoundedResponseBody(response, this.maxResponseBodyBytes);
|
|
4075
2330
|
}
|
|
4076
2331
|
catch (err) {
|
|
4077
2332
|
if (err instanceof ResponseBodyTooLargeError) {
|
|
4078
2333
|
this.log(`rejected oversized response body from ${response.status}`, {
|
|
4079
2334
|
status: response.status,
|
|
4080
2335
|
});
|
|
4081
|
-
const detail = `Response body exceeds ${
|
|
2336
|
+
const detail = `Response body exceeds ${this.maxResponseBodyBytes}-byte limit on HTTP ${response.status}; ` +
|
|
4082
2337
|
"request a smaller page when listing collections";
|
|
4083
2338
|
if (response.ok) {
|
|
4084
2339
|
throw unexpectedResponseError(detail, { status: response.status });
|
|
@@ -4195,10 +2450,10 @@ export class QURLClient {
|
|
|
4195
2450
|
});
|
|
4196
2451
|
}
|
|
4197
2452
|
return {
|
|
4198
|
-
data: undefined,
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
2453
|
+
envelope: { data: undefined },
|
|
2454
|
+
status: response.status,
|
|
2455
|
+
bodyEmpty: responseBody.length === 0,
|
|
2456
|
+
headers: captureResponseHeaders ? response.headers : undefined,
|
|
4202
2457
|
};
|
|
4203
2458
|
}
|
|
4204
2459
|
// Exact no-content helpers must inspect both axes of the success
|
|
@@ -4208,20 +2463,20 @@ export class QURLClient {
|
|
|
4208
2463
|
(isPassthrough && allowEmptyPassthroughBody)) &&
|
|
4209
2464
|
responseBody.length === 0) {
|
|
4210
2465
|
return {
|
|
4211
|
-
data: undefined,
|
|
4212
|
-
|
|
4213
|
-
|
|
4214
|
-
|
|
2466
|
+
envelope: { data: undefined },
|
|
2467
|
+
status: response.status,
|
|
2468
|
+
bodyEmpty: true,
|
|
2469
|
+
headers: captureResponseHeaders ? response.headers : undefined,
|
|
4215
2470
|
};
|
|
4216
2471
|
}
|
|
4217
2472
|
try {
|
|
4218
2473
|
const json = JSON.parse(responseBody);
|
|
4219
2474
|
return {
|
|
4220
|
-
...json,
|
|
4221
|
-
|
|
2475
|
+
envelope: { ...json },
|
|
2476
|
+
status: response.status,
|
|
4222
2477
|
// JSON.parse("") throws, so parsed JSON is necessarily non-empty.
|
|
4223
|
-
|
|
4224
|
-
|
|
2478
|
+
bodyEmpty: false,
|
|
2479
|
+
headers: captureResponseHeaders ? response.headers : undefined,
|
|
4225
2480
|
};
|
|
4226
2481
|
}
|
|
4227
2482
|
catch {
|
|
@@ -4289,6 +2544,7 @@ export class QURLClient {
|
|
|
4289
2544
|
instance: boundedErrorIdentifier(err.instance),
|
|
4290
2545
|
invalid_fields: boundedInvalidFields(err.invalid_fields),
|
|
4291
2546
|
request_id: boundedErrorIdentifier(json.meta?.request_id),
|
|
2547
|
+
tombstone: response.status === 410 ? boundedTombstone(json.meta?.tombstone) : undefined,
|
|
4292
2548
|
retry_after: this.parseRetryAfter(response),
|
|
4293
2549
|
};
|
|
4294
2550
|
}
|
|
@@ -4335,8 +2591,8 @@ export class QURLClient {
|
|
|
4335
2591
|
const trimmed = header.trim();
|
|
4336
2592
|
if (/^[A-Z][a-z]{2}, \d{2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} GMT$/.test(trimmed)) {
|
|
4337
2593
|
const seconds = Math.ceil((Date.parse(trimmed) - Date.now()) / 1000);
|
|
4338
|
-
return Number.isSafeInteger(seconds)
|
|
4339
|
-
? seconds
|
|
2594
|
+
return Number.isSafeInteger(seconds)
|
|
2595
|
+
? Math.min(Math.max(0, seconds), RETRY_AFTER_PARSE_LIMIT_S)
|
|
4340
2596
|
: undefined;
|
|
4341
2597
|
}
|
|
4342
2598
|
if (!/^\d+$/.test(trimmed)) {
|
|
@@ -4420,92 +2676,7 @@ export class QURLClient {
|
|
|
4420
2676
|
return new NetworkError(`Network failure while reading response body after HTTP ${response.status}`, { cause: err });
|
|
4421
2677
|
}
|
|
4422
2678
|
}
|
|
4423
|
-
|
|
4424
|
-
|
|
4425
|
-
|
|
4426
|
-
|
|
4427
|
-
* not constructed directly. Mint short-lived access links for the resource
|
|
4428
|
-
* with {@link createPortal} (qurl-go: `qurl.Resource`).
|
|
4429
|
-
*/
|
|
4430
|
-
export class ProtectedResource {
|
|
4431
|
-
// A true private field (not a module-scope WeakMap registry, which
|
|
4432
|
-
// CONTRIBUTING.md's dual-build rule bars) so the binding stays on the
|
|
4433
|
-
// instance and out of Object.keys/JSON.stringify output.
|
|
4434
|
-
#client;
|
|
4435
|
-
/** The public resource CRID. */
|
|
4436
|
-
crid;
|
|
4437
|
-
/** The private URL protected by this resource, when known. */
|
|
4438
|
-
targetUrl;
|
|
4439
|
-
/**
|
|
4440
|
-
* Full server-populated {@link Resource} metadata when the handle came
|
|
4441
|
-
* from an API response; `undefined` for {@link QURLClient.resourceByCrid}
|
|
4442
|
-
* handles and for the one-call {@link QURLClient.createPortalForUrl} path.
|
|
4443
|
-
*/
|
|
4444
|
-
details;
|
|
4445
|
-
constructor(client, id, targetUrl, details) {
|
|
4446
|
-
this.#client = client;
|
|
4447
|
-
this.crid = id;
|
|
4448
|
-
this.targetUrl = targetUrl;
|
|
4449
|
-
this.details = details;
|
|
4450
|
-
}
|
|
4451
|
-
/**
|
|
4452
|
-
* Whether `resource` was produced by `client`. A static because the
|
|
4453
|
-
* binding lives in a private field that only this class can read; used
|
|
4454
|
-
* by {@link QURLClient.createPortal} to refuse handles bound to a
|
|
4455
|
-
* different client.
|
|
4456
|
-
*/
|
|
4457
|
-
static isBoundTo(resource, client) {
|
|
4458
|
-
return resource.#client === client;
|
|
4459
|
-
}
|
|
4460
|
-
/**
|
|
4461
|
-
* Mint a short-lived qURL link (a portal) for this resource. See
|
|
4462
|
-
* {@link QURLClient.createPortal} for the option reference.
|
|
4463
|
-
*/
|
|
4464
|
-
async createPortal(opts = {}, requestOptions) {
|
|
4465
|
-
return this.#client.createPortal(this, opts, requestOptions);
|
|
4466
|
-
}
|
|
4467
|
-
}
|
|
4468
|
-
const CONNECTOR_RESOURCE_CONSTRUCTOR_TOKEN = Symbol("validated ConnectorResource");
|
|
4469
|
-
/**
|
|
4470
|
-
* A validated qURL Connector resource bound to the client that loaded it.
|
|
4471
|
-
*
|
|
4472
|
-
* Resource identity, reverse-routing identity, and NHP admission identity are
|
|
4473
|
-
* distinct server-issued values. Callers must consume each field verbatim and
|
|
4474
|
-
* must never derive one from another.
|
|
4475
|
-
*/
|
|
4476
|
-
export class ConnectorResource {
|
|
4477
|
-
#client;
|
|
4478
|
-
resourcePublicKey;
|
|
4479
|
-
/** Required public identifier, verified against the returned resource key. */
|
|
4480
|
-
crid;
|
|
4481
|
-
connectorRoutingId;
|
|
4482
|
-
knockResourceId;
|
|
4483
|
-
slug;
|
|
4484
|
-
alias;
|
|
4485
|
-
/** @internal Instances are returned by QURLClient after wire validation. */
|
|
4486
|
-
constructor(client, details, validationToken) {
|
|
4487
|
-
if (validationToken !== CONNECTOR_RESOURCE_CONSTRUCTOR_TOKEN) {
|
|
4488
|
-
throw clientValidationError("ConnectorResource cannot be constructed directly; load it through QURLClient");
|
|
4489
|
-
}
|
|
4490
|
-
this.#client = client;
|
|
4491
|
-
this.resourcePublicKey = details.resource_id;
|
|
4492
|
-
this.crid = details.crid;
|
|
4493
|
-
this.connectorRoutingId = details.connector_routing_id;
|
|
4494
|
-
this.knockResourceId = details.knock_resource_id;
|
|
4495
|
-
this.slug = details.slug;
|
|
4496
|
-
this.alias = details.alias ?? undefined;
|
|
4497
|
-
}
|
|
4498
|
-
/** Mint a short-lived portal for this Connector resource. */
|
|
4499
|
-
async createPortal(opts = {}, requestOptions) {
|
|
4500
|
-
return this.#client.createPortal(this.crid, opts, requestOptions);
|
|
4501
|
-
}
|
|
4502
|
-
}
|
|
4503
|
-
function sameConsumerPolicy(actual, expected, foundExisting) {
|
|
4504
|
-
return (actual?.mode === expected.mode &&
|
|
4505
|
-
(actual?.version === expected.version ||
|
|
4506
|
-
(foundExisting &&
|
|
4507
|
-
Number.isSafeInteger(actual?.version) &&
|
|
4508
|
-
Number(actual?.version) > expected.version)) &&
|
|
4509
|
-
actual?.contract_id === expected.contract_id);
|
|
4510
|
-
}
|
|
4511
|
-
//# sourceMappingURL=client.js.map
|
|
2679
|
+
export { isApiKeyRequestScope } from "./client-management.js";
|
|
2680
|
+
export { SIGNED_FRAGMENT_RE } from "./client-portals.js";
|
|
2681
|
+
export { ProtectedResource } from "./client-portals.js";
|
|
2682
|
+
export { ConnectorResource } from "./client-connectors.js";
|