@layervai/qurl 0.6.0 → 2.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 +332 -15
- package/dist/cjs/client.d.ts +62 -31
- package/dist/cjs/client.js +932 -163
- package/dist/cjs/crid.d.ts +3 -0
- package/dist/cjs/crid.js +62 -0
- package/dist/cjs/errors.d.ts +30 -5
- package/dist/cjs/errors.js +41 -6
- package/dist/cjs/index.d.ts +5 -3
- package/dist/cjs/index.js +11 -1
- package/dist/cjs/node/agent-runtime.d.ts +108 -0
- package/dist/cjs/node/agent-runtime.js +952 -0
- package/dist/cjs/node/agent-state.d.ts +100 -0
- package/dist/cjs/node/agent-state.js +311 -0
- package/dist/cjs/node/agent-transport.d.ts +17 -0
- package/dist/cjs/node/agent-transport.js +118 -0
- package/dist/cjs/node/deployment.d.ts +6 -0
- package/dist/cjs/node/deployment.js +24 -3
- package/dist/cjs/node/file-agent-state.d.ts +15 -0
- package/dist/cjs/node/file-agent-state.js +137 -0
- package/dist/cjs/node/native-loader.cjs +7 -0
- package/dist/cjs/node/native-loader.d.cts +11 -0
- package/dist/cjs/node/native-udp.d.ts +8 -3
- package/dist/cjs/node/native-udp.js +8 -3
- package/dist/cjs/node/nhp-wire.d.ts +9 -0
- package/dist/cjs/node/nhp-wire.js +24 -7
- package/dist/cjs/node/portal-opener.d.ts +7 -0
- package/dist/cjs/node/portal-opener.js +28 -6
- package/dist/cjs/node/provider.d.ts +19 -0
- package/dist/cjs/node/provider.js +153 -0
- package/dist/cjs/node/qv2.d.ts +3 -1
- package/dist/cjs/node/qv2.js +16 -2
- package/dist/cjs/node/relay.d.ts +12 -0
- package/dist/cjs/node/relay.js +111 -0
- package/dist/cjs/node/sealed-agent-state.d.ts +19 -0
- package/dist/cjs/node/sealed-agent-state.js +193 -0
- package/dist/cjs/node/strict-json.d.ts +1 -1
- package/dist/cjs/node/strict-json.js +8 -3
- package/dist/cjs/node.d.ts +14 -0
- package/dist/cjs/node.js +24 -1
- package/dist/cjs/share.d.ts +56 -0
- package/dist/cjs/share.js +113 -0
- package/dist/cjs/types.d.ts +173 -13
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/client.d.ts +62 -31
- package/dist/esm/client.d.ts.map +1 -1
- package/dist/esm/client.js +932 -164
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/crid.d.ts +4 -0
- package/dist/esm/crid.d.ts.map +1 -0
- package/dist/esm/crid.js +58 -0
- package/dist/esm/crid.js.map +1 -0
- package/dist/esm/errors.d.ts +30 -5
- package/dist/esm/errors.d.ts.map +1 -1
- package/dist/esm/errors.js +39 -5
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/index.d.ts +5 -3
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +3 -2
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/node/agent-runtime.d.ts +109 -0
- package/dist/esm/node/agent-runtime.d.ts.map +1 -0
- package/dist/esm/node/agent-runtime.js +946 -0
- package/dist/esm/node/agent-runtime.js.map +1 -0
- package/dist/esm/node/agent-state.d.ts +101 -0
- package/dist/esm/node/agent-state.d.ts.map +1 -0
- package/dist/esm/node/agent-state.js +298 -0
- package/dist/esm/node/agent-state.js.map +1 -0
- package/dist/esm/node/agent-transport.d.ts +18 -0
- package/dist/esm/node/agent-transport.d.ts.map +1 -0
- package/dist/esm/node/agent-transport.js +114 -0
- package/dist/esm/node/agent-transport.js.map +1 -0
- package/dist/esm/node/deployment.d.ts +6 -0
- package/dist/esm/node/deployment.d.ts.map +1 -1
- package/dist/esm/node/deployment.js +23 -3
- package/dist/esm/node/deployment.js.map +1 -1
- package/dist/esm/node/file-agent-state.d.ts +16 -0
- package/dist/esm/node/file-agent-state.d.ts.map +1 -0
- package/dist/esm/node/file-agent-state.js +134 -0
- package/dist/esm/node/file-agent-state.js.map +1 -0
- package/dist/esm/node/native-loader.cjs +7 -0
- package/dist/esm/node/native-loader.d.cts +11 -0
- package/dist/esm/node/native-udp.d.ts +8 -3
- package/dist/esm/node/native-udp.d.ts.map +1 -1
- package/dist/esm/node/native-udp.js +7 -5
- package/dist/esm/node/native-udp.js.map +1 -1
- package/dist/esm/node/nhp-wire.d.ts +9 -0
- package/dist/esm/node/nhp-wire.d.ts.map +1 -1
- package/dist/esm/node/nhp-wire.js +23 -6
- package/dist/esm/node/nhp-wire.js.map +1 -1
- package/dist/esm/node/portal-opener.d.ts +7 -0
- package/dist/esm/node/portal-opener.d.ts.map +1 -1
- package/dist/esm/node/portal-opener.js +28 -6
- package/dist/esm/node/portal-opener.js.map +1 -1
- package/dist/esm/node/provider.d.ts +20 -0
- package/dist/esm/node/provider.d.ts.map +1 -0
- package/dist/esm/node/provider.js +149 -0
- package/dist/esm/node/provider.js.map +1 -0
- package/dist/esm/node/qv2.d.ts +3 -1
- package/dist/esm/node/qv2.d.ts.map +1 -1
- package/dist/esm/node/qv2.js +17 -3
- package/dist/esm/node/qv2.js.map +1 -1
- package/dist/esm/node/relay.d.ts +13 -0
- package/dist/esm/node/relay.d.ts.map +1 -0
- package/dist/esm/node/relay.js +105 -0
- package/dist/esm/node/relay.js.map +1 -0
- package/dist/esm/node/sealed-agent-state.d.ts +20 -0
- package/dist/esm/node/sealed-agent-state.d.ts.map +1 -0
- package/dist/esm/node/sealed-agent-state.js +190 -0
- package/dist/esm/node/sealed-agent-state.js.map +1 -0
- package/dist/esm/node/strict-json.d.ts +1 -1
- package/dist/esm/node/strict-json.d.ts.map +1 -1
- package/dist/esm/node/strict-json.js +8 -3
- package/dist/esm/node/strict-json.js.map +1 -1
- package/dist/esm/node.d.ts +14 -0
- package/dist/esm/node.d.ts.map +1 -1
- package/dist/esm/node.js +7 -0
- package/dist/esm/node.js.map +1 -1
- package/dist/esm/share.d.ts +57 -0
- package/dist/esm/share.d.ts.map +1 -0
- package/dist/esm/share.js +109 -0
- package/dist/esm/share.js.map +1 -0
- package/dist/esm/types.d.ts +173 -13
- package/dist/esm/types.d.ts.map +1 -1
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +17 -8
package/dist/esm/client.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { parseCrid, cridKeyMatches } from "./crid.js";
|
|
2
|
+
import { ConnectorResourceOutcomeUnknownError, createError, DelegatedBatchOutcomeUnknownError, ERROR_CODE_AMBIGUOUS_RESOURCE, ERROR_CODE_CLIENT_VALIDATION, ERROR_CODE_RESOURCE_NOT_FOUND, ERROR_CODE_CONNECTOR_RESOURCE_REVOKED, ERROR_CODE_UNEXPECTED_RESPONSE, ERROR_CODE_UNKNOWN, NetworkError, NotFoundError, QURLError, RuntimeError, TimeoutError, ValidationError, } from "./errors.js";
|
|
2
3
|
import { stripTrailingSlashes } from "./internal.js";
|
|
4
|
+
import { ShareLink } from "./share.js";
|
|
3
5
|
import { VERSION } from "./version.js";
|
|
4
6
|
const DEFAULT_BASE_URL = "https://api.layerv.ai";
|
|
5
7
|
const DEFAULT_MAX_RETRIES = 3;
|
|
@@ -71,6 +73,7 @@ class ResponseBodyMaterializationError extends Error {
|
|
|
71
73
|
}
|
|
72
74
|
const NO_PASSTHROUGH_STATUSES = [];
|
|
73
75
|
const BATCH_PASSTHROUGH_STATUSES = [400];
|
|
76
|
+
const DELEGATED_BATCH_PASSTHROUGH_STATUSES = [304];
|
|
74
77
|
/** Allowlist of known query-param keys for the `list()` endpoint. */
|
|
75
78
|
const LIST_PARAM_KEYS = [
|
|
76
79
|
"limit",
|
|
@@ -84,6 +87,24 @@ const LIST_PARAM_KEYS = [
|
|
|
84
87
|
"expires_after",
|
|
85
88
|
];
|
|
86
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
|
+
];
|
|
87
108
|
// Compile-time witness: `Exclude<keyof X, (typeof KEYS)[number]>` is
|
|
88
109
|
// `never` iff KEYS lists every key of X. Paired with the
|
|
89
110
|
// `satisfies readonly (keyof X)[]` clause on the array (which catches
|
|
@@ -94,6 +115,10 @@ function assertExhaustive(_) {
|
|
|
94
115
|
}
|
|
95
116
|
assertExhaustive(true);
|
|
96
117
|
assertExhaustive(true);
|
|
118
|
+
assertExhaustive(true);
|
|
119
|
+
assertExhaustive(true);
|
|
120
|
+
assertExhaustive(true);
|
|
121
|
+
assertExhaustive(true);
|
|
97
122
|
const CREATE_FIELD_KEYS = [
|
|
98
123
|
"type",
|
|
99
124
|
"target_url",
|
|
@@ -173,6 +198,10 @@ const CREATE_QURL_FOR_RESOURCE_FIELD_KEYS = [
|
|
|
173
198
|
"access_policy",
|
|
174
199
|
"target_path",
|
|
175
200
|
];
|
|
201
|
+
const SHARE_RESOURCE_OPTION_KEYS = [
|
|
202
|
+
"ttlSeconds",
|
|
203
|
+
];
|
|
204
|
+
assertExhaustive(true);
|
|
176
205
|
assertExhaustive(true);
|
|
177
206
|
/**
|
|
178
207
|
* Options accepted by the portal-verb `protectUrl` / `createPortal`
|
|
@@ -231,11 +260,12 @@ const UPDATE_WEBHOOK_FIELD_KEYS = [
|
|
|
231
260
|
];
|
|
232
261
|
assertExhaustive(true);
|
|
233
262
|
const CREATE_API_KEY_FIELD_KEYS = [
|
|
263
|
+
"kind",
|
|
234
264
|
"name",
|
|
235
265
|
"scopes",
|
|
266
|
+
"target",
|
|
267
|
+
"claims",
|
|
236
268
|
"expires_in",
|
|
237
|
-
"purpose",
|
|
238
|
-
"tunnel_slug",
|
|
239
269
|
];
|
|
240
270
|
assertExhaustive(true);
|
|
241
271
|
const UPDATE_API_KEY_FIELD_KEYS = [
|
|
@@ -715,12 +745,55 @@ const MAX_DESCRIPTION = 500;
|
|
|
715
745
|
const MAX_CUSTOM_DOMAIN = 253;
|
|
716
746
|
const MAX_MAX_SESSIONS = 1000;
|
|
717
747
|
const MAX_API_KEY_NAME = 100;
|
|
748
|
+
const API_KEY_REQUEST_SCOPE_VALUES = [
|
|
749
|
+
"qurl:read",
|
|
750
|
+
"qurl:write",
|
|
751
|
+
"qurl:resolve",
|
|
752
|
+
"qurl:agent",
|
|
753
|
+
];
|
|
754
|
+
assertExhaustive(true);
|
|
755
|
+
const API_KEY_REQUEST_SCOPES = new Set(API_KEY_REQUEST_SCOPE_VALUES);
|
|
756
|
+
/** Narrow a response scope before reusing it in a create/update request. */
|
|
757
|
+
export function isApiKeyRequestScope(scope) {
|
|
758
|
+
return API_KEY_REQUEST_SCOPES.has(scope);
|
|
759
|
+
}
|
|
760
|
+
const CREDENTIAL_TARGET_VALUES = [
|
|
761
|
+
"agent",
|
|
762
|
+
"connector",
|
|
763
|
+
];
|
|
764
|
+
assertExhaustive(true);
|
|
765
|
+
const CREDENTIAL_TARGETS = new Set(CREDENTIAL_TARGET_VALUES);
|
|
766
|
+
const CREDENTIAL_CLAIM_TYPE_VALUES = [
|
|
767
|
+
"connector",
|
|
768
|
+
];
|
|
769
|
+
assertExhaustive(true);
|
|
770
|
+
const CREDENTIAL_CLAIM_TYPES = new Set(CREDENTIAL_CLAIM_TYPE_VALUES);
|
|
771
|
+
// Mirrors CreateApiKeyRequest.expires_in in the service OpenAPI. Composite
|
|
772
|
+
// durations are deliberately invalid on this endpoint. The 24h cap permits
|
|
773
|
+
// 1d, but no positive whole-week duration.
|
|
774
|
+
const CREDENTIAL_DURATION_PATTERN = /^([0-9]+)([smhdw])$/;
|
|
775
|
+
const CREDENTIAL_DURATION_MULTIPLIERS = {
|
|
776
|
+
s: 1n,
|
|
777
|
+
m: 60n,
|
|
778
|
+
h: 3600n,
|
|
779
|
+
d: 86400n,
|
|
780
|
+
w: 604800n,
|
|
781
|
+
};
|
|
782
|
+
const MAX_ENROLLMENT_TOKEN_TTL_SECONDS = 86400n;
|
|
718
783
|
const MAX_ALIAS = 64;
|
|
719
784
|
const MAX_SLUG = 64;
|
|
720
785
|
const MAX_TARGET_PATH = 2048;
|
|
721
786
|
const MAX_TAGS = 10;
|
|
722
787
|
const MAX_TAG_LENGTH = 50;
|
|
723
788
|
const MAX_AUTO_PAGINATION_PAGES = 10_000;
|
|
789
|
+
const MIN_DELEGATED_BATCH_IDEMPOTENCY_KEY = 32;
|
|
790
|
+
const MAX_DELEGATED_MINT_CAPABILITY = 8192;
|
|
791
|
+
const MAX_DELEGATED_ETAG = 96;
|
|
792
|
+
const MAX_DELEGATED_BATCH_ITEMS = 100;
|
|
793
|
+
const DELEGATED_BATCH_ID_PATTERN = /^dqb_[A-Za-z0-9_-]{22}$/;
|
|
794
|
+
const DELEGATED_QURL_ID_PATTERN = /^q_[0-9a-f]{11}$/;
|
|
795
|
+
const STRONG_ETAG_PATTERN = /^"[\x21\x23-\x7e]+"$/;
|
|
796
|
+
const UTC_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
|
|
724
797
|
// Keep these identity contracts aligned with qurl-go and the public API.
|
|
725
798
|
const CONNECTOR_SLUG_PATTERN = /^[a-z][a-z0-9-]{1,62}[a-z0-9]$/;
|
|
726
799
|
// Current service schemas intentionally share this grammar, but keep the
|
|
@@ -732,7 +805,6 @@ const CONNECTOR_ROUTING_ID_PATTERN = /^c-[a-z2-7]{51}[aq]$/;
|
|
|
732
805
|
// CreateQurlRequest.target_url pattern is loose (just a URI) but
|
|
733
806
|
// UpdateQurlRequest.tags pattern is specific — enforce it here.
|
|
734
807
|
const TAG_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9 _-]*$/;
|
|
735
|
-
const RESOURCE_ID_PREFIX = "r_";
|
|
736
808
|
function requireMaxLength(value, field, max) {
|
|
737
809
|
if (value === undefined)
|
|
738
810
|
return;
|
|
@@ -833,22 +905,129 @@ function pageFromMeta(out, meta) {
|
|
|
833
905
|
page_size: meta?.page_size,
|
|
834
906
|
};
|
|
835
907
|
}
|
|
908
|
+
// Best-effort defence-in-depth against today's access-token grammar. The
|
|
909
|
+
// service remains authoritative because resource IDs and credentials are both
|
|
910
|
+
// opaque and future credential formats may not retain the `at_` prefix. The
|
|
911
|
+
// delimiter requirement cannot collide with current base64url public keys,
|
|
912
|
+
// standard-base64 keys, or base32 CRIDs.
|
|
913
|
+
const PATH_EMBEDDED_ACCESS_TOKEN_RE = /[?&#=:\s]at_/i;
|
|
914
|
+
const PATH_PREFIXED_ACCESS_TOKEN_RE = /\/at_/i;
|
|
915
|
+
const QURL_LINK_URL_RE = /^(?:https?:\/\/)?(?:[a-z0-9-]+\.)*qurl\.link(?::\d{1,5})?(?:\/|[?#])/i;
|
|
916
|
+
// ponytail: today's 22-character token suffix only improves URL-paste diagnostics;
|
|
917
|
+
// other lengths are still rejected as URLs without echoing the input.
|
|
918
|
+
const URL_EMBEDDED_ACCESS_TOKEN_RE = /[?&#=:/]at_[a-z0-9_-]{22}(?:$|[?&#:/])/i;
|
|
919
|
+
// A host without a slash can be a legitimate domain identifier. Require the
|
|
920
|
+
// path boundary so shared validation does not break the domain namespace.
|
|
921
|
+
const PATH_SCHEMELESS_URL_RE = /^[a-z0-9-]+(?:\.[a-z0-9-]+)+(?::\d{1,5})?\//i;
|
|
922
|
+
const QURL_DISPLAY_ID_PREFIX_RE = /^q_/i;
|
|
923
|
+
const MAX_PATH_ID_DECODE_PASSES = 3;
|
|
924
|
+
const MAX_PATH_ID_LENGTH = 4096;
|
|
925
|
+
function isUrlLikePathId(value) {
|
|
926
|
+
return /^https?:\/\//i.test(value) || PATH_SCHEMELESS_URL_RE.test(value);
|
|
927
|
+
}
|
|
928
|
+
const REVOKE_INDIVIDUAL_QURL_RECOVERY = "revoke individual tokens with revokeResourceQurl(crid, qurlId)";
|
|
929
|
+
const DELETE_RESOURCE_RECOVERY = "pass a CRID returned by the API; to revoke one qURL, call revokeResourceQurl(crid, qurlId)";
|
|
930
|
+
const RESOURCE_OR_QURL_ID_PATH_OPTIONS = {
|
|
931
|
+
requireCridUnlessQurlDisplayId: true,
|
|
932
|
+
};
|
|
933
|
+
const QURL_ID_PATH_OPTIONS = {
|
|
934
|
+
rejectBareAccessToken: true,
|
|
935
|
+
accessTokenRecovery: "pass the qURL display ID, not its access token",
|
|
936
|
+
};
|
|
836
937
|
/**
|
|
837
938
|
* Validates that a path-parameter argument is a non-empty string. Some callers
|
|
838
939
|
* pass resource/qURL display IDs, while newer endpoints also pass domains,
|
|
839
940
|
* session IDs, webhook IDs, and API-key IDs, so this intentionally enforces
|
|
840
|
-
* only basic shape and leaves endpoint-specific
|
|
941
|
+
* only basic shape plus transport-safety rules and leaves endpoint-specific
|
|
942
|
+
* identifier grammar to the service.
|
|
841
943
|
*/
|
|
842
|
-
function
|
|
944
|
+
function validatePathId(id, method, field = "id", options = {}) {
|
|
945
|
+
if (options.requireCridUnlessQurlDisplayId && typeof id === "string" && !id.startsWith("q_")) {
|
|
946
|
+
requireResourceCrid(id, method, field);
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
843
949
|
// `.trim()` catches whitespace-only and padded IDs before they round-trip as
|
|
844
950
|
// `%20...%20` paths that the server can only reject with a less useful 404.
|
|
845
951
|
// The pre-flight error is more actionable than the 404.
|
|
846
|
-
if (typeof id !== "string"
|
|
952
|
+
if (typeof id !== "string") {
|
|
953
|
+
const observedType = id === null ? "null" : typeof id;
|
|
954
|
+
throw clientValidationError(`${method}: ${field} is required (got ${observedType})`);
|
|
955
|
+
}
|
|
956
|
+
if (id.trim() === "") {
|
|
847
957
|
throw clientValidationError(`${method}: ${field} is required`);
|
|
848
958
|
}
|
|
849
959
|
if (id.trim() !== id) {
|
|
850
960
|
throw clientValidationError(`${method}: ${field} must not include leading or trailing whitespace`);
|
|
851
961
|
}
|
|
962
|
+
if (id.length > MAX_PATH_ID_LENGTH) {
|
|
963
|
+
throw clientValidationError(`${method}: ${field} must be ${MAX_PATH_ID_LENGTH} characters or fewer`);
|
|
964
|
+
}
|
|
965
|
+
const decodedIds = decodedPathIdForms(id);
|
|
966
|
+
// URL resolvers and intermediaries can normalize raw or pre-encoded dot
|
|
967
|
+
// segments before routing. Reject every decoded form we inspected so an
|
|
968
|
+
// item operation cannot be retargeted to a collection endpoint. Embedded
|
|
969
|
+
// `a/../b` remains an opaque single segment because the later URL builder
|
|
970
|
+
// percent-encodes its slash.
|
|
971
|
+
if (decodedIds.some((value) => value === "." || value === "..")) {
|
|
972
|
+
throw clientValidationError(`${method}: ${field} is an invalid URL path segment`);
|
|
973
|
+
}
|
|
974
|
+
// `/` is deliberately outside the general delimiter class so a URL path
|
|
975
|
+
// such as `/at_a_glance` is not misread as a credential. For non-URL input,
|
|
976
|
+
// `/at_` remains a strong pasted-path signal. Fragment, query, header, and
|
|
977
|
+
// pasted `Bearer at_...` / `token:at_...` forms are also strong signals.
|
|
978
|
+
// Bare `at_...` values are rejected only for resource/qURL identifiers:
|
|
979
|
+
// those current formats have fixed non-colliding prefixes, while domain,
|
|
980
|
+
// webhook, session, and API-key IDs are deliberately opaque/open contracts.
|
|
981
|
+
// Full URLs and delimiter/path-prefixed credentials remain rejected in
|
|
982
|
+
// every namespace.
|
|
983
|
+
const containsUrl = decodedIds.some(isUrlLikePathId);
|
|
984
|
+
const containsAccessToken = decodedIds.some((value) => {
|
|
985
|
+
const valueIsUrl = isUrlLikePathId(value);
|
|
986
|
+
return ((QURL_LINK_URL_RE.test(value) &&
|
|
987
|
+
(PATH_EMBEDDED_ACCESS_TOKEN_RE.test(value) || PATH_PREFIXED_ACCESS_TOKEN_RE.test(value))) ||
|
|
988
|
+
(valueIsUrl
|
|
989
|
+
? URL_EMBEDDED_ACCESS_TOKEN_RE.test(value)
|
|
990
|
+
: PATH_EMBEDDED_ACCESS_TOKEN_RE.test(value) || PATH_PREFIXED_ACCESS_TOKEN_RE.test(value)) ||
|
|
991
|
+
(options.rejectBareAccessToken === true && value.slice(0, 3).toLowerCase() === "at_"));
|
|
992
|
+
});
|
|
993
|
+
if (containsAccessToken) {
|
|
994
|
+
const recovery = options.accessTokenRecovery ?? "pass the identifier returned by the API";
|
|
995
|
+
throw clientValidationError(`${method}: ${field} must not contain an access token; ${recovery}`);
|
|
996
|
+
}
|
|
997
|
+
// A full target/qURL is a common argument mix-up, but it is distinct from
|
|
998
|
+
// passing an access-token credential. Diagnose it accurately without
|
|
999
|
+
// echoing the URL, which may itself contain sensitive query parameters.
|
|
1000
|
+
if (containsUrl) {
|
|
1001
|
+
throw clientValidationError(`${method}: ${field} must be an identifier, not a URL`);
|
|
1002
|
+
}
|
|
1003
|
+
if (options.rejectQurlDisplayId === true &&
|
|
1004
|
+
decodedIds.some((value) => QURL_DISPLAY_ID_PREFIX_RE.test(value))) {
|
|
1005
|
+
throw clientValidationError(`${method}: ${field} must not be a qURL display ID; ${REVOKE_INDIVIDUAL_QURL_RECOVERY}`);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
function decodedPathIdForms(id) {
|
|
1009
|
+
// Probe repeatedly encoded input too. Callers sometimes pre-encode a copied
|
|
1010
|
+
// link before passing it through a URL builder that encodes it again. Three
|
|
1011
|
+
// passes cover the common accidental cases without allowing hostile input
|
|
1012
|
+
// to create unbounded work. More deeply encoded or malformed-percent input
|
|
1013
|
+
// deliberately fails open to the authoritative service after safe segment
|
|
1014
|
+
// encoding; this heuristic is not an identifier parser.
|
|
1015
|
+
const decodedIds = [id];
|
|
1016
|
+
let decodedId = id;
|
|
1017
|
+
for (let pass = 0; pass < MAX_PATH_ID_DECODE_PASSES; pass += 1) {
|
|
1018
|
+
try {
|
|
1019
|
+
const next = decodeURIComponent(decodedId);
|
|
1020
|
+
if (next === decodedId)
|
|
1021
|
+
break;
|
|
1022
|
+
decodedIds.push(next);
|
|
1023
|
+
decodedId = next;
|
|
1024
|
+
}
|
|
1025
|
+
catch {
|
|
1026
|
+
// Malformed percent escapes remain opaque and are left to the service.
|
|
1027
|
+
break;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
return decodedIds;
|
|
852
1031
|
}
|
|
853
1032
|
function requireConnectorSlug(slug, method) {
|
|
854
1033
|
if (typeof slug !== "string" || !CONNECTOR_SLUG_PATTERN.test(slug)) {
|
|
@@ -895,29 +1074,23 @@ function decodeCanonicalBase64Url(value) {
|
|
|
895
1074
|
return undefined;
|
|
896
1075
|
}
|
|
897
1076
|
}
|
|
898
|
-
async function isValidConnectorResourceId(value) {
|
|
899
|
-
const der = decodeCanonicalBase64Url(value);
|
|
900
|
-
if (!der)
|
|
901
|
-
return false;
|
|
902
|
-
if (!globalThis.crypto?.subtle) {
|
|
903
|
-
throw new RuntimeError("qURL Connector resource validation requires the Web Crypto SubtleCrypto API");
|
|
904
|
-
}
|
|
905
|
-
return importsAsConnectorPublicKey(der);
|
|
906
|
-
}
|
|
907
1077
|
function requireConnectorSubtleCrypto(method) {
|
|
908
1078
|
if (!globalThis.crypto?.subtle) {
|
|
909
1079
|
throw new RuntimeError(`${method}: requires the Web Crypto SubtleCrypto API`);
|
|
910
1080
|
}
|
|
911
1081
|
}
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
1082
|
+
function requireResourceCrid(crid, method, field = "id") {
|
|
1083
|
+
if (parseCrid(crid))
|
|
1084
|
+
return;
|
|
1085
|
+
// Keep actionable paste errors without inspecting valid CRIDs twice.
|
|
1086
|
+
validatePathId(crid, method, field, {
|
|
1087
|
+
rejectBareAccessToken: true,
|
|
1088
|
+
rejectQurlDisplayId: true,
|
|
1089
|
+
accessTokenRecovery: method === "delete" || method === "deleteResource"
|
|
1090
|
+
? DELETE_RESOURCE_RECOVERY
|
|
1091
|
+
: "pass a CRID returned by the API",
|
|
1092
|
+
});
|
|
1093
|
+
throw clientValidationError(`${method}: requires a valid CRID`);
|
|
921
1094
|
}
|
|
922
1095
|
async function importsAsConnectorPublicKey(der) {
|
|
923
1096
|
try {
|
|
@@ -936,23 +1109,28 @@ function containsControlCharacter(value) {
|
|
|
936
1109
|
return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);
|
|
937
1110
|
});
|
|
938
1111
|
}
|
|
939
|
-
async function
|
|
940
|
-
if (typeof
|
|
941
|
-
throw unexpectedResponseError(`${method}: response
|
|
1112
|
+
async function verifyResourceIdentity(resource, method, expectedCrid) {
|
|
1113
|
+
if (!resource || typeof resource.resource_id !== "string") {
|
|
1114
|
+
throw unexpectedResponseError(`${method}: response has missing or invalid resource_id`);
|
|
942
1115
|
}
|
|
943
|
-
const
|
|
944
|
-
|
|
1116
|
+
const resourceKey = decodeCanonicalBase64Url(resource.resource_id);
|
|
1117
|
+
requireConnectorSubtleCrypto(method);
|
|
1118
|
+
if (!resourceKey || !(await importsAsConnectorPublicKey(resourceKey))) {
|
|
945
1119
|
throw unexpectedResponseError(`${method}: response has missing or invalid resource_id`);
|
|
946
1120
|
}
|
|
947
|
-
if (
|
|
948
|
-
throw unexpectedResponseError(`${method}: response
|
|
1121
|
+
if (expectedCrid !== undefined && resource.crid !== expectedCrid) {
|
|
1122
|
+
throw unexpectedResponseError(`${method}: response crid does not match the request`);
|
|
949
1123
|
}
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
if (expectation.resourceId === undefined &&
|
|
953
|
-
!(await isValidConnectorResourceId(resource.resource_id))) {
|
|
954
|
-
throw unexpectedResponseError(`${method}: response has missing or invalid resource_id`);
|
|
1124
|
+
if (!(await cridKeyMatches(resource.crid, resourceKey))) {
|
|
1125
|
+
throw unexpectedResponseError(`${method}: response has missing, invalid, or public-key-mismatched crid`);
|
|
955
1126
|
}
|
|
1127
|
+
}
|
|
1128
|
+
async function parseConnectorResource(client, value, method, expectation = {}) {
|
|
1129
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
1130
|
+
throw unexpectedResponseError(`${method}: response is missing connector resource data`);
|
|
1131
|
+
}
|
|
1132
|
+
const resource = value;
|
|
1133
|
+
await verifyResourceIdentity(resource, method, expectation.crid);
|
|
956
1134
|
if (typeof resource.connector_routing_id !== "string" ||
|
|
957
1135
|
!CONNECTOR_ROUTING_ID_PATTERN.test(resource.connector_routing_id)) {
|
|
958
1136
|
throw unexpectedResponseError(`${method}: response has missing or invalid connector_routing_id`);
|
|
@@ -985,12 +1163,6 @@ async function parseConnectorResource(client, value, method, expectation = {}) {
|
|
|
985
1163
|
// could not create or update.
|
|
986
1164
|
throw unexpectedResponseError(`${method}: response has invalid alias`);
|
|
987
1165
|
}
|
|
988
|
-
if (resource.crid !== undefined && typeof resource.crid !== "string") {
|
|
989
|
-
throw unexpectedResponseError(`${method}: response has invalid crid`);
|
|
990
|
-
}
|
|
991
|
-
// Match qurl-go: ConnectorResource carries an optional producer CRID
|
|
992
|
-
// verbatim. Consumers that possess a trusted delivered key can verify the
|
|
993
|
-
// binding separately; this management-plane row is not itself a trust root.
|
|
994
1166
|
if (resource.status === "revoked") {
|
|
995
1167
|
if (expectation.revokedIsLifecycleError !== true) {
|
|
996
1168
|
throw unexpectedResponseError(`${method}: active-only operation returned a revoked connector resource`);
|
|
@@ -1107,6 +1279,289 @@ function requireValidAccessPolicy(policy) {
|
|
|
1107
1279
|
}
|
|
1108
1280
|
}
|
|
1109
1281
|
}
|
|
1282
|
+
function validateCreateDelegatedQurlBatchInput(input) {
|
|
1283
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
1284
|
+
throw clientValidationError(`createDelegatedQurlBatch: input must be an object (got ${describeShape(input)})`);
|
|
1285
|
+
}
|
|
1286
|
+
const typed = input;
|
|
1287
|
+
requireNoUnknownFields(typed, DELEGATED_BATCH_INPUT_KEYS, "createDelegatedQurlBatch");
|
|
1288
|
+
if (typeof typed.mint_capability !== "string" ||
|
|
1289
|
+
typed.mint_capability.length < 1 ||
|
|
1290
|
+
typed.mint_capability.length > MAX_DELEGATED_MINT_CAPABILITY) {
|
|
1291
|
+
throw clientValidationError(`createDelegatedQurlBatch: mint_capability must be a string of 1-${MAX_DELEGATED_MINT_CAPABILITY} characters`);
|
|
1292
|
+
}
|
|
1293
|
+
if (!Array.isArray(typed.grants) ||
|
|
1294
|
+
typed.grants.length < 1 ||
|
|
1295
|
+
typed.grants.length > MAX_DELEGATED_BATCH_ITEMS) {
|
|
1296
|
+
throw clientValidationError(`createDelegatedQurlBatch: grants must contain 1-${MAX_DELEGATED_BATCH_ITEMS} items`);
|
|
1297
|
+
}
|
|
1298
|
+
for (const [index, grant] of typed.grants.entries()) {
|
|
1299
|
+
const field = `createDelegatedQurlBatch: grants[${index}]`;
|
|
1300
|
+
if (typeof grant !== "object" || grant === null || Array.isArray(grant)) {
|
|
1301
|
+
throw clientValidationError(`${field}: must be an object (got ${describeShape(grant)})`);
|
|
1302
|
+
}
|
|
1303
|
+
requireNoUnknownFields(grant, DELEGATED_BATCH_GRANT_KEYS, field);
|
|
1304
|
+
requireMaxUtf8Bytes(grant.label, `${field}.label`, MAX_LABEL);
|
|
1305
|
+
requireBooleanIfPresent(grant.one_time_use, `${field}.one_time_use`);
|
|
1306
|
+
requireMaxSessionsInRange(grant.max_sessions, `${field}.max_sessions`);
|
|
1307
|
+
for (const durationField of ["expires_in", "session_duration"]) {
|
|
1308
|
+
const value = grant[durationField];
|
|
1309
|
+
if (value !== undefined && (typeof value !== "string" || value.length === 0)) {
|
|
1310
|
+
throw clientValidationError(`${field}.${durationField}: must be a non-empty string`);
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
if (grant.access_policy !== undefined) {
|
|
1314
|
+
requireValidAccessPolicy(grant.access_policy);
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
function validateDelegatedBatchCreateOptions(options) {
|
|
1319
|
+
if (typeof options !== "object" || options === null || Array.isArray(options)) {
|
|
1320
|
+
throw clientValidationError("createDelegatedQurlBatch: options are required");
|
|
1321
|
+
}
|
|
1322
|
+
requireNoUnknownFields(options, DELEGATED_BATCH_CREATE_OPTION_KEYS, "createDelegatedQurlBatch options");
|
|
1323
|
+
const key = options.idempotencyKey;
|
|
1324
|
+
if (typeof key !== "string" ||
|
|
1325
|
+
key.length < MIN_DELEGATED_BATCH_IDEMPOTENCY_KEY ||
|
|
1326
|
+
key.length > MAX_IDEMPOTENCY_KEY ||
|
|
1327
|
+
!/^[!-~]+$/.test(key)) {
|
|
1328
|
+
throw clientValidationError(`createDelegatedQurlBatch: idempotencyKey must contain ${MIN_DELEGATED_BATCH_IDEMPOTENCY_KEY}-${MAX_IDEMPOTENCY_KEY} visible ASCII characters`);
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
function validateGetDelegatedQurlBatchOptions(options) {
|
|
1332
|
+
if (options === undefined)
|
|
1333
|
+
return;
|
|
1334
|
+
if (typeof options !== "object" || options === null || Array.isArray(options)) {
|
|
1335
|
+
throw clientValidationError(`getDelegatedQurlBatch: options must be an object (got ${describeShape(options)})`);
|
|
1336
|
+
}
|
|
1337
|
+
requireNoUnknownFields(options, DELEGATED_BATCH_GET_OPTION_KEYS, "getDelegatedQurlBatch options");
|
|
1338
|
+
const etag = options.etag;
|
|
1339
|
+
if (etag !== undefined &&
|
|
1340
|
+
(typeof etag !== "string" ||
|
|
1341
|
+
etag.length > MAX_DELEGATED_ETAG ||
|
|
1342
|
+
!STRONG_ETAG_PATTERN.test(etag))) {
|
|
1343
|
+
throw clientValidationError("getDelegatedQurlBatch: etag must be a valid strong ETag");
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
function delegatedResponseError(status, detail, requestId) {
|
|
1347
|
+
return unexpectedResponseError(`Delegated qURL batch ${detail}`, { status, requestId });
|
|
1348
|
+
}
|
|
1349
|
+
function delegatedResponseObject(value, status, label, requestId) {
|
|
1350
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
1351
|
+
throw delegatedResponseError(status, `${label} is not an object`, requestId);
|
|
1352
|
+
}
|
|
1353
|
+
return value;
|
|
1354
|
+
}
|
|
1355
|
+
function delegatedEnvelope(envelope, status) {
|
|
1356
|
+
if (typeof envelope.meta !== "object" || envelope.meta === null || Array.isArray(envelope.meta)) {
|
|
1357
|
+
throw delegatedResponseError(status, "response meta is not an object");
|
|
1358
|
+
}
|
|
1359
|
+
const meta = envelope.meta;
|
|
1360
|
+
if (typeof meta.request_id !== "string") {
|
|
1361
|
+
throw delegatedResponseError(status, "response meta has invalid request_id");
|
|
1362
|
+
}
|
|
1363
|
+
const data = delegatedResponseObject(envelope.data, status, "response data", meta.request_id);
|
|
1364
|
+
return { data, requestId: meta.request_id };
|
|
1365
|
+
}
|
|
1366
|
+
function requiredDelegatedHeader(envelope, name, status, requestId) {
|
|
1367
|
+
const headers = envelope.__http_headers;
|
|
1368
|
+
if (!headers || typeof headers.get !== "function") {
|
|
1369
|
+
throw delegatedResponseError(status, "response headers are unavailable", requestId);
|
|
1370
|
+
}
|
|
1371
|
+
const value = headers.get(name);
|
|
1372
|
+
if (value === null) {
|
|
1373
|
+
throw delegatedResponseError(status, `response is missing ${name}`, requestId);
|
|
1374
|
+
}
|
|
1375
|
+
return value;
|
|
1376
|
+
}
|
|
1377
|
+
function hasDelegatedNoStore(value) {
|
|
1378
|
+
if (value === null)
|
|
1379
|
+
return false;
|
|
1380
|
+
const directives = new Set(value.split(",").map((directive) => directive.trim().split("=", 1)[0].toLowerCase()));
|
|
1381
|
+
return directives.has("private") && directives.has("no-store");
|
|
1382
|
+
}
|
|
1383
|
+
function delegatedResponseHeaders(envelope, status, requestId) {
|
|
1384
|
+
const cacheControl = envelope.__http_headers?.get("Cache-Control") ?? null;
|
|
1385
|
+
if (cacheControl !== null) {
|
|
1386
|
+
if (!hasDelegatedNoStore(cacheControl)) {
|
|
1387
|
+
throw delegatedResponseError(status, "response has invalid Cache-Control", requestId);
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
else if (status !== 304) {
|
|
1391
|
+
requiredDelegatedHeader(envelope, "Cache-Control", status, requestId);
|
|
1392
|
+
}
|
|
1393
|
+
const etag = envelope.__http_headers?.get("ETag") ?? undefined;
|
|
1394
|
+
if (etag !== undefined && (etag.length > MAX_DELEGATED_ETAG || !STRONG_ETAG_PATTERN.test(etag))) {
|
|
1395
|
+
throw delegatedResponseError(status, "response has invalid strong ETag", requestId);
|
|
1396
|
+
}
|
|
1397
|
+
const rawRetryAfter = envelope.__http_headers?.get("Retry-After") ?? "";
|
|
1398
|
+
const trimmed = rawRetryAfter.trim();
|
|
1399
|
+
const seconds = /^\d+$/.test(trimmed)
|
|
1400
|
+
? Number(trimmed)
|
|
1401
|
+
: /^[A-Z][a-z]{2}, \d{2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} GMT$/.test(trimmed)
|
|
1402
|
+
? Math.ceil((Date.parse(trimmed) - Date.now()) / 1000)
|
|
1403
|
+
: NaN;
|
|
1404
|
+
const retryAfter = Number.isSafeInteger(seconds) && seconds > 0 && seconds <= RETRY_AFTER_PARSE_LIMIT_S
|
|
1405
|
+
? seconds
|
|
1406
|
+
: undefined;
|
|
1407
|
+
return { etag, retryAfter };
|
|
1408
|
+
}
|
|
1409
|
+
function isUtcTimestamp(value) {
|
|
1410
|
+
return (typeof value === "string" &&
|
|
1411
|
+
UTC_TIMESTAMP_PATTERN.test(value) &&
|
|
1412
|
+
Number.isFinite(Date.parse(value)));
|
|
1413
|
+
}
|
|
1414
|
+
function delegatedBatchBase(data, status, requestId, expectedBatchId) {
|
|
1415
|
+
if (typeof data.batch_id !== "string" ||
|
|
1416
|
+
!DELEGATED_BATCH_ID_PATTERN.test(data.batch_id) ||
|
|
1417
|
+
(expectedBatchId !== undefined && data.batch_id !== expectedBatchId)) {
|
|
1418
|
+
throw delegatedResponseError(status, "response has invalid batch_id", requestId);
|
|
1419
|
+
}
|
|
1420
|
+
if (!Number.isInteger(data.item_count) ||
|
|
1421
|
+
Number(data.item_count) < 1 ||
|
|
1422
|
+
Number(data.item_count) > MAX_DELEGATED_BATCH_ITEMS) {
|
|
1423
|
+
throw delegatedResponseError(status, "response has invalid item_count", requestId);
|
|
1424
|
+
}
|
|
1425
|
+
if (!isUtcTimestamp(data.submitted_at)) {
|
|
1426
|
+
throw delegatedResponseError(status, "response has invalid submitted_at", requestId);
|
|
1427
|
+
}
|
|
1428
|
+
return {
|
|
1429
|
+
batchId: data.batch_id,
|
|
1430
|
+
itemCount: Number(data.item_count),
|
|
1431
|
+
submittedAt: data.submitted_at,
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
function validateDelegatedLocation(value, baseUrl, batchId, status, requestId) {
|
|
1435
|
+
let location;
|
|
1436
|
+
try {
|
|
1437
|
+
const base = new URL(`${baseUrl}/`);
|
|
1438
|
+
const relative = value.startsWith("/v1/delegated-qurl-batches/")
|
|
1439
|
+
? `${base.pathname.replace(/\/$/, "")}${value}`
|
|
1440
|
+
: value;
|
|
1441
|
+
location = new URL(relative, base);
|
|
1442
|
+
}
|
|
1443
|
+
catch {
|
|
1444
|
+
throw delegatedResponseError(status, "response has invalid Location", requestId);
|
|
1445
|
+
}
|
|
1446
|
+
const expected = new URL(`${baseUrl}/v1/delegated-qurl-batches/${batchId}`);
|
|
1447
|
+
if (location.username !== "" ||
|
|
1448
|
+
location.password !== "" ||
|
|
1449
|
+
location.search !== "" ||
|
|
1450
|
+
location.hash !== "" ||
|
|
1451
|
+
location.href !== expected.href) {
|
|
1452
|
+
throw delegatedResponseError(status, "response has invalid Location", requestId);
|
|
1453
|
+
}
|
|
1454
|
+
return location.href;
|
|
1455
|
+
}
|
|
1456
|
+
function validateDelegatedQurlLink(value) {
|
|
1457
|
+
if (typeof value !== "string")
|
|
1458
|
+
return false;
|
|
1459
|
+
try {
|
|
1460
|
+
const link = new URL(value);
|
|
1461
|
+
return (link.protocol === "https:" &&
|
|
1462
|
+
link.username === "" &&
|
|
1463
|
+
link.password === "" &&
|
|
1464
|
+
link.hash.length > 1);
|
|
1465
|
+
}
|
|
1466
|
+
catch {
|
|
1467
|
+
return false;
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
function validateDelegatedQurl(value, expectedQurlId, status, requestId) {
|
|
1471
|
+
const data = delegatedResponseObject(value, status, "response data", requestId);
|
|
1472
|
+
if (data.qurl_id !== expectedQurlId) {
|
|
1473
|
+
throw delegatedResponseError(status, "response has invalid qurl_id", requestId);
|
|
1474
|
+
}
|
|
1475
|
+
if (data.status !== "active" &&
|
|
1476
|
+
data.status !== "consumed" &&
|
|
1477
|
+
data.status !== "expired" &&
|
|
1478
|
+
data.status !== "revoked") {
|
|
1479
|
+
throw delegatedResponseError(status, "response has invalid status", requestId);
|
|
1480
|
+
}
|
|
1481
|
+
if (!isUtcTimestamp(data.expires_at) ||
|
|
1482
|
+
typeof data.one_time_use !== "boolean" ||
|
|
1483
|
+
!Number.isInteger(data.max_sessions) ||
|
|
1484
|
+
Number(data.max_sessions) < 0 ||
|
|
1485
|
+
!Number.isInteger(data.session_duration) ||
|
|
1486
|
+
Number(data.session_duration) <= 0 ||
|
|
1487
|
+
(data.label !== undefined && typeof data.label !== "string") ||
|
|
1488
|
+
(data.access_policy !== undefined &&
|
|
1489
|
+
(typeof data.access_policy !== "object" ||
|
|
1490
|
+
data.access_policy === null ||
|
|
1491
|
+
Array.isArray(data.access_policy)))) {
|
|
1492
|
+
throw delegatedResponseError(status, "response has invalid qURL metadata", requestId);
|
|
1493
|
+
}
|
|
1494
|
+
return { ...data, request_id: requestId };
|
|
1495
|
+
}
|
|
1496
|
+
function validateDelegatedBatchResults(value, itemCount, batchStatus, status, requestId) {
|
|
1497
|
+
if (!Array.isArray(value) || value.length !== itemCount) {
|
|
1498
|
+
throw delegatedResponseError(status, "terminal response has invalid results", requestId);
|
|
1499
|
+
}
|
|
1500
|
+
let succeeded = 0;
|
|
1501
|
+
const qurlIds = new Set();
|
|
1502
|
+
const links = new Set();
|
|
1503
|
+
const results = value.map((raw, index) => {
|
|
1504
|
+
const item = delegatedResponseObject(raw, status, `result ${index}`, requestId);
|
|
1505
|
+
if (item.index !== index) {
|
|
1506
|
+
throw delegatedResponseError(status, `result ${index} has invalid index`, requestId);
|
|
1507
|
+
}
|
|
1508
|
+
if (item.status === "succeeded") {
|
|
1509
|
+
if (item.error !== undefined && item.error !== null) {
|
|
1510
|
+
throw delegatedResponseError(status, `result ${index} has conflicting fields`, requestId);
|
|
1511
|
+
}
|
|
1512
|
+
const qurl = delegatedResponseObject(item.qurl, status, `result ${index} qurl`, requestId);
|
|
1513
|
+
if (typeof qurl.qurl_id !== "string" ||
|
|
1514
|
+
!DELEGATED_QURL_ID_PATTERN.test(qurl.qurl_id) ||
|
|
1515
|
+
!validateDelegatedQurlLink(qurl.qurl_link) ||
|
|
1516
|
+
!isUtcTimestamp(qurl.expires_at) ||
|
|
1517
|
+
qurlIds.has(qurl.qurl_id) ||
|
|
1518
|
+
links.has(qurl.qurl_link)) {
|
|
1519
|
+
throw delegatedResponseError(status, `result ${index} has invalid qurl`, requestId);
|
|
1520
|
+
}
|
|
1521
|
+
qurlIds.add(qurl.qurl_id);
|
|
1522
|
+
links.add(qurl.qurl_link);
|
|
1523
|
+
succeeded++;
|
|
1524
|
+
return {
|
|
1525
|
+
index,
|
|
1526
|
+
status: "succeeded",
|
|
1527
|
+
qurl: {
|
|
1528
|
+
qurl_id: qurl.qurl_id,
|
|
1529
|
+
qurl_link: qurl.qurl_link,
|
|
1530
|
+
expires_at: qurl.expires_at,
|
|
1531
|
+
},
|
|
1532
|
+
};
|
|
1533
|
+
}
|
|
1534
|
+
if (item.status !== "failed" || (item.qurl !== undefined && item.qurl !== null)) {
|
|
1535
|
+
throw delegatedResponseError(status, `result ${index} has invalid status`, requestId);
|
|
1536
|
+
}
|
|
1537
|
+
const error = delegatedResponseObject(item.error, status, `result ${index} error`, requestId);
|
|
1538
|
+
if (typeof error.code !== "string" ||
|
|
1539
|
+
error.code.length === 0 ||
|
|
1540
|
+
typeof error.message !== "string") {
|
|
1541
|
+
throw delegatedResponseError(status, `result ${index} has invalid error`, requestId);
|
|
1542
|
+
}
|
|
1543
|
+
return {
|
|
1544
|
+
index,
|
|
1545
|
+
status: "failed",
|
|
1546
|
+
error: { code: error.code, message: error.message },
|
|
1547
|
+
};
|
|
1548
|
+
});
|
|
1549
|
+
if ((batchStatus === "succeeded" && succeeded !== itemCount) ||
|
|
1550
|
+
(batchStatus === "failed" && succeeded !== 0) ||
|
|
1551
|
+
(batchStatus === "partially_failed" && (succeeded === 0 || succeeded === itemCount))) {
|
|
1552
|
+
throw delegatedResponseError(status, "terminal status does not match results", requestId);
|
|
1553
|
+
}
|
|
1554
|
+
return results;
|
|
1555
|
+
}
|
|
1556
|
+
function classifyDelegatedBatchCreateFailure(error, batchId) {
|
|
1557
|
+
if (!(error instanceof QURLError)) {
|
|
1558
|
+
throw new DelegatedBatchOutcomeUnknownError(new RuntimeError("Unexpected failure after delegated batch dispatch", { cause: error }), batchId);
|
|
1559
|
+
}
|
|
1560
|
+
if ((error.status >= 400 && error.status < 500 && error.status !== 408) || error.status === 501) {
|
|
1561
|
+
throw error;
|
|
1562
|
+
}
|
|
1563
|
+
throw new DelegatedBatchOutcomeUnknownError(error, batchId);
|
|
1564
|
+
}
|
|
1110
1565
|
// `format: uri` in the OpenAPI spec allows schemes the SDK doesn't
|
|
1111
1566
|
// usefully support (`ftp://`, `file://`, `javascript:`, …). This is a
|
|
1112
1567
|
// cheap client-side sanity check — the server is still the
|
|
@@ -1375,6 +1830,56 @@ function validateApiKeyWriteFields(input, method, requiredFields = {}) {
|
|
|
1375
1830
|
if (requiredFields.scopes || input.scopes !== undefined) {
|
|
1376
1831
|
requireNonEmptyArrayField(input, "scopes", method);
|
|
1377
1832
|
requireStringArrayElements(input.scopes, "scopes", method);
|
|
1833
|
+
const unsupported = input.scopes.find((scope) => !API_KEY_REQUEST_SCOPES.has(scope));
|
|
1834
|
+
if (unsupported !== undefined) {
|
|
1835
|
+
throw clientValidationError(`${method}: scopes contains an unsupported permission ${JSON.stringify(unsupported)}; this SDK accepts ${API_KEY_REQUEST_SCOPE_VALUES.join(", ")}`);
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
function validateEnrollmentTokenFields(input, method) {
|
|
1840
|
+
if (input.target !== undefined &&
|
|
1841
|
+
(typeof input.target !== "string" || !CREDENTIAL_TARGETS.has(input.target))) {
|
|
1842
|
+
throw clientValidationError(`${method}: target must be 'agent' or 'connector'`);
|
|
1843
|
+
}
|
|
1844
|
+
let claimCount = 0;
|
|
1845
|
+
if (input.claims !== undefined) {
|
|
1846
|
+
if (!Array.isArray(input.claims)) {
|
|
1847
|
+
throw clientValidationError(`${method}: claims must be an array`);
|
|
1848
|
+
}
|
|
1849
|
+
if (input.claims.length > 1) {
|
|
1850
|
+
throw clientValidationError(`${method}: claims must contain at most one claim`);
|
|
1851
|
+
}
|
|
1852
|
+
claimCount = input.claims.length;
|
|
1853
|
+
for (const [index, claim] of input.claims.entries()) {
|
|
1854
|
+
if (!claim || typeof claim !== "object" || Array.isArray(claim)) {
|
|
1855
|
+
throw clientValidationError(`${method}: claims[${index}] must be an object`);
|
|
1856
|
+
}
|
|
1857
|
+
requireNoUnknownFields(claim, ["type", "id"], `${method}: claims[${index}]`);
|
|
1858
|
+
const record = claim;
|
|
1859
|
+
if (typeof record.type !== "string" || !CREDENTIAL_CLAIM_TYPES.has(record.type)) {
|
|
1860
|
+
throw clientValidationError(`${method}: claims[${index}].type must be 'connector'`);
|
|
1861
|
+
}
|
|
1862
|
+
if (typeof record.id !== "string" || !CONNECTOR_SLUG_PATTERN.test(record.id)) {
|
|
1863
|
+
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`);
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
// CreateApiKeyRequest.target permits an agent with zero or one connector claim.
|
|
1868
|
+
if (input.target === "connector" && claimCount !== 1) {
|
|
1869
|
+
throw clientValidationError(`${method}: target 'connector' requires exactly one claim`);
|
|
1870
|
+
}
|
|
1871
|
+
if (input.expires_in !== undefined) {
|
|
1872
|
+
if (typeof input.expires_in !== "string") {
|
|
1873
|
+
throw clientValidationError(`${method}: expires_in must be a duration string`);
|
|
1874
|
+
}
|
|
1875
|
+
const match = CREDENTIAL_DURATION_PATTERN.exec(input.expires_in);
|
|
1876
|
+
if (!match) {
|
|
1877
|
+
throw clientValidationError(`${method}: expires_in must match <integer><s|m|h|d|w>`);
|
|
1878
|
+
}
|
|
1879
|
+
const seconds = BigInt(match[1]) * CREDENTIAL_DURATION_MULTIPLIERS[match[2]];
|
|
1880
|
+
if (seconds <= 0n || seconds > MAX_ENROLLMENT_TOKEN_TTL_SECONDS) {
|
|
1881
|
+
throw clientValidationError(`${method}: expires_in must be greater than zero and at most 24h`);
|
|
1882
|
+
}
|
|
1378
1883
|
}
|
|
1379
1884
|
}
|
|
1380
1885
|
/**
|
|
@@ -1570,15 +2075,17 @@ function parseApiDate(value) {
|
|
|
1570
2075
|
* check; everything else is optional because the two mint endpoints differ
|
|
1571
2076
|
* in which ancillary fields they return.
|
|
1572
2077
|
*/
|
|
1573
|
-
function parsePortal(data, method) {
|
|
2078
|
+
async function parsePortal(data, method, expectedCrid) {
|
|
1574
2079
|
if (typeof data?.resource_id !== "string" || data.resource_id.trim() === "") {
|
|
1575
2080
|
throw unexpectedResponseError(`${method}: response is missing resource_id`);
|
|
1576
2081
|
}
|
|
1577
2082
|
if (typeof data.qurl_link !== "string" || data.qurl_link.trim() === "") {
|
|
1578
2083
|
throw unexpectedResponseError(`${method}: response is missing qurl_link`);
|
|
1579
2084
|
}
|
|
2085
|
+
await verifyResourceIdentity(data, method, expectedCrid);
|
|
1580
2086
|
return {
|
|
1581
|
-
|
|
2087
|
+
crid: data.crid,
|
|
2088
|
+
resourcePublicKey: data.resource_id,
|
|
1582
2089
|
link: data.qurl_link,
|
|
1583
2090
|
site: data.qurl_site,
|
|
1584
2091
|
expiresAt: parseApiDate(data.expires_at),
|
|
@@ -1925,31 +2432,29 @@ export class QURLClient {
|
|
|
1925
2432
|
body.alias = normalized.alias;
|
|
1926
2433
|
validateResourceWriteFields(body);
|
|
1927
2434
|
const resource = await this.request("POST", "/v1/resources", body, requestOptions);
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
}
|
|
1931
|
-
return new ProtectedResource(this, resource.resource_id,
|
|
2435
|
+
await verifyResourceIdentity(resource, "protectUrl");
|
|
2436
|
+
return new ProtectedResource(this, resource.crid,
|
|
1932
2437
|
// Some resource types redact target_url in responses; fall back to
|
|
1933
2438
|
// the caller-supplied URL so the handle stays useful.
|
|
1934
2439
|
resource.target_url || targetUrl, resource);
|
|
1935
2440
|
}
|
|
1936
2441
|
/**
|
|
1937
|
-
* Return a portal-minting handle for a stored
|
|
2442
|
+
* Return a portal-minting handle for a stored CRID, without an API
|
|
1938
2443
|
* call.
|
|
1939
2444
|
*
|
|
1940
|
-
* Use when you persisted a LayerV
|
|
1941
|
-
* portals for it (qurl-go: `Client.
|
|
2445
|
+
* Use when you persisted a LayerV CRID and want to mint more
|
|
2446
|
+
* portals for it (qurl-go: `Client.ResourceByCRID`):
|
|
1942
2447
|
*
|
|
1943
2448
|
* ```ts
|
|
1944
|
-
* const resource = client.
|
|
2449
|
+
* const resource = client.resourceByCrid(storedCrid);
|
|
1945
2450
|
* const portal = await resource.createPortal({ validFor: "1h" });
|
|
1946
2451
|
* ```
|
|
1947
2452
|
*
|
|
1948
2453
|
* The handle carries no server metadata; use {@link getResource} when you
|
|
1949
2454
|
* need the full resource details.
|
|
1950
2455
|
*/
|
|
1951
|
-
|
|
1952
|
-
|
|
2456
|
+
resourceByCrid(id) {
|
|
2457
|
+
requireResourceCrid(id, "resourceByCrid");
|
|
1953
2458
|
return new ProtectedResource(this, id);
|
|
1954
2459
|
}
|
|
1955
2460
|
/** Find or create the active qURL Connector resource for an immutable slug. */
|
|
@@ -1997,15 +2502,16 @@ export class QURLClient {
|
|
|
1997
2502
|
}
|
|
1998
2503
|
return { resource, foundExisting };
|
|
1999
2504
|
}
|
|
2000
|
-
/** Fetch a qURL Connector resource by its
|
|
2001
|
-
async getConnectorResource(
|
|
2002
|
-
|
|
2003
|
-
|
|
2505
|
+
/** Fetch a qURL Connector resource by its CRID. */
|
|
2506
|
+
async getConnectorResource(crid) {
|
|
2507
|
+
requireResourceCrid(crid, "getConnectorResource");
|
|
2508
|
+
requireConnectorSubtleCrypto("getConnectorResource");
|
|
2509
|
+
const { data, __http_status } = await this.rawRequest("GET", `/v1/resources/${encodeURIComponent(crid)}`, undefined, { retry: false });
|
|
2004
2510
|
if (__http_status !== 200) {
|
|
2005
2511
|
throw unexpectedResponseError(`getConnectorResource: expected HTTP 200, got ${__http_status ?? "unknown"}`);
|
|
2006
2512
|
}
|
|
2007
2513
|
return parseConnectorResource(this, data?.resource, "getConnectorResource", {
|
|
2008
|
-
|
|
2514
|
+
crid,
|
|
2009
2515
|
revokedIsLifecycleError: true,
|
|
2010
2516
|
});
|
|
2011
2517
|
}
|
|
@@ -2043,15 +2549,15 @@ export class QURLClient {
|
|
|
2043
2549
|
return parseConnectorResource(this, data[0], "getConnectorResourceBySlug", { slug });
|
|
2044
2550
|
}
|
|
2045
2551
|
/**
|
|
2046
|
-
* Revoke a qURL Connector resource by
|
|
2552
|
+
* Revoke a qURL Connector resource by CRID.
|
|
2047
2553
|
*
|
|
2048
2554
|
* The API does not apply Idempotency-Key replay to DELETE. After an
|
|
2049
2555
|
* outcome-unknown failure, reconcile by ID before issuing a deliberate retry.
|
|
2050
2556
|
*/
|
|
2051
|
-
async deleteConnectorResource(
|
|
2052
|
-
|
|
2557
|
+
async deleteConnectorResource(crid) {
|
|
2558
|
+
requireResourceCrid(crid, "deleteConnectorResource");
|
|
2053
2559
|
try {
|
|
2054
|
-
await this.requestNoContent(`/v1/resources/${encodeURIComponent(
|
|
2560
|
+
await this.requestNoContent(`/v1/resources/${encodeURIComponent(crid)}`);
|
|
2055
2561
|
}
|
|
2056
2562
|
catch (error) {
|
|
2057
2563
|
classifyConnectorMutationFailure("delete", error);
|
|
@@ -2064,7 +2570,7 @@ export class QURLClient {
|
|
|
2064
2570
|
* reach one private resource. Recipients open `portal.link` directly and
|
|
2065
2571
|
* need no LayerV credentials. Prefer short lifetimes such as
|
|
2066
2572
|
* `{ validFor: "5m" }`. Accepts a {@link ProtectedResource} handle or a
|
|
2067
|
-
*
|
|
2573
|
+
* CRID string. REST-shaped equivalents: {@link mintLink} /
|
|
2068
2574
|
* {@link createQurlForResource}.
|
|
2069
2575
|
*
|
|
2070
2576
|
* Duration options take a string (`"5m"`, `"24h"`; server-validated) or a
|
|
@@ -2083,22 +2589,22 @@ export class QURLClient {
|
|
|
2083
2589
|
if (!ProtectedResource.isBoundTo(resource, this)) {
|
|
2084
2590
|
throw clientValidationError("createPortal: resource is bound to a different client");
|
|
2085
2591
|
}
|
|
2086
|
-
resourceId = resource.
|
|
2592
|
+
resourceId = resource.crid;
|
|
2087
2593
|
}
|
|
2088
2594
|
else {
|
|
2089
|
-
throw clientValidationError(`createPortal: resource must be a ProtectedResource handle or a
|
|
2595
|
+
throw clientValidationError(`createPortal: resource must be a ProtectedResource handle or a CRID string (got ${describeShape(resource)})`);
|
|
2090
2596
|
}
|
|
2091
|
-
|
|
2597
|
+
requireResourceCrid(resourceId, "createPortal");
|
|
2092
2598
|
const body = buildCreatePortalBody(opts, "createPortal");
|
|
2093
2599
|
const data = await this.request("POST", `/v1/resources/${encodeURIComponent(resourceId)}/qurls`, body, requestOptions);
|
|
2094
|
-
return parsePortal(data, "createPortal");
|
|
2600
|
+
return parsePortal(data, "createPortal", resourceId);
|
|
2095
2601
|
}
|
|
2096
2602
|
/**
|
|
2097
2603
|
* Protect `targetUrl` and mint a portal in one API call.
|
|
2098
2604
|
*
|
|
2099
2605
|
* Convenience for one-off scripts (qurl-go: `Client.CreatePortalForURL`).
|
|
2100
2606
|
* The returned resource handle is reusable for minting more portals but
|
|
2101
|
-
* carries
|
|
2607
|
+
* carries the CRID, verification key, and the caller-supplied target URL; use
|
|
2102
2608
|
* {@link protectUrl} when you need the full server-populated resource
|
|
2103
2609
|
* metadata. Portal options match {@link createPortal}; `targetUrl` gets
|
|
2104
2610
|
* the same malformed-URL and embedded-credentials rejection as
|
|
@@ -2113,10 +2619,10 @@ export class QURLClient {
|
|
|
2113
2619
|
...buildCreatePortalBody(opts, "createPortalForUrl"),
|
|
2114
2620
|
};
|
|
2115
2621
|
const data = await this.request("POST", "/v1/qurls", body, requestOptions);
|
|
2116
|
-
const portal = parsePortal(data, "createPortalForUrl");
|
|
2622
|
+
const portal = await parsePortal(data, "createPortalForUrl");
|
|
2117
2623
|
return {
|
|
2118
2624
|
portal,
|
|
2119
|
-
resource: new ProtectedResource(this, portal.
|
|
2625
|
+
resource: new ProtectedResource(this, portal.crid, targetUrl),
|
|
2120
2626
|
};
|
|
2121
2627
|
}
|
|
2122
2628
|
/**
|
|
@@ -2150,7 +2656,7 @@ export class QURLClient {
|
|
|
2150
2656
|
return {
|
|
2151
2657
|
resourceUrl: resolved.target_url,
|
|
2152
2658
|
openSeconds: resolved.access_grant?.expires_in ?? 0,
|
|
2153
|
-
|
|
2659
|
+
resourcePublicKey: resolved.resource_id,
|
|
2154
2660
|
};
|
|
2155
2661
|
}
|
|
2156
2662
|
// --- Public API ---
|
|
@@ -2310,14 +2816,204 @@ export class QURLClient {
|
|
|
2310
2816
|
}
|
|
2311
2817
|
return requestId !== undefined ? { ...result, request_id: requestId } : result;
|
|
2312
2818
|
}
|
|
2819
|
+
/**
|
|
2820
|
+
* Start one durable delegated qURL batch. The SDK sends this mutation once.
|
|
2821
|
+
* If the result may have committed, retry explicitly with the same key and
|
|
2822
|
+
* semantic body.
|
|
2823
|
+
*/
|
|
2824
|
+
async createDelegatedQurlBatch(input, options) {
|
|
2825
|
+
validateCreateDelegatedQurlBatchInput(input);
|
|
2826
|
+
validateDelegatedBatchCreateOptions(options);
|
|
2827
|
+
let dispatched = false;
|
|
2828
|
+
let acceptedBatchId;
|
|
2829
|
+
try {
|
|
2830
|
+
const envelope = await this.rawRequest("POST", "/v1/delegated-qurl-batches", input, {
|
|
2831
|
+
requestOptions: options,
|
|
2832
|
+
retry: false,
|
|
2833
|
+
generateIdempotencyKey: false,
|
|
2834
|
+
captureResponseHeaders: true,
|
|
2835
|
+
onDispatch: () => {
|
|
2836
|
+
dispatched = true;
|
|
2837
|
+
},
|
|
2838
|
+
});
|
|
2839
|
+
const status = envelope.__http_status ?? 0;
|
|
2840
|
+
if (status !== 202) {
|
|
2841
|
+
throw delegatedResponseError(status, "create returned an unexpected success status");
|
|
2842
|
+
}
|
|
2843
|
+
const rawData = envelope.data;
|
|
2844
|
+
if (typeof rawData === "object" && rawData !== null && !Array.isArray(rawData)) {
|
|
2845
|
+
const candidate = rawData.batch_id;
|
|
2846
|
+
if (typeof candidate === "string" && DELEGATED_BATCH_ID_PATTERN.test(candidate)) {
|
|
2847
|
+
acceptedBatchId = candidate;
|
|
2848
|
+
}
|
|
2849
|
+
}
|
|
2850
|
+
const { data, requestId } = delegatedEnvelope(envelope, status);
|
|
2851
|
+
const base = delegatedBatchBase(data, status, requestId);
|
|
2852
|
+
if (data.status !== "queued" || base.itemCount !== input.grants.length) {
|
|
2853
|
+
throw delegatedResponseError(status, "acceptance data is inconsistent", requestId);
|
|
2854
|
+
}
|
|
2855
|
+
const responseHeaders = delegatedResponseHeaders(envelope, status, requestId);
|
|
2856
|
+
const location = validateDelegatedLocation(requiredDelegatedHeader(envelope, "Location", status, requestId), this.baseUrl, base.batchId, status, requestId);
|
|
2857
|
+
return {
|
|
2858
|
+
http_status: 202,
|
|
2859
|
+
batch_id: base.batchId,
|
|
2860
|
+
status: "queued",
|
|
2861
|
+
item_count: base.itemCount,
|
|
2862
|
+
submitted_at: base.submittedAt,
|
|
2863
|
+
etag: responseHeaders.etag,
|
|
2864
|
+
retry_after: responseHeaders.retryAfter,
|
|
2865
|
+
location,
|
|
2866
|
+
request_id: requestId,
|
|
2867
|
+
};
|
|
2868
|
+
}
|
|
2869
|
+
catch (error) {
|
|
2870
|
+
if (!dispatched)
|
|
2871
|
+
throw error;
|
|
2872
|
+
classifyDelegatedBatchCreateFailure(error, acceptedBatchId);
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2875
|
+
/**
|
|
2876
|
+
* Read one delegated qURL batch state. One call performs one logical read;
|
|
2877
|
+
* callers retain control of the poll count and total deadline.
|
|
2878
|
+
*/
|
|
2879
|
+
async getDelegatedQurlBatch(batchId, options) {
|
|
2880
|
+
if (typeof batchId !== "string" || !DELEGATED_BATCH_ID_PATTERN.test(batchId)) {
|
|
2881
|
+
throw clientValidationError("getDelegatedQurlBatch: batchId must match dqb_ plus 22 URL-safe characters");
|
|
2882
|
+
}
|
|
2883
|
+
validateGetDelegatedQurlBatchOptions(options);
|
|
2884
|
+
const envelope = await this.rawRequest("GET", `/v1/delegated-qurl-batches/${encodeURIComponent(batchId)}`, undefined, {
|
|
2885
|
+
passthroughStatuses: DELEGATED_BATCH_PASSTHROUGH_STATUSES,
|
|
2886
|
+
allowEmptyPassthroughBody: true,
|
|
2887
|
+
ifNoneMatch: options?.etag,
|
|
2888
|
+
retry: false,
|
|
2889
|
+
captureResponseHeaders: true,
|
|
2890
|
+
});
|
|
2891
|
+
const status = envelope.__http_status ?? 0;
|
|
2892
|
+
if (status === 304) {
|
|
2893
|
+
if (envelope.__http_body_empty !== true || options?.etag === undefined) {
|
|
2894
|
+
throw delegatedResponseError(status, "304 response is inconsistent");
|
|
2895
|
+
}
|
|
2896
|
+
const responseHeaders = delegatedResponseHeaders(envelope, status);
|
|
2897
|
+
if (responseHeaders.etag !== undefined && responseHeaders.etag !== options.etag) {
|
|
2898
|
+
throw delegatedResponseError(status, "304 response changed the ETag");
|
|
2899
|
+
}
|
|
2900
|
+
return {
|
|
2901
|
+
http_status: 304,
|
|
2902
|
+
etag: responseHeaders.etag ?? options.etag,
|
|
2903
|
+
...(responseHeaders.retryAfter === undefined
|
|
2904
|
+
? {}
|
|
2905
|
+
: { retry_after: responseHeaders.retryAfter }),
|
|
2906
|
+
};
|
|
2907
|
+
}
|
|
2908
|
+
if (status !== 200 && status !== 202) {
|
|
2909
|
+
throw delegatedResponseError(status, "read returned an unexpected success status");
|
|
2910
|
+
}
|
|
2911
|
+
const { data, requestId } = delegatedEnvelope(envelope, status);
|
|
2912
|
+
const base = delegatedBatchBase(data, status, requestId, batchId);
|
|
2913
|
+
if (status === 202) {
|
|
2914
|
+
if (data.status !== "queued" && data.status !== "running") {
|
|
2915
|
+
throw delegatedResponseError(status, "pending data has a terminal status", requestId);
|
|
2916
|
+
}
|
|
2917
|
+
const responseHeaders = delegatedResponseHeaders(envelope, status, requestId);
|
|
2918
|
+
return {
|
|
2919
|
+
http_status: 202,
|
|
2920
|
+
batch_id: base.batchId,
|
|
2921
|
+
status: data.status,
|
|
2922
|
+
item_count: base.itemCount,
|
|
2923
|
+
submitted_at: base.submittedAt,
|
|
2924
|
+
etag: responseHeaders.etag,
|
|
2925
|
+
retry_after: responseHeaders.retryAfter,
|
|
2926
|
+
request_id: requestId,
|
|
2927
|
+
};
|
|
2928
|
+
}
|
|
2929
|
+
if (data.status !== "succeeded" &&
|
|
2930
|
+
data.status !== "partially_failed" &&
|
|
2931
|
+
data.status !== "failed") {
|
|
2932
|
+
throw delegatedResponseError(status, "terminal data has a non-terminal status", requestId);
|
|
2933
|
+
}
|
|
2934
|
+
let results;
|
|
2935
|
+
try {
|
|
2936
|
+
if (!isUtcTimestamp(data.completed_at)) {
|
|
2937
|
+
throw delegatedResponseError(status, "terminal data has invalid completed_at", requestId);
|
|
2938
|
+
}
|
|
2939
|
+
results = validateDelegatedBatchResults(data.results, base.itemCount, data.status, status, requestId);
|
|
2940
|
+
}
|
|
2941
|
+
catch (error) {
|
|
2942
|
+
const partialQurlIds = Array.isArray(data.results)
|
|
2943
|
+
? [
|
|
2944
|
+
...new Set(data.results.flatMap((item) => item &&
|
|
2945
|
+
typeof item === "object" &&
|
|
2946
|
+
item.status === "succeeded" &&
|
|
2947
|
+
typeof item.qurl?.qurl_id === "string" &&
|
|
2948
|
+
DELEGATED_QURL_ID_PATTERN.test(item.qurl.qurl_id)
|
|
2949
|
+
? [item.qurl.qurl_id]
|
|
2950
|
+
: [])),
|
|
2951
|
+
]
|
|
2952
|
+
: [];
|
|
2953
|
+
if (error instanceof QURLError)
|
|
2954
|
+
Object.defineProperty(error, "partialQurlIds", {
|
|
2955
|
+
value: Object.freeze(partialQurlIds),
|
|
2956
|
+
enumerable: true,
|
|
2957
|
+
});
|
|
2958
|
+
throw error;
|
|
2959
|
+
}
|
|
2960
|
+
// This is the caller's only copy of the bearer links. Do not discard a
|
|
2961
|
+
// valid terminal body because an intermediary removed a cache validator;
|
|
2962
|
+
// qurl-service owns the required no-store response policy.
|
|
2963
|
+
const cacheControl = envelope.__http_headers?.get("Cache-Control") ?? null;
|
|
2964
|
+
if (!hasDelegatedNoStore(cacheControl)) {
|
|
2965
|
+
this.log("delegated batch terminal response is missing private, no-store", {
|
|
2966
|
+
batch_id: base.batchId,
|
|
2967
|
+
});
|
|
2968
|
+
}
|
|
2969
|
+
const etag = envelope.__http_headers?.get("ETag") ?? undefined;
|
|
2970
|
+
const terminalEtag = etag !== undefined && etag.length <= MAX_DELEGATED_ETAG && STRONG_ETAG_PATTERN.test(etag)
|
|
2971
|
+
? etag
|
|
2972
|
+
: undefined;
|
|
2973
|
+
return {
|
|
2974
|
+
http_status: 200,
|
|
2975
|
+
batch_id: base.batchId,
|
|
2976
|
+
status: data.status,
|
|
2977
|
+
item_count: base.itemCount,
|
|
2978
|
+
submitted_at: base.submittedAt,
|
|
2979
|
+
completed_at: data.completed_at,
|
|
2980
|
+
results,
|
|
2981
|
+
...(terminalEtag === undefined ? {} : { etag: terminalEtag }),
|
|
2982
|
+
request_id: requestId,
|
|
2983
|
+
};
|
|
2984
|
+
}
|
|
2985
|
+
/** Read owner-visible metadata for one delegated qURL. */
|
|
2986
|
+
async getDelegatedQurl(qurlId) {
|
|
2987
|
+
if (typeof qurlId !== "string" || !DELEGATED_QURL_ID_PATTERN.test(qurlId)) {
|
|
2988
|
+
throw clientValidationError("getDelegatedQurl: qurlId must match q_ plus 11 lowercase hexadecimal characters");
|
|
2989
|
+
}
|
|
2990
|
+
const envelope = await this.rawRequest("GET", `/v1/delegated-qurls/${encodeURIComponent(qurlId)}`);
|
|
2991
|
+
const status = envelope.__http_status ?? 0;
|
|
2992
|
+
if (status !== 200) {
|
|
2993
|
+
throw delegatedResponseError(status, "read returned an unexpected success status");
|
|
2994
|
+
}
|
|
2995
|
+
const { data, requestId } = delegatedEnvelope(envelope, status);
|
|
2996
|
+
return validateDelegatedQurl(data, qurlId, status, requestId);
|
|
2997
|
+
}
|
|
2998
|
+
/**
|
|
2999
|
+
* Revoke one delegated qURL. DELETE is never retried automatically. A 503
|
|
3000
|
+
* `mutation_outcome_unknown` is safe to retry with the same qURL ID.
|
|
3001
|
+
*/
|
|
3002
|
+
async deleteDelegatedQurl(qurlId) {
|
|
3003
|
+
if (typeof qurlId !== "string" || !DELEGATED_QURL_ID_PATTERN.test(qurlId)) {
|
|
3004
|
+
throw clientValidationError("deleteDelegatedQurl: qurlId must match q_ plus 11 lowercase hexadecimal characters");
|
|
3005
|
+
}
|
|
3006
|
+
await this.requestNoContent(`/v1/delegated-qurls/${encodeURIComponent(qurlId)}`);
|
|
3007
|
+
}
|
|
2313
3008
|
/**
|
|
2314
3009
|
* Get a qURL resource and its access tokens.
|
|
2315
3010
|
*
|
|
2316
|
-
* Accepts
|
|
2317
|
-
* prefix); the API resolves
|
|
3011
|
+
* Accepts a CRID or qURL
|
|
3012
|
+
* display ID (`q_` prefix); the API resolves display IDs to the parent
|
|
3013
|
+
* resource automatically.
|
|
2318
3014
|
*/
|
|
2319
3015
|
async get(id) {
|
|
2320
|
-
|
|
3016
|
+
validatePathId(id, "get", "id", RESOURCE_OR_QURL_ID_PATH_OPTIONS);
|
|
2321
3017
|
const raw = await this.request("GET", `/v1/qurls/${encodeURIComponent(id)}`);
|
|
2322
3018
|
return this.mapQurlsField(raw);
|
|
2323
3019
|
}
|
|
@@ -2359,49 +3055,20 @@ export class QURLClient {
|
|
|
2359
3055
|
/**
|
|
2360
3056
|
* Delete (revoke) a qURL resource and all its access tokens.
|
|
2361
3057
|
*
|
|
2362
|
-
*
|
|
2363
|
-
*
|
|
2364
|
-
*
|
|
2365
|
-
*
|
|
3058
|
+
* Requires a CRID returned by the API. To revoke one qURL, use
|
|
3059
|
+
* {@link revokeResourceQurl} instead.
|
|
3060
|
+
*
|
|
3061
|
+
* @throws {ValidationError} If `id` is not a canonical CRID.
|
|
2366
3062
|
*/
|
|
2367
3063
|
async delete(id) {
|
|
2368
|
-
|
|
2369
|
-
// is a raw TypeError instead of the structured ValidationError the
|
|
2370
|
-
// rest of the surface produces.
|
|
2371
|
-
if (typeof id !== "string") {
|
|
2372
|
-
throw clientValidationError(`delete: requires a resource ID (${RESOURCE_ID_PREFIX} prefix + suffix) — got ${id === null ? "null" : typeof id}`);
|
|
2373
|
-
}
|
|
2374
|
-
if (id.trim() !== id) {
|
|
2375
|
-
throw clientValidationError("delete: id must not include leading or trailing whitespace");
|
|
2376
|
-
}
|
|
2377
|
-
// Too-short check runs BEFORE the prefix check so it catches
|
|
2378
|
-
// bare-prefix inputs like `"r_"` (right prefix, no suffix) in
|
|
2379
|
-
// addition to `""` / `"x"` / `"ab"` / `"q_"`. Without this
|
|
2380
|
-
// ordering, an exact `"r_"` would pass the startsWith check and
|
|
2381
|
-
// the SDK would send `DELETE /v1/qurls/r_` to the server —
|
|
2382
|
-
// rejected server-side, but this catches it client-side without
|
|
2383
|
-
// a round-trip.
|
|
2384
|
-
if (id.length <= RESOURCE_ID_PREFIX.length) {
|
|
2385
|
-
throw clientValidationError(`delete: requires a resource ID (${RESOURCE_ID_PREFIX} prefix + suffix) — got ${id.length} character${id.length === 1 ? "" : "s"}`);
|
|
2386
|
-
}
|
|
2387
|
-
if (!id.startsWith(RESOURCE_ID_PREFIX)) {
|
|
2388
|
-
// Wrong-prefix branch: the input is long enough to plausibly be
|
|
2389
|
-
// an ID but has the wrong prefix (e.g. `q_3a7f2c8e91b`).
|
|
2390
|
-
// Echo only the 2-char prefix — never the raw ID
|
|
2391
|
-
// — so observability pipelines don't end up with caller-supplied
|
|
2392
|
-
// identifiers in error logs.
|
|
2393
|
-
const observedPrefix = id.slice(0, RESOURCE_ID_PREFIX.length);
|
|
2394
|
-
throw clientValidationError(`delete: only resource IDs (${RESOURCE_ID_PREFIX} prefix) are accepted — ` +
|
|
2395
|
-
`got an ID starting with "${observedPrefix}". ` +
|
|
2396
|
-
"To revoke a single access token, use the resource-scoped token endpoint.");
|
|
2397
|
-
}
|
|
3064
|
+
requireResourceCrid(id, "delete");
|
|
2398
3065
|
await this.requestNoContent(`/v1/qurls/${encodeURIComponent(id)}`);
|
|
2399
3066
|
}
|
|
2400
3067
|
/**
|
|
2401
3068
|
* Extend a qURL's expiration.
|
|
2402
3069
|
*
|
|
2403
|
-
* Accepts
|
|
2404
|
-
* prefix).
|
|
3070
|
+
* Accepts a CRID or qURL
|
|
3071
|
+
* display ID (`q_` prefix). Delegates to {@link update} with only the
|
|
2405
3072
|
* expiration fields. `ExtendInput` shares its `extend_by` / `expires_at`
|
|
2406
3073
|
* fields with `UpdateInput` but is *narrower in two ways*: (1) exactly
|
|
2407
3074
|
* one of the two must be present (XOR via `?: never`), where `UpdateInput`
|
|
@@ -2413,7 +3080,7 @@ export class QURLClient {
|
|
|
2413
3080
|
* enforced separately by the runtime check inside `update()`.
|
|
2414
3081
|
*/
|
|
2415
3082
|
async extend(id, input, options) {
|
|
2416
|
-
|
|
3083
|
+
validatePathId(id, "extend", "id", RESOURCE_OR_QURL_ID_PATH_OPTIONS);
|
|
2417
3084
|
requireObjectInput(input, "extend");
|
|
2418
3085
|
const { extend_by, expires_at } = input;
|
|
2419
3086
|
if (extend_by === undefined && expires_at === undefined) {
|
|
@@ -2427,11 +3094,12 @@ export class QURLClient {
|
|
|
2427
3094
|
/**
|
|
2428
3095
|
* Update a qURL — extend expiration, change description, rename tags.
|
|
2429
3096
|
*
|
|
2430
|
-
* Accepts
|
|
2431
|
-
* prefix); the API resolves
|
|
3097
|
+
* Accepts a CRID or qURL
|
|
3098
|
+
* display ID (`q_` prefix); the API resolves display IDs to the parent
|
|
3099
|
+
* resource automatically.
|
|
2432
3100
|
*/
|
|
2433
3101
|
async update(id, input, options) {
|
|
2434
|
-
|
|
3102
|
+
validatePathId(id, "update", "id", RESOURCE_OR_QURL_ID_PATH_OPTIONS);
|
|
2435
3103
|
requireObjectInput(input, "update");
|
|
2436
3104
|
requireNoUnknownFields(input, UPDATE_FIELD_KEYS, "update");
|
|
2437
3105
|
// Normalize null → undefined so untyped-JS callers passing
|
|
@@ -2458,15 +3126,16 @@ export class QURLClient {
|
|
|
2458
3126
|
*
|
|
2459
3127
|
* Portal-flow equivalent: {@link createPortal}.
|
|
2460
3128
|
*
|
|
2461
|
-
* Accepts
|
|
2462
|
-
* prefix); the API resolves
|
|
3129
|
+
* Accepts a CRID or qURL
|
|
3130
|
+
* display ID (`q_` prefix); the API resolves display IDs to the parent
|
|
3131
|
+
* resource automatically.
|
|
2463
3132
|
*
|
|
2464
3133
|
* Passing `{}` or an object with all fields `null`/`undefined` is
|
|
2465
3134
|
* equivalent to omitting the second argument: no body is sent, and the
|
|
2466
3135
|
* server applies its 24h default expiration.
|
|
2467
3136
|
*/
|
|
2468
3137
|
async mintLink(id, input, options) {
|
|
2469
|
-
|
|
3138
|
+
validatePathId(id, "mintLink", "id", RESOURCE_OR_QURL_ID_PATH_OPTIONS);
|
|
2470
3139
|
// Normalize null → omitted so untyped-JS callers passing
|
|
2471
3140
|
// `{ expires_in: null, expires_at: "..." }` don't leak null into
|
|
2472
3141
|
// the wire body via JSON.stringify and don't bypass the XOR check
|
|
@@ -2580,12 +3249,12 @@ export class QURLClient {
|
|
|
2580
3249
|
}
|
|
2581
3250
|
/** Get one resource plus its bounded qURL preview. */
|
|
2582
3251
|
async getResource(id) {
|
|
2583
|
-
|
|
3252
|
+
requireResourceCrid(id, "getResource");
|
|
2584
3253
|
return this.request("GET", `/v1/resources/${encodeURIComponent(id)}`);
|
|
2585
3254
|
}
|
|
2586
3255
|
/** Update resource metadata. */
|
|
2587
3256
|
async updateResource(id, input, options) {
|
|
2588
|
-
|
|
3257
|
+
requireResourceCrid(id, "updateResource");
|
|
2589
3258
|
requireObjectInput(input, "updateResource");
|
|
2590
3259
|
requireNoUnknownFields(input, UPDATE_RESOURCE_FIELD_KEYS, "updateResource");
|
|
2591
3260
|
const validationInput = normalizePatchFields(input, UPDATE_RESOURCE_FIELD_KEYS, { preserveNullFields: ["alias"] });
|
|
@@ -2599,7 +3268,7 @@ export class QURLClient {
|
|
|
2599
3268
|
}
|
|
2600
3269
|
/** Revoke a resource and all of its qURLs. */
|
|
2601
3270
|
async deleteResource(id) {
|
|
2602
|
-
|
|
3271
|
+
requireResourceCrid(id, "deleteResource");
|
|
2603
3272
|
await this.requestNoContent(`/v1/resources/${encodeURIComponent(id)}`);
|
|
2604
3273
|
}
|
|
2605
3274
|
/**
|
|
@@ -2608,7 +3277,7 @@ export class QURLClient {
|
|
|
2608
3277
|
* Portal-flow equivalent: {@link createPortal} (same endpoint).
|
|
2609
3278
|
*/
|
|
2610
3279
|
async createQurlForResource(id, input, options) {
|
|
2611
|
-
|
|
3280
|
+
requireResourceCrid(id, "createQurlForResource");
|
|
2612
3281
|
let normalized = input;
|
|
2613
3282
|
if (input !== undefined) {
|
|
2614
3283
|
requireObjectInput(input, "createQurlForResource");
|
|
@@ -2619,16 +3288,83 @@ export class QURLClient {
|
|
|
2619
3288
|
validateQurlTokenOptions(normalized);
|
|
2620
3289
|
return this.request("POST", `/v1/resources/${encodeURIComponent(id)}/qurls`, normalized, options);
|
|
2621
3290
|
}
|
|
3291
|
+
/**
|
|
3292
|
+
* Mint a fresh, short-lived share link for an existing CRID.
|
|
3293
|
+
* The returned link is secret and is not retrievable after this response.
|
|
3294
|
+
* If you hold the trusted resource DER SPKI, call `ShareLink.verifyCrid()`
|
|
3295
|
+
* before delivery to verify that the response CRID derives from that key.
|
|
3296
|
+
*/
|
|
3297
|
+
async shareResource(id, options = {}, requestOptions) {
|
|
3298
|
+
requireResourceCrid(id, "shareResource");
|
|
3299
|
+
requireObjectInput(options, "shareResource");
|
|
3300
|
+
requireNoUnknownFields(options, SHARE_RESOURCE_OPTION_KEYS, "shareResource");
|
|
3301
|
+
const body = {};
|
|
3302
|
+
if (options.ttlSeconds !== undefined) {
|
|
3303
|
+
if (typeof options.ttlSeconds !== "number" ||
|
|
3304
|
+
!Number.isSafeInteger(options.ttlSeconds) ||
|
|
3305
|
+
options.ttlSeconds <= 0) {
|
|
3306
|
+
throw clientValidationError("shareResource: ttlSeconds must be a positive safe integer");
|
|
3307
|
+
}
|
|
3308
|
+
body.ttl_seconds = options.ttlSeconds;
|
|
3309
|
+
}
|
|
3310
|
+
const data = await this.request("POST", `/v1/resources/${encodeURIComponent(id)}/share`, body, requestOptions);
|
|
3311
|
+
if (typeof data?.qurl !== "string" || data.qurl.trim() === "") {
|
|
3312
|
+
throw unexpectedResponseError("shareResource: response is missing qurl");
|
|
3313
|
+
}
|
|
3314
|
+
if (data.crid !== id) {
|
|
3315
|
+
throw unexpectedResponseError("shareResource: response CRID does not match the requested CRID");
|
|
3316
|
+
}
|
|
3317
|
+
const wire = data;
|
|
3318
|
+
for (const field of ["qurl_id", "type", "expires_at"]) {
|
|
3319
|
+
const value = wire[field];
|
|
3320
|
+
if (value !== undefined && value !== null && typeof value !== "string") {
|
|
3321
|
+
throw unexpectedResponseError(`shareResource: response has invalid ${field}`);
|
|
3322
|
+
}
|
|
3323
|
+
}
|
|
3324
|
+
for (const field of ["qurl_id", "type"]) {
|
|
3325
|
+
const value = wire[field];
|
|
3326
|
+
if (typeof value === "string" && (value.trim() === "" || value.trim() !== value)) {
|
|
3327
|
+
throw unexpectedResponseError(`shareResource: response has invalid ${field}`);
|
|
3328
|
+
}
|
|
3329
|
+
}
|
|
3330
|
+
if (wire.expires_in_seconds !== undefined &&
|
|
3331
|
+
wire.expires_in_seconds !== null &&
|
|
3332
|
+
(typeof wire.expires_in_seconds !== "number" ||
|
|
3333
|
+
!Number.isSafeInteger(wire.expires_in_seconds) ||
|
|
3334
|
+
wire.expires_in_seconds < 0)) {
|
|
3335
|
+
throw unexpectedResponseError("shareResource: response has invalid expires_in_seconds");
|
|
3336
|
+
}
|
|
3337
|
+
if (wire.single_use !== undefined &&
|
|
3338
|
+
wire.single_use !== null &&
|
|
3339
|
+
typeof wire.single_use !== "boolean") {
|
|
3340
|
+
throw unexpectedResponseError("shareResource: response has invalid single_use");
|
|
3341
|
+
}
|
|
3342
|
+
const expiresAt = parseApiDate(typeof wire.expires_at === "string" ? wire.expires_at : undefined);
|
|
3343
|
+
if (typeof wire.expires_at === "string" && expiresAt === undefined) {
|
|
3344
|
+
throw unexpectedResponseError("shareResource: response has invalid expires_at");
|
|
3345
|
+
}
|
|
3346
|
+
return new ShareLink({
|
|
3347
|
+
// Repair transport-only edge whitespace on the one-time credential;
|
|
3348
|
+
// ancillary identifiers fail closed instead of being normalized.
|
|
3349
|
+
link: data.qurl.trim(),
|
|
3350
|
+
qurlId: typeof wire.qurl_id === "string" ? wire.qurl_id : undefined,
|
|
3351
|
+
crid: id,
|
|
3352
|
+
type: typeof wire.type === "string" ? wire.type : undefined,
|
|
3353
|
+
expiresAt,
|
|
3354
|
+
expiresInSeconds: typeof wire.expires_in_seconds === "number" ? wire.expires_in_seconds : undefined,
|
|
3355
|
+
singleUse: typeof wire.single_use === "boolean" ? wire.single_use : undefined,
|
|
3356
|
+
});
|
|
3357
|
+
}
|
|
2622
3358
|
/** Revoke a specific qURL token on a resource. */
|
|
2623
3359
|
async revokeResourceQurl(id, qurlId) {
|
|
2624
|
-
|
|
2625
|
-
|
|
3360
|
+
requireResourceCrid(id, "revokeResourceQurl");
|
|
3361
|
+
validatePathId(qurlId, "revokeResourceQurl", "qurl id", QURL_ID_PATH_OPTIONS);
|
|
2626
3362
|
await this.requestNoContent(`/v1/resources/${encodeURIComponent(id)}/qurls/${encodeURIComponent(qurlId)}`);
|
|
2627
3363
|
}
|
|
2628
3364
|
/** Update a specific qURL token on a resource. */
|
|
2629
3365
|
async updateResourceQurl(id, qurlId, input, options) {
|
|
2630
|
-
|
|
2631
|
-
|
|
3366
|
+
requireResourceCrid(id, "updateResourceQurl");
|
|
3367
|
+
validatePathId(qurlId, "updateResourceQurl", "qurl id", QURL_ID_PATH_OPTIONS);
|
|
2632
3368
|
requireObjectInput(input, "updateResourceQurl");
|
|
2633
3369
|
requireNoUnknownFields(input, UPDATE_RESOURCE_QURL_FIELD_KEYS, "updateResourceQurl");
|
|
2634
3370
|
const normalized = normalizePatchFields(input, UPDATE_RESOURCE_QURL_FIELD_KEYS);
|
|
@@ -2644,7 +3380,7 @@ export class QURLClient {
|
|
|
2644
3380
|
* this page and emits a debug log rather than pretending it fetched all pages.
|
|
2645
3381
|
*/
|
|
2646
3382
|
async listResourceSessions(id) {
|
|
2647
|
-
|
|
3383
|
+
requireResourceCrid(id, "listResourceSessions");
|
|
2648
3384
|
const { data, meta } = await this.rawRequest("GET", `/v1/resources/${encodeURIComponent(id)}/sessions`);
|
|
2649
3385
|
if (meta?.has_more || meta?.next_cursor) {
|
|
2650
3386
|
this.log("listResourceSessions: pagination metadata surfaced on unpaginated endpoint", {
|
|
@@ -2661,7 +3397,7 @@ export class QURLClient {
|
|
|
2661
3397
|
}
|
|
2662
3398
|
/** Terminate all active sessions for a resource and return the server count. */
|
|
2663
3399
|
async terminateAllResourceSessions(id) {
|
|
2664
|
-
|
|
3400
|
+
requireResourceCrid(id, "terminateAllResourceSessions");
|
|
2665
3401
|
const path = `/v1/resources/${encodeURIComponent(id)}/sessions`;
|
|
2666
3402
|
const { data, meta, __http_status } = await this.rawRequest("DELETE", path);
|
|
2667
3403
|
if (__http_status === 204) {
|
|
@@ -2676,8 +3412,8 @@ export class QURLClient {
|
|
|
2676
3412
|
}
|
|
2677
3413
|
/** Terminate a specific resource session. */
|
|
2678
3414
|
async terminateResourceSession(id, sessionId) {
|
|
2679
|
-
|
|
2680
|
-
|
|
3415
|
+
requireResourceCrid(id, "terminateResourceSession");
|
|
3416
|
+
validatePathId(sessionId, "terminateResourceSession", "session id");
|
|
2681
3417
|
await this.requestNoContent(`/v1/resources/${encodeURIComponent(id)}/sessions/${encodeURIComponent(sessionId)}`);
|
|
2682
3418
|
}
|
|
2683
3419
|
/** List connector installations. */
|
|
@@ -2780,22 +3516,22 @@ export class QURLClient {
|
|
|
2780
3516
|
}
|
|
2781
3517
|
/** Get custom domain status. */
|
|
2782
3518
|
async getDomain(domain) {
|
|
2783
|
-
|
|
3519
|
+
validatePathId(domain, "getDomain", "domain");
|
|
2784
3520
|
return this.request("GET", `/v1/domains/${encodeURIComponent(domain)}`);
|
|
2785
3521
|
}
|
|
2786
3522
|
/** Remove a custom domain. */
|
|
2787
3523
|
async deleteDomain(domain) {
|
|
2788
|
-
|
|
3524
|
+
validatePathId(domain, "deleteDomain", "domain");
|
|
2789
3525
|
await this.requestNoContent(`/v1/domains/${encodeURIComponent(domain)}`);
|
|
2790
3526
|
}
|
|
2791
3527
|
/** Trigger DNS verification for a custom domain. */
|
|
2792
3528
|
async verifyDomain(domain, options) {
|
|
2793
|
-
|
|
3529
|
+
validatePathId(domain, "verifyDomain", "domain");
|
|
2794
3530
|
return this.request("POST", `/v1/domains/${encodeURIComponent(domain)}/verify`, undefined, options);
|
|
2795
3531
|
}
|
|
2796
3532
|
/** Regenerate a domain verification token. */
|
|
2797
3533
|
async regenerateDomainToken(domain, options) {
|
|
2798
|
-
|
|
3534
|
+
validatePathId(domain, "regenerateDomainToken", "domain");
|
|
2799
3535
|
return this.request("POST", `/v1/domains/${encodeURIComponent(domain)}/regenerate-token`, undefined, options);
|
|
2800
3536
|
}
|
|
2801
3537
|
/** List webhooks. */
|
|
@@ -2831,12 +3567,12 @@ export class QURLClient {
|
|
|
2831
3567
|
}
|
|
2832
3568
|
/** Get a webhook. */
|
|
2833
3569
|
async getWebhook(id) {
|
|
2834
|
-
|
|
3570
|
+
validatePathId(id, "getWebhook");
|
|
2835
3571
|
return this.request("GET", `/v1/webhooks/${encodeURIComponent(id)}`);
|
|
2836
3572
|
}
|
|
2837
3573
|
/** Update a webhook. */
|
|
2838
3574
|
async updateWebhook(id, input, options) {
|
|
2839
|
-
|
|
3575
|
+
validatePathId(id, "updateWebhook");
|
|
2840
3576
|
requireObjectInput(input, "updateWebhook");
|
|
2841
3577
|
requireNoUnknownFields(input, UPDATE_WEBHOOK_FIELD_KEYS, "updateWebhook");
|
|
2842
3578
|
const normalized = normalizePatchFields(input, UPDATE_WEBHOOK_FIELD_KEYS);
|
|
@@ -2846,17 +3582,17 @@ export class QURLClient {
|
|
|
2846
3582
|
}
|
|
2847
3583
|
/** Delete a webhook. */
|
|
2848
3584
|
async deleteWebhook(id) {
|
|
2849
|
-
|
|
3585
|
+
validatePathId(id, "deleteWebhook");
|
|
2850
3586
|
await this.requestNoContent(`/v1/webhooks/${encodeURIComponent(id)}`);
|
|
2851
3587
|
}
|
|
2852
3588
|
/** Regenerate a webhook signing secret. */
|
|
2853
3589
|
async regenerateWebhookSecret(id, options) {
|
|
2854
|
-
|
|
3590
|
+
validatePathId(id, "regenerateWebhookSecret");
|
|
2855
3591
|
return this.request("POST", `/v1/webhooks/${encodeURIComponent(id)}/secret`, undefined, options);
|
|
2856
3592
|
}
|
|
2857
3593
|
/** List delivery attempts for a webhook. */
|
|
2858
3594
|
async listWebhookDeliveries(id, input = {}) {
|
|
2859
|
-
|
|
3595
|
+
validatePathId(id, "listWebhookDeliveries");
|
|
2860
3596
|
const { data, meta } = await this.rawRequest("GET", appendQuery(`/v1/webhooks/${encodeURIComponent(id)}/deliveries`, input, WEBHOOK_DELIVERY_LIST_PARAM_KEYS, "listWebhookDeliveries"));
|
|
2861
3597
|
return pageFromMeta({ deliveries: data ?? [] }, meta);
|
|
2862
3598
|
}
|
|
@@ -2867,7 +3603,7 @@ export class QURLClient {
|
|
|
2867
3603
|
* iteration begins.
|
|
2868
3604
|
*/
|
|
2869
3605
|
listAllWebhookDeliveries(id, input = {}) {
|
|
2870
|
-
|
|
3606
|
+
validatePathId(id, "listAllWebhookDeliveries");
|
|
2871
3607
|
validateListAllInput(input, WEBHOOK_DELIVERY_LIST_PARAM_KEYS, "listAllWebhookDeliveries");
|
|
2872
3608
|
return this.paginateAll("listAllWebhookDeliveries", input, (pageInput) => this.listWebhookDeliveries(id, pageInput), (page) => page.deliveries);
|
|
2873
3609
|
}
|
|
@@ -2876,10 +3612,34 @@ export class QURLClient {
|
|
|
2876
3612
|
requireObjectInput(input, "createApiKey");
|
|
2877
3613
|
requireNoUnknownFields(input, CREATE_API_KEY_FIELD_KEYS, "createApiKey");
|
|
2878
3614
|
const normalizedRecord = normalizePatchFields(input, CREATE_API_KEY_FIELD_KEYS);
|
|
3615
|
+
// `kind` is required by the server and has no default there. Filling it
|
|
3616
|
+
// in keeps the common durable-key call site unchanged.
|
|
3617
|
+
normalizedRecord.kind ??= "api_key";
|
|
3618
|
+
if (normalizedRecord.kind !== "api_key" && normalizedRecord.kind !== "enrollment_token") {
|
|
3619
|
+
throw clientValidationError("createApiKey: kind must be 'api_key' or 'enrollment_token'");
|
|
3620
|
+
}
|
|
3621
|
+
// `scopes` and `target`/`claims` are mutually exclusive by kind, and the
|
|
3622
|
+
// server rejects either mismatch with 400 invalid_input. Fail both
|
|
3623
|
+
// directions locally rather than paying a round trip.
|
|
3624
|
+
const isEnrollmentToken = normalizedRecord.kind === "enrollment_token";
|
|
3625
|
+
if (isEnrollmentToken) {
|
|
3626
|
+
if (normalizedRecord.scopes !== undefined) {
|
|
3627
|
+
throw clientValidationError("createApiKey: scopes is not accepted for kind 'enrollment_token'; " +
|
|
3628
|
+
"the server assigns them from target");
|
|
3629
|
+
}
|
|
3630
|
+
validateEnrollmentTokenFields(normalizedRecord, "createApiKey");
|
|
3631
|
+
}
|
|
3632
|
+
else {
|
|
3633
|
+
for (const field of ["target", "claims", "expires_in"]) {
|
|
3634
|
+
if (normalizedRecord[field] !== undefined) {
|
|
3635
|
+
throw clientValidationError(`createApiKey: ${field} is only accepted for kind 'enrollment_token'`);
|
|
3636
|
+
}
|
|
3637
|
+
}
|
|
3638
|
+
}
|
|
2879
3639
|
const normalized = normalizedRecord;
|
|
2880
3640
|
validateApiKeyWriteFields(normalizedRecord, "createApiKey", {
|
|
2881
3641
|
name: true,
|
|
2882
|
-
scopes:
|
|
3642
|
+
scopes: !isEnrollmentToken,
|
|
2883
3643
|
});
|
|
2884
3644
|
return this.request("POST", "/v1/api-keys", normalized, options);
|
|
2885
3645
|
}
|
|
@@ -2900,7 +3660,7 @@ export class QURLClient {
|
|
|
2900
3660
|
}
|
|
2901
3661
|
/** Update an API key. */
|
|
2902
3662
|
async updateApiKey(keyId, input, options) {
|
|
2903
|
-
|
|
3663
|
+
validatePathId(keyId, "updateApiKey");
|
|
2904
3664
|
requireObjectInput(input, "updateApiKey");
|
|
2905
3665
|
requireNoUnknownFields(input, UPDATE_API_KEY_FIELD_KEYS, "updateApiKey");
|
|
2906
3666
|
const normalized = normalizePatchFields(input, UPDATE_API_KEY_FIELD_KEYS);
|
|
@@ -2910,7 +3670,7 @@ export class QURLClient {
|
|
|
2910
3670
|
}
|
|
2911
3671
|
/** Revoke an API key. */
|
|
2912
3672
|
async revokeApiKey(keyId) {
|
|
2913
|
-
|
|
3673
|
+
validatePathId(keyId, "revokeApiKey");
|
|
2914
3674
|
await this.requestNoContent(`/v1/api-keys/${encodeURIComponent(keyId)}`);
|
|
2915
3675
|
}
|
|
2916
3676
|
/** Redeem an access code. */
|
|
@@ -2928,7 +3688,7 @@ export class QURLClient {
|
|
|
2928
3688
|
requireNoUnknownFields(input, CREATE_ACCESS_CODE_FIELD_KEYS, "createAccessCode");
|
|
2929
3689
|
const normalizedRecord = normalizePatchFields(input, CREATE_ACCESS_CODE_FIELD_KEYS);
|
|
2930
3690
|
const normalized = normalizedRecord;
|
|
2931
|
-
|
|
3691
|
+
requireResourceCrid(normalizedRecord.resource_id, "createAccessCode", "resource_id");
|
|
2932
3692
|
return this.request("POST", "/v1/access-codes", normalized, options);
|
|
2933
3693
|
}
|
|
2934
3694
|
/**
|
|
@@ -2955,7 +3715,7 @@ export class QURLClient {
|
|
|
2955
3715
|
}
|
|
2956
3716
|
/** Revoke an access code. */
|
|
2957
3717
|
async revokeAccessCode(id) {
|
|
2958
|
-
|
|
3718
|
+
validatePathId(id, "revokeAccessCode");
|
|
2959
3719
|
await this.requestNoContent(`/v1/access-codes/${encodeURIComponent(id)}`);
|
|
2960
3720
|
}
|
|
2961
3721
|
// --- Internal HTTP plumbing ---
|
|
@@ -2997,7 +3757,7 @@ export class QURLClient {
|
|
|
2997
3757
|
*/
|
|
2998
3758
|
async rawRequest(method, path, body, rawOptions = {}) {
|
|
2999
3759
|
const url = `${this.baseUrl}${path}`;
|
|
3000
|
-
const { passthroughStatuses = NO_PASSTHROUGH_STATUSES, requestOptions, allowEmptySuccessBody = false, retry = true, generateIdempotencyKey = true, } = rawOptions;
|
|
3760
|
+
const { passthroughStatuses = NO_PASSTHROUGH_STATUSES, requestOptions, allowEmptySuccessBody = false, allowEmptyPassthroughBody = false, retry = true, generateIdempotencyKey = true, ifNoneMatch, captureResponseHeaders = false, onDispatch, } = rawOptions;
|
|
3001
3761
|
const idempotencyKey = idempotencyKeyForRequest(method, requestOptions, generateIdempotencyKey);
|
|
3002
3762
|
const headers = {
|
|
3003
3763
|
Authorization: `Bearer ${this.apiKey}`,
|
|
@@ -3010,6 +3770,9 @@ export class QURLClient {
|
|
|
3010
3770
|
if (idempotencyKey !== undefined) {
|
|
3011
3771
|
headers["Idempotency-Key"] = idempotencyKey;
|
|
3012
3772
|
}
|
|
3773
|
+
if (ifNoneMatch !== undefined) {
|
|
3774
|
+
headers["If-None-Match"] = ifNoneMatch;
|
|
3775
|
+
}
|
|
3013
3776
|
// Reads may retry transient statuses and transport failures. POST/PATCH
|
|
3014
3777
|
// keep status-code retries limited to rate limits and reuse the same
|
|
3015
3778
|
// Idempotency-Key for fetch-level failures. DELETE is deliberately never
|
|
@@ -3046,6 +3809,7 @@ export class QURLClient {
|
|
|
3046
3809
|
// attempt shouldn't poison the next try's deadline. Total
|
|
3047
3810
|
// request bound: roughly `timeout * (maxRetries + 1) +
|
|
3048
3811
|
// sum(retryDelay)`.
|
|
3812
|
+
onDispatch?.();
|
|
3049
3813
|
response = await this.fetchFn(url, {
|
|
3050
3814
|
method,
|
|
3051
3815
|
headers,
|
|
@@ -3221,16 +3985,20 @@ export class QURLClient {
|
|
|
3221
3985
|
data: undefined,
|
|
3222
3986
|
__http_status: response.status,
|
|
3223
3987
|
__http_body_empty: responseBody.length === 0,
|
|
3988
|
+
__http_headers: captureResponseHeaders ? response.headers : undefined,
|
|
3224
3989
|
};
|
|
3225
3990
|
}
|
|
3226
3991
|
// Exact no-content helpers must inspect both axes of the success
|
|
3227
3992
|
// contract. Let only those callers observe an empty non-204 response;
|
|
3228
3993
|
// body-returning methods still reject it as non-JSON below.
|
|
3229
|
-
if (response.ok && allowEmptySuccessBody
|
|
3994
|
+
if (((response.ok && allowEmptySuccessBody) ||
|
|
3995
|
+
(isPassthrough && allowEmptyPassthroughBody)) &&
|
|
3996
|
+
responseBody.length === 0) {
|
|
3230
3997
|
return {
|
|
3231
3998
|
data: undefined,
|
|
3232
3999
|
__http_status: response.status,
|
|
3233
4000
|
__http_body_empty: true,
|
|
4001
|
+
__http_headers: captureResponseHeaders ? response.headers : undefined,
|
|
3234
4002
|
};
|
|
3235
4003
|
}
|
|
3236
4004
|
try {
|
|
@@ -3240,6 +4008,7 @@ export class QURLClient {
|
|
|
3240
4008
|
__http_status: response.status,
|
|
3241
4009
|
// JSON.parse("") throws, so parsed JSON is necessarily non-empty.
|
|
3242
4010
|
__http_body_empty: false,
|
|
4011
|
+
__http_headers: captureResponseHeaders ? response.headers : undefined,
|
|
3243
4012
|
};
|
|
3244
4013
|
}
|
|
3245
4014
|
catch {
|
|
@@ -3350,14 +4119,13 @@ export class QURLClient {
|
|
|
3350
4119
|
const header = response.headers.get("Retry-After");
|
|
3351
4120
|
if (!header)
|
|
3352
4121
|
return undefined;
|
|
3353
|
-
// TODO: parse HTTP-date format per RFC 7231 §7.1.3 — currently only
|
|
3354
|
-
// handles delta-seconds; HTTP-date headers fall through to the
|
|
3355
|
-
// exponential-backoff branch in `retryDelay`. Tracked in #61.
|
|
3356
|
-
//
|
|
3357
|
-
// Digit-only pre-check rather than `parseInt` alone: `parseInt("60abc")`
|
|
3358
|
-
// returns `60` and would silently honor a malformed header. The
|
|
3359
|
-
// strict check makes any deviation observable (debug log) instead.
|
|
3360
4122
|
const trimmed = header.trim();
|
|
4123
|
+
if (/^[A-Z][a-z]{2}, \d{2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} GMT$/.test(trimmed)) {
|
|
4124
|
+
const seconds = Math.ceil((Date.parse(trimmed) - Date.now()) / 1000);
|
|
4125
|
+
return Number.isSafeInteger(seconds) && seconds > 0 && seconds <= RETRY_AFTER_PARSE_LIMIT_S
|
|
4126
|
+
? seconds
|
|
4127
|
+
: undefined;
|
|
4128
|
+
}
|
|
3361
4129
|
if (!/^\d+$/.test(trimmed)) {
|
|
3362
4130
|
this.log("Retry-After header was non-numeric, using exponential backoff", {
|
|
3363
4131
|
header_value: header.slice(0, 100),
|
|
@@ -3442,7 +4210,7 @@ export class QURLClient {
|
|
|
3442
4210
|
/**
|
|
3443
4211
|
* A LayerV-protected resource, bound to the client that produced it.
|
|
3444
4212
|
*
|
|
3445
|
-
* Obtained from {@link QURLClient.protectUrl} or {@link QURLClient.
|
|
4213
|
+
* Obtained from {@link QURLClient.protectUrl} or {@link QURLClient.resourceByCrid} —
|
|
3446
4214
|
* not constructed directly. Mint short-lived access links for the resource
|
|
3447
4215
|
* with {@link createPortal} (qurl-go: `qurl.Resource`).
|
|
3448
4216
|
*/
|
|
@@ -3451,19 +4219,19 @@ export class ProtectedResource {
|
|
|
3451
4219
|
// CONTRIBUTING.md's dual-build rule bars) so the binding stays on the
|
|
3452
4220
|
// instance and out of Object.keys/JSON.stringify output.
|
|
3453
4221
|
#client;
|
|
3454
|
-
/** The
|
|
3455
|
-
|
|
4222
|
+
/** The public resource CRID. */
|
|
4223
|
+
crid;
|
|
3456
4224
|
/** The private URL protected by this resource, when known. */
|
|
3457
4225
|
targetUrl;
|
|
3458
4226
|
/**
|
|
3459
4227
|
* Full server-populated {@link Resource} metadata when the handle came
|
|
3460
|
-
* from an API response; `undefined` for {@link QURLClient.
|
|
4228
|
+
* from an API response; `undefined` for {@link QURLClient.resourceByCrid}
|
|
3461
4229
|
* handles and for the one-call {@link QURLClient.createPortalForUrl} path.
|
|
3462
4230
|
*/
|
|
3463
4231
|
details;
|
|
3464
4232
|
constructor(client, id, targetUrl, details) {
|
|
3465
4233
|
this.#client = client;
|
|
3466
|
-
this.
|
|
4234
|
+
this.crid = id;
|
|
3467
4235
|
this.targetUrl = targetUrl;
|
|
3468
4236
|
this.details = details;
|
|
3469
4237
|
}
|
|
@@ -3494,8 +4262,8 @@ const CONNECTOR_RESOURCE_CONSTRUCTOR_TOKEN = Symbol("validated ConnectorResource
|
|
|
3494
4262
|
*/
|
|
3495
4263
|
export class ConnectorResource {
|
|
3496
4264
|
#client;
|
|
3497
|
-
|
|
3498
|
-
/**
|
|
4265
|
+
resourcePublicKey;
|
|
4266
|
+
/** Required public identifier, verified against the returned resource key. */
|
|
3499
4267
|
crid;
|
|
3500
4268
|
connectorRoutingId;
|
|
3501
4269
|
knockResourceId;
|
|
@@ -3507,7 +4275,7 @@ export class ConnectorResource {
|
|
|
3507
4275
|
throw clientValidationError("ConnectorResource cannot be constructed directly; load it through QURLClient");
|
|
3508
4276
|
}
|
|
3509
4277
|
this.#client = client;
|
|
3510
|
-
this.
|
|
4278
|
+
this.resourcePublicKey = details.resource_id;
|
|
3511
4279
|
this.crid = details.crid;
|
|
3512
4280
|
this.connectorRoutingId = details.connector_routing_id;
|
|
3513
4281
|
this.knockResourceId = details.knock_resource_id;
|
|
@@ -3516,7 +4284,7 @@ export class ConnectorResource {
|
|
|
3516
4284
|
}
|
|
3517
4285
|
/** Mint a short-lived portal for this Connector resource. */
|
|
3518
4286
|
async createPortal(opts = {}, requestOptions) {
|
|
3519
|
-
return this.#client.createPortal(this.
|
|
4287
|
+
return this.#client.createPortal(this.crid, opts, requestOptions);
|
|
3520
4288
|
}
|
|
3521
4289
|
}
|
|
3522
4290
|
//# sourceMappingURL=client.js.map
|