@layervai/qurl 0.5.0 → 0.7.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 +160 -11
- package/dist/cjs/client.d.ts +75 -39
- package/dist/cjs/client.js +672 -152
- package/dist/cjs/crid.d.ts +2 -0
- package/dist/cjs/crid.js +53 -0
- package/dist/cjs/errors.d.ts +34 -5
- package/dist/cjs/errors.js +44 -6
- package/dist/cjs/index.d.ts +6 -3
- package/dist/cjs/index.js +13 -1
- package/dist/cjs/share.d.ts +56 -0
- package/dist/cjs/share.js +113 -0
- package/dist/cjs/types.d.ts +84 -13
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/client.d.ts +75 -39
- package/dist/esm/client.d.ts.map +1 -1
- package/dist/esm/client.js +670 -152
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/crid.d.ts +3 -0
- package/dist/esm/crid.d.ts.map +1 -0
- package/dist/esm/crid.js +50 -0
- package/dist/esm/crid.js.map +1 -0
- package/dist/esm/errors.d.ts +34 -5
- package/dist/esm/errors.d.ts.map +1 -1
- package/dist/esm/errors.js +42 -5
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/index.d.ts +6 -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/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 +84 -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 +2 -2
package/dist/esm/client.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { parseCrid, cridKeyMatches } from "./crid.js";
|
|
2
|
+
import { ConnectorResourceOutcomeUnknownError, createError, 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;
|
|
@@ -173,6 +175,10 @@ const CREATE_QURL_FOR_RESOURCE_FIELD_KEYS = [
|
|
|
173
175
|
"access_policy",
|
|
174
176
|
"target_path",
|
|
175
177
|
];
|
|
178
|
+
const SHARE_RESOURCE_OPTION_KEYS = [
|
|
179
|
+
"ttlSeconds",
|
|
180
|
+
];
|
|
181
|
+
assertExhaustive(true);
|
|
176
182
|
assertExhaustive(true);
|
|
177
183
|
/**
|
|
178
184
|
* Options accepted by the portal-verb `protectUrl` / `createPortal`
|
|
@@ -231,11 +237,12 @@ const UPDATE_WEBHOOK_FIELD_KEYS = [
|
|
|
231
237
|
];
|
|
232
238
|
assertExhaustive(true);
|
|
233
239
|
const CREATE_API_KEY_FIELD_KEYS = [
|
|
240
|
+
"kind",
|
|
234
241
|
"name",
|
|
235
242
|
"scopes",
|
|
243
|
+
"target",
|
|
244
|
+
"claims",
|
|
236
245
|
"expires_in",
|
|
237
|
-
"purpose",
|
|
238
|
-
"tunnel_slug",
|
|
239
246
|
];
|
|
240
247
|
assertExhaustive(true);
|
|
241
248
|
const UPDATE_API_KEY_FIELD_KEYS = [
|
|
@@ -320,14 +327,14 @@ function validateRequestOptions(options) {
|
|
|
320
327
|
}
|
|
321
328
|
}
|
|
322
329
|
}
|
|
323
|
-
function idempotencyKeyForRequest(method, options) {
|
|
330
|
+
function idempotencyKeyForRequest(method, options, generateWhenMissing = true) {
|
|
324
331
|
// Validate before method gating so future read-only methods that accept
|
|
325
332
|
// RequestOptions fail loudly on malformed options instead of discarding them.
|
|
326
333
|
validateRequestOptions(options);
|
|
327
334
|
if (!IDEMPOTENCY_KEY_METHODS.has(method)) {
|
|
328
335
|
return undefined;
|
|
329
336
|
}
|
|
330
|
-
return options?.idempotencyKey ?? generateUuidV7();
|
|
337
|
+
return options?.idempotencyKey ?? (generateWhenMissing ? generateUuidV7() : undefined);
|
|
331
338
|
}
|
|
332
339
|
function generateUuidV7() {
|
|
333
340
|
const bytes = new Uint8Array(16);
|
|
@@ -715,16 +722,58 @@ const MAX_DESCRIPTION = 500;
|
|
|
715
722
|
const MAX_CUSTOM_DOMAIN = 253;
|
|
716
723
|
const MAX_MAX_SESSIONS = 1000;
|
|
717
724
|
const MAX_API_KEY_NAME = 100;
|
|
725
|
+
const API_KEY_REQUEST_SCOPE_VALUES = [
|
|
726
|
+
"qurl:read",
|
|
727
|
+
"qurl:write",
|
|
728
|
+
"qurl:resolve",
|
|
729
|
+
"qurl:agent",
|
|
730
|
+
];
|
|
731
|
+
assertExhaustive(true);
|
|
732
|
+
const API_KEY_REQUEST_SCOPES = new Set(API_KEY_REQUEST_SCOPE_VALUES);
|
|
733
|
+
/** Narrow a response scope before reusing it in a create/update request. */
|
|
734
|
+
export function isApiKeyRequestScope(scope) {
|
|
735
|
+
return API_KEY_REQUEST_SCOPES.has(scope);
|
|
736
|
+
}
|
|
737
|
+
const CREDENTIAL_TARGET_VALUES = [
|
|
738
|
+
"agent",
|
|
739
|
+
"connector",
|
|
740
|
+
];
|
|
741
|
+
assertExhaustive(true);
|
|
742
|
+
const CREDENTIAL_TARGETS = new Set(CREDENTIAL_TARGET_VALUES);
|
|
743
|
+
const CREDENTIAL_CLAIM_TYPE_VALUES = [
|
|
744
|
+
"connector",
|
|
745
|
+
];
|
|
746
|
+
assertExhaustive(true);
|
|
747
|
+
const CREDENTIAL_CLAIM_TYPES = new Set(CREDENTIAL_CLAIM_TYPE_VALUES);
|
|
748
|
+
// Mirrors CreateApiKeyRequest.expires_in in the service OpenAPI. Composite
|
|
749
|
+
// durations are deliberately invalid on this endpoint. The 24h cap permits
|
|
750
|
+
// 1d, but no positive whole-week duration.
|
|
751
|
+
const CREDENTIAL_DURATION_PATTERN = /^([0-9]+)([smhdw])$/;
|
|
752
|
+
const CREDENTIAL_DURATION_MULTIPLIERS = {
|
|
753
|
+
s: 1n,
|
|
754
|
+
m: 60n,
|
|
755
|
+
h: 3600n,
|
|
756
|
+
d: 86400n,
|
|
757
|
+
w: 604800n,
|
|
758
|
+
};
|
|
759
|
+
const MAX_ENROLLMENT_TOKEN_TTL_SECONDS = 86400n;
|
|
718
760
|
const MAX_ALIAS = 64;
|
|
719
761
|
const MAX_SLUG = 64;
|
|
720
762
|
const MAX_TARGET_PATH = 2048;
|
|
721
763
|
const MAX_TAGS = 10;
|
|
722
764
|
const MAX_TAG_LENGTH = 50;
|
|
723
765
|
const MAX_AUTO_PAGINATION_PAGES = 10_000;
|
|
766
|
+
// Keep these identity contracts aligned with qurl-go and the public API.
|
|
767
|
+
const CONNECTOR_SLUG_PATTERN = /^[a-z][a-z0-9-]{1,62}[a-z0-9]$/;
|
|
768
|
+
// Current service schemas intentionally share this grammar, but keep the
|
|
769
|
+
// mutable alias contract separate from the immutable lookup identity.
|
|
770
|
+
const CONNECTOR_ALIAS_PATTERN = /^[a-z][a-z0-9-]{1,62}[a-z0-9]$/;
|
|
771
|
+
const CONNECTOR_RESOURCE_ID_PATTERN = /^[A-Za-z0-9_-]{122}$/;
|
|
772
|
+
const CONNECTOR_RESOURCE_ID_DER_PREFIX = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE";
|
|
773
|
+
const CONNECTOR_ROUTING_ID_PATTERN = /^c-[a-z2-7]{51}[aq]$/;
|
|
724
774
|
// CreateQurlRequest.target_url pattern is loose (just a URI) but
|
|
725
775
|
// UpdateQurlRequest.tags pattern is specific — enforce it here.
|
|
726
776
|
const TAG_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9 _-]*$/;
|
|
727
|
-
const RESOURCE_ID_PREFIX = "r_";
|
|
728
777
|
function requireMaxLength(value, field, max) {
|
|
729
778
|
if (value === undefined)
|
|
730
779
|
return;
|
|
@@ -825,22 +874,279 @@ function pageFromMeta(out, meta) {
|
|
|
825
874
|
page_size: meta?.page_size,
|
|
826
875
|
};
|
|
827
876
|
}
|
|
877
|
+
// Best-effort defence-in-depth against today's access-token grammar. The
|
|
878
|
+
// service remains authoritative because resource IDs and credentials are both
|
|
879
|
+
// opaque and future credential formats may not retain the `at_` prefix. The
|
|
880
|
+
// delimiter requirement cannot collide with current base64url public keys,
|
|
881
|
+
// standard-base64 keys, or base32 CRIDs.
|
|
882
|
+
const PATH_EMBEDDED_ACCESS_TOKEN_RE = /[?&#=:\s]at_/i;
|
|
883
|
+
const PATH_PREFIXED_ACCESS_TOKEN_RE = /\/at_/i;
|
|
884
|
+
const QURL_LINK_URL_RE = /^(?:https?:\/\/)?(?:[a-z0-9-]+\.)*qurl\.link(?::\d{1,5})?(?:\/|[?#])/i;
|
|
885
|
+
// ponytail: today's 22-character token suffix only improves URL-paste diagnostics;
|
|
886
|
+
// other lengths are still rejected as URLs without echoing the input.
|
|
887
|
+
const URL_EMBEDDED_ACCESS_TOKEN_RE = /[?&#=:/]at_[a-z0-9_-]{22}(?:$|[?&#:/])/i;
|
|
888
|
+
// A host without a slash can be a legitimate domain identifier. Require the
|
|
889
|
+
// path boundary so shared validation does not break the domain namespace.
|
|
890
|
+
const PATH_SCHEMELESS_URL_RE = /^[a-z0-9-]+(?:\.[a-z0-9-]+)+(?::\d{1,5})?\//i;
|
|
891
|
+
const QURL_DISPLAY_ID_PREFIX_RE = /^q_/i;
|
|
892
|
+
const MAX_PATH_ID_DECODE_PASSES = 3;
|
|
893
|
+
const MAX_PATH_ID_LENGTH = 4096;
|
|
894
|
+
function isUrlLikePathId(value) {
|
|
895
|
+
return /^https?:\/\//i.test(value) || PATH_SCHEMELESS_URL_RE.test(value);
|
|
896
|
+
}
|
|
897
|
+
const REVOKE_INDIVIDUAL_QURL_RECOVERY = "revoke individual tokens with revokeResourceQurl(crid, qurlId)";
|
|
898
|
+
const DELETE_RESOURCE_RECOVERY = "pass a CRID returned by the API; to revoke one qURL, call revokeResourceQurl(crid, qurlId)";
|
|
899
|
+
const RESOURCE_OR_QURL_ID_PATH_OPTIONS = {
|
|
900
|
+
requireCridUnlessQurlDisplayId: true,
|
|
901
|
+
};
|
|
902
|
+
const QURL_ID_PATH_OPTIONS = {
|
|
903
|
+
rejectBareAccessToken: true,
|
|
904
|
+
accessTokenRecovery: "pass the qURL display ID, not its access token",
|
|
905
|
+
};
|
|
828
906
|
/**
|
|
829
907
|
* Validates that a path-parameter argument is a non-empty string. Some callers
|
|
830
908
|
* pass resource/qURL display IDs, while newer endpoints also pass domains,
|
|
831
909
|
* session IDs, webhook IDs, and API-key IDs, so this intentionally enforces
|
|
832
|
-
* only basic shape and leaves endpoint-specific
|
|
910
|
+
* only basic shape plus transport-safety rules and leaves endpoint-specific
|
|
911
|
+
* identifier grammar to the service.
|
|
833
912
|
*/
|
|
834
|
-
function
|
|
913
|
+
function validatePathId(id, method, field = "id", options = {}) {
|
|
914
|
+
if (options.requireCridUnlessQurlDisplayId && typeof id === "string" && !id.startsWith("q_")) {
|
|
915
|
+
requireResourceCrid(id, method, field);
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
835
918
|
// `.trim()` catches whitespace-only and padded IDs before they round-trip as
|
|
836
919
|
// `%20...%20` paths that the server can only reject with a less useful 404.
|
|
837
920
|
// The pre-flight error is more actionable than the 404.
|
|
838
|
-
if (typeof id !== "string"
|
|
921
|
+
if (typeof id !== "string") {
|
|
922
|
+
const observedType = id === null ? "null" : typeof id;
|
|
923
|
+
throw clientValidationError(`${method}: ${field} is required (got ${observedType})`);
|
|
924
|
+
}
|
|
925
|
+
if (id.trim() === "") {
|
|
839
926
|
throw clientValidationError(`${method}: ${field} is required`);
|
|
840
927
|
}
|
|
841
928
|
if (id.trim() !== id) {
|
|
842
929
|
throw clientValidationError(`${method}: ${field} must not include leading or trailing whitespace`);
|
|
843
930
|
}
|
|
931
|
+
if (id.length > MAX_PATH_ID_LENGTH) {
|
|
932
|
+
throw clientValidationError(`${method}: ${field} must be ${MAX_PATH_ID_LENGTH} characters or fewer`);
|
|
933
|
+
}
|
|
934
|
+
const decodedIds = decodedPathIdForms(id);
|
|
935
|
+
// URL resolvers and intermediaries can normalize raw or pre-encoded dot
|
|
936
|
+
// segments before routing. Reject every decoded form we inspected so an
|
|
937
|
+
// item operation cannot be retargeted to a collection endpoint. Embedded
|
|
938
|
+
// `a/../b` remains an opaque single segment because the later URL builder
|
|
939
|
+
// percent-encodes its slash.
|
|
940
|
+
if (decodedIds.some((value) => value === "." || value === "..")) {
|
|
941
|
+
throw clientValidationError(`${method}: ${field} is an invalid URL path segment`);
|
|
942
|
+
}
|
|
943
|
+
// `/` is deliberately outside the general delimiter class so a URL path
|
|
944
|
+
// such as `/at_a_glance` is not misread as a credential. For non-URL input,
|
|
945
|
+
// `/at_` remains a strong pasted-path signal. Fragment, query, header, and
|
|
946
|
+
// pasted `Bearer at_...` / `token:at_...` forms are also strong signals.
|
|
947
|
+
// Bare `at_...` values are rejected only for resource/qURL identifiers:
|
|
948
|
+
// those current formats have fixed non-colliding prefixes, while domain,
|
|
949
|
+
// webhook, session, and API-key IDs are deliberately opaque/open contracts.
|
|
950
|
+
// Full URLs and delimiter/path-prefixed credentials remain rejected in
|
|
951
|
+
// every namespace.
|
|
952
|
+
const containsUrl = decodedIds.some(isUrlLikePathId);
|
|
953
|
+
const containsAccessToken = decodedIds.some((value) => {
|
|
954
|
+
const valueIsUrl = isUrlLikePathId(value);
|
|
955
|
+
return ((QURL_LINK_URL_RE.test(value) &&
|
|
956
|
+
(PATH_EMBEDDED_ACCESS_TOKEN_RE.test(value) || PATH_PREFIXED_ACCESS_TOKEN_RE.test(value))) ||
|
|
957
|
+
(valueIsUrl
|
|
958
|
+
? URL_EMBEDDED_ACCESS_TOKEN_RE.test(value)
|
|
959
|
+
: PATH_EMBEDDED_ACCESS_TOKEN_RE.test(value) || PATH_PREFIXED_ACCESS_TOKEN_RE.test(value)) ||
|
|
960
|
+
(options.rejectBareAccessToken === true && value.slice(0, 3).toLowerCase() === "at_"));
|
|
961
|
+
});
|
|
962
|
+
if (containsAccessToken) {
|
|
963
|
+
const recovery = options.accessTokenRecovery ?? "pass the identifier returned by the API";
|
|
964
|
+
throw clientValidationError(`${method}: ${field} must not contain an access token; ${recovery}`);
|
|
965
|
+
}
|
|
966
|
+
// A full target/qURL is a common argument mix-up, but it is distinct from
|
|
967
|
+
// passing an access-token credential. Diagnose it accurately without
|
|
968
|
+
// echoing the URL, which may itself contain sensitive query parameters.
|
|
969
|
+
if (containsUrl) {
|
|
970
|
+
throw clientValidationError(`${method}: ${field} must be an identifier, not a URL`);
|
|
971
|
+
}
|
|
972
|
+
if (options.rejectQurlDisplayId === true &&
|
|
973
|
+
decodedIds.some((value) => QURL_DISPLAY_ID_PREFIX_RE.test(value))) {
|
|
974
|
+
throw clientValidationError(`${method}: ${field} must not be a qURL display ID; ${REVOKE_INDIVIDUAL_QURL_RECOVERY}`);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
function decodedPathIdForms(id) {
|
|
978
|
+
// Probe repeatedly encoded input too. Callers sometimes pre-encode a copied
|
|
979
|
+
// link before passing it through a URL builder that encodes it again. Three
|
|
980
|
+
// passes cover the common accidental cases without allowing hostile input
|
|
981
|
+
// to create unbounded work. More deeply encoded or malformed-percent input
|
|
982
|
+
// deliberately fails open to the authoritative service after safe segment
|
|
983
|
+
// encoding; this heuristic is not an identifier parser.
|
|
984
|
+
const decodedIds = [id];
|
|
985
|
+
let decodedId = id;
|
|
986
|
+
for (let pass = 0; pass < MAX_PATH_ID_DECODE_PASSES; pass += 1) {
|
|
987
|
+
try {
|
|
988
|
+
const next = decodeURIComponent(decodedId);
|
|
989
|
+
if (next === decodedId)
|
|
990
|
+
break;
|
|
991
|
+
decodedIds.push(next);
|
|
992
|
+
decodedId = next;
|
|
993
|
+
}
|
|
994
|
+
catch {
|
|
995
|
+
// Malformed percent escapes remain opaque and are left to the service.
|
|
996
|
+
break;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
return decodedIds;
|
|
1000
|
+
}
|
|
1001
|
+
function requireConnectorSlug(slug, method) {
|
|
1002
|
+
if (typeof slug !== "string" || !CONNECTOR_SLUG_PATTERN.test(slug)) {
|
|
1003
|
+
throw clientValidationError(`${method}: slug must be 3-64 lowercase alphanumeric or hyphen characters, start with a letter, and end alphanumeric`);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
function classifyConnectorMutationFailure(operation, error) {
|
|
1007
|
+
if (!(error instanceof QURLError)) {
|
|
1008
|
+
throw new ConnectorResourceOutcomeUnknownError(new RuntimeError("Unexpected failure after Connector mutation dispatch", { cause: error }));
|
|
1009
|
+
}
|
|
1010
|
+
// Most authoritative 4xx responses prove that the mutation was rejected.
|
|
1011
|
+
// bootstrap_key_consumed is the exception: the service returns it from the
|
|
1012
|
+
// consume phase after the resource may have been bound or found.
|
|
1013
|
+
// Callers perform argument/runtime preflight outside their mutation try
|
|
1014
|
+
// blocks; every other surfaced failure therefore follows dispatch or a
|
|
1015
|
+
// nominal success whose resource contract could not be consumed, so callers
|
|
1016
|
+
// must reconcile before retrying. Ensure's exact 201 + valid resource but
|
|
1017
|
+
// missing found_existing is handled after this classifier: the row proves
|
|
1018
|
+
// the selected resource, while only its required metadata is unavailable.
|
|
1019
|
+
if (operation === "ensure" && error.status === 409 && error.code === "bootstrap_key_consumed") {
|
|
1020
|
+
throw new ConnectorResourceOutcomeUnknownError(error);
|
|
1021
|
+
}
|
|
1022
|
+
if (error.status >= 400 && error.status < 500) {
|
|
1023
|
+
throw error;
|
|
1024
|
+
}
|
|
1025
|
+
throw new ConnectorResourceOutcomeUnknownError(error);
|
|
1026
|
+
}
|
|
1027
|
+
function decodeCanonicalBase64Url(value) {
|
|
1028
|
+
if (!CONNECTOR_RESOURCE_ID_PATTERN.test(value) ||
|
|
1029
|
+
!value.startsWith(CONNECTOR_RESOURCE_ID_DER_PREFIX)) {
|
|
1030
|
+
return undefined;
|
|
1031
|
+
}
|
|
1032
|
+
try {
|
|
1033
|
+
const padded = value.replace(/-/g, "+").replace(/_/g, "/") + "==";
|
|
1034
|
+
const binary = atob(padded);
|
|
1035
|
+
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
1036
|
+
const canonical = btoa(String.fromCharCode(...bytes))
|
|
1037
|
+
.replace(/\+/g, "-")
|
|
1038
|
+
.replace(/\//g, "_")
|
|
1039
|
+
.replace(/=+$/, "");
|
|
1040
|
+
return canonical === value ? bytes : undefined;
|
|
1041
|
+
}
|
|
1042
|
+
catch {
|
|
1043
|
+
return undefined;
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
function requireConnectorSubtleCrypto(method) {
|
|
1047
|
+
if (!globalThis.crypto?.subtle) {
|
|
1048
|
+
throw new RuntimeError(`${method}: requires the Web Crypto SubtleCrypto API`);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
function requireResourceCrid(crid, method, field = "id") {
|
|
1052
|
+
if (parseCrid(crid))
|
|
1053
|
+
return;
|
|
1054
|
+
// Keep actionable paste errors without inspecting valid CRIDs twice.
|
|
1055
|
+
validatePathId(crid, method, field, {
|
|
1056
|
+
rejectBareAccessToken: true,
|
|
1057
|
+
rejectQurlDisplayId: true,
|
|
1058
|
+
accessTokenRecovery: method === "delete" || method === "deleteResource"
|
|
1059
|
+
? DELETE_RESOURCE_RECOVERY
|
|
1060
|
+
: "pass a CRID returned by the API",
|
|
1061
|
+
});
|
|
1062
|
+
throw clientValidationError(`${method}: requires a valid CRID`);
|
|
1063
|
+
}
|
|
1064
|
+
async function importsAsConnectorPublicKey(der) {
|
|
1065
|
+
try {
|
|
1066
|
+
const keyData = new ArrayBuffer(der.byteLength);
|
|
1067
|
+
new Uint8Array(keyData).set(der);
|
|
1068
|
+
await globalThis.crypto.subtle.importKey("spki", keyData, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
|
|
1069
|
+
return true;
|
|
1070
|
+
}
|
|
1071
|
+
catch {
|
|
1072
|
+
return false;
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
function containsControlCharacter(value) {
|
|
1076
|
+
return Array.from(value).some((character) => {
|
|
1077
|
+
const codePoint = character.codePointAt(0);
|
|
1078
|
+
return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
async function verifyResourceIdentity(resource, method, expectedCrid) {
|
|
1082
|
+
if (!resource || typeof resource.resource_id !== "string") {
|
|
1083
|
+
throw unexpectedResponseError(`${method}: response has missing or invalid resource_id`);
|
|
1084
|
+
}
|
|
1085
|
+
const resourceKey = decodeCanonicalBase64Url(resource.resource_id);
|
|
1086
|
+
requireConnectorSubtleCrypto(method);
|
|
1087
|
+
if (!resourceKey || !(await importsAsConnectorPublicKey(resourceKey))) {
|
|
1088
|
+
throw unexpectedResponseError(`${method}: response has missing or invalid resource_id`);
|
|
1089
|
+
}
|
|
1090
|
+
if (expectedCrid !== undefined && resource.crid !== expectedCrid) {
|
|
1091
|
+
throw unexpectedResponseError(`${method}: response crid does not match the request`);
|
|
1092
|
+
}
|
|
1093
|
+
if (!(await cridKeyMatches(resource.crid, resourceKey))) {
|
|
1094
|
+
throw unexpectedResponseError(`${method}: response has missing, invalid, or public-key-mismatched crid`);
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
async function parseConnectorResource(client, value, method, expectation = {}) {
|
|
1098
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
1099
|
+
throw unexpectedResponseError(`${method}: response is missing connector resource data`);
|
|
1100
|
+
}
|
|
1101
|
+
const resource = value;
|
|
1102
|
+
await verifyResourceIdentity(resource, method, expectation.crid);
|
|
1103
|
+
if (typeof resource.connector_routing_id !== "string" ||
|
|
1104
|
+
!CONNECTOR_ROUTING_ID_PATTERN.test(resource.connector_routing_id)) {
|
|
1105
|
+
throw unexpectedResponseError(`${method}: response has missing or invalid connector_routing_id`);
|
|
1106
|
+
}
|
|
1107
|
+
if (typeof resource.knock_resource_id !== "string" ||
|
|
1108
|
+
resource.knock_resource_id.trim() === "" ||
|
|
1109
|
+
resource.knock_resource_id.trim() !== resource.knock_resource_id ||
|
|
1110
|
+
containsControlCharacter(resource.knock_resource_id)) {
|
|
1111
|
+
throw unexpectedResponseError(`${method}: response has missing or invalid knock_resource_id`);
|
|
1112
|
+
}
|
|
1113
|
+
if (resource.resource_id === resource.knock_resource_id ||
|
|
1114
|
+
resource.connector_routing_id === resource.knock_resource_id) {
|
|
1115
|
+
throw unexpectedResponseError(`${method}: response cross-wires resource identity, routing, and admission values`);
|
|
1116
|
+
}
|
|
1117
|
+
if (resource.type !== "tunnel") {
|
|
1118
|
+
throw unexpectedResponseError(`${method}: response resource type is not tunnel`);
|
|
1119
|
+
}
|
|
1120
|
+
if (typeof resource.slug !== "string" || !CONNECTOR_SLUG_PATTERN.test(resource.slug)) {
|
|
1121
|
+
throw unexpectedResponseError(`${method}: response has missing or invalid slug`);
|
|
1122
|
+
}
|
|
1123
|
+
if (expectation.slug !== undefined && resource.slug !== expectation.slug) {
|
|
1124
|
+
throw unexpectedResponseError(`${method}: response slug does not match the request`);
|
|
1125
|
+
}
|
|
1126
|
+
if (resource.alias !== undefined &&
|
|
1127
|
+
resource.alias !== null &&
|
|
1128
|
+
(typeof resource.alias !== "string" || !CONNECTOR_ALIAS_PATTERN.test(resource.alias))) {
|
|
1129
|
+
// The public API intentionally gives mutable aliases and immutable slugs the
|
|
1130
|
+
// same canonical wire grammar (domain aliasPattern/slugPattern). Keeping
|
|
1131
|
+
// response validation aligned avoids accepting a row the service itself
|
|
1132
|
+
// could not create or update.
|
|
1133
|
+
throw unexpectedResponseError(`${method}: response has invalid alias`);
|
|
1134
|
+
}
|
|
1135
|
+
if (resource.status === "revoked") {
|
|
1136
|
+
if (expectation.revokedIsLifecycleError !== true) {
|
|
1137
|
+
throw unexpectedResponseError(`${method}: active-only operation returned a revoked connector resource`);
|
|
1138
|
+
}
|
|
1139
|
+
throw new QURLError({
|
|
1140
|
+
status: 0,
|
|
1141
|
+
code: ERROR_CODE_CONNECTOR_RESOURCE_REVOKED,
|
|
1142
|
+
title: "Connector Resource Revoked",
|
|
1143
|
+
detail: `${method}: qURL Connector resource is revoked`,
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
if (resource.status !== "active") {
|
|
1147
|
+
throw unexpectedResponseError(`${method}: response has invalid resource status`);
|
|
1148
|
+
}
|
|
1149
|
+
return new ConnectorResource(client, resource, CONNECTOR_RESOURCE_CONSTRUCTOR_TOKEN);
|
|
844
1150
|
}
|
|
845
1151
|
function requireValidTags(tags) {
|
|
846
1152
|
// null tolerance for untyped-JS callers — treated as "don't touch."
|
|
@@ -1210,6 +1516,56 @@ function validateApiKeyWriteFields(input, method, requiredFields = {}) {
|
|
|
1210
1516
|
if (requiredFields.scopes || input.scopes !== undefined) {
|
|
1211
1517
|
requireNonEmptyArrayField(input, "scopes", method);
|
|
1212
1518
|
requireStringArrayElements(input.scopes, "scopes", method);
|
|
1519
|
+
const unsupported = input.scopes.find((scope) => !API_KEY_REQUEST_SCOPES.has(scope));
|
|
1520
|
+
if (unsupported !== undefined) {
|
|
1521
|
+
throw clientValidationError(`${method}: scopes contains an unsupported permission ${JSON.stringify(unsupported)}; this SDK accepts ${API_KEY_REQUEST_SCOPE_VALUES.join(", ")}`);
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
function validateEnrollmentTokenFields(input, method) {
|
|
1526
|
+
if (input.target !== undefined &&
|
|
1527
|
+
(typeof input.target !== "string" || !CREDENTIAL_TARGETS.has(input.target))) {
|
|
1528
|
+
throw clientValidationError(`${method}: target must be 'agent' or 'connector'`);
|
|
1529
|
+
}
|
|
1530
|
+
let claimCount = 0;
|
|
1531
|
+
if (input.claims !== undefined) {
|
|
1532
|
+
if (!Array.isArray(input.claims)) {
|
|
1533
|
+
throw clientValidationError(`${method}: claims must be an array`);
|
|
1534
|
+
}
|
|
1535
|
+
if (input.claims.length > 1) {
|
|
1536
|
+
throw clientValidationError(`${method}: claims must contain at most one claim`);
|
|
1537
|
+
}
|
|
1538
|
+
claimCount = input.claims.length;
|
|
1539
|
+
for (const [index, claim] of input.claims.entries()) {
|
|
1540
|
+
if (!claim || typeof claim !== "object" || Array.isArray(claim)) {
|
|
1541
|
+
throw clientValidationError(`${method}: claims[${index}] must be an object`);
|
|
1542
|
+
}
|
|
1543
|
+
requireNoUnknownFields(claim, ["type", "id"], `${method}: claims[${index}]`);
|
|
1544
|
+
const record = claim;
|
|
1545
|
+
if (typeof record.type !== "string" || !CREDENTIAL_CLAIM_TYPES.has(record.type)) {
|
|
1546
|
+
throw clientValidationError(`${method}: claims[${index}].type must be 'connector'`);
|
|
1547
|
+
}
|
|
1548
|
+
if (typeof record.id !== "string" || !CONNECTOR_SLUG_PATTERN.test(record.id)) {
|
|
1549
|
+
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`);
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
// CreateApiKeyRequest.target permits an agent with zero or one connector claim.
|
|
1554
|
+
if (input.target === "connector" && claimCount !== 1) {
|
|
1555
|
+
throw clientValidationError(`${method}: target 'connector' requires exactly one claim`);
|
|
1556
|
+
}
|
|
1557
|
+
if (input.expires_in !== undefined) {
|
|
1558
|
+
if (typeof input.expires_in !== "string") {
|
|
1559
|
+
throw clientValidationError(`${method}: expires_in must be a duration string`);
|
|
1560
|
+
}
|
|
1561
|
+
const match = CREDENTIAL_DURATION_PATTERN.exec(input.expires_in);
|
|
1562
|
+
if (!match) {
|
|
1563
|
+
throw clientValidationError(`${method}: expires_in must match <integer><s|m|h|d|w>`);
|
|
1564
|
+
}
|
|
1565
|
+
const seconds = BigInt(match[1]) * CREDENTIAL_DURATION_MULTIPLIERS[match[2]];
|
|
1566
|
+
if (seconds <= 0n || seconds > MAX_ENROLLMENT_TOKEN_TTL_SECONDS) {
|
|
1567
|
+
throw clientValidationError(`${method}: expires_in must be greater than zero and at most 24h`);
|
|
1568
|
+
}
|
|
1213
1569
|
}
|
|
1214
1570
|
}
|
|
1215
1571
|
/**
|
|
@@ -1405,15 +1761,17 @@ function parseApiDate(value) {
|
|
|
1405
1761
|
* check; everything else is optional because the two mint endpoints differ
|
|
1406
1762
|
* in which ancillary fields they return.
|
|
1407
1763
|
*/
|
|
1408
|
-
function parsePortal(data, method) {
|
|
1764
|
+
async function parsePortal(data, method, expectedCrid) {
|
|
1409
1765
|
if (typeof data?.resource_id !== "string" || data.resource_id.trim() === "") {
|
|
1410
1766
|
throw unexpectedResponseError(`${method}: response is missing resource_id`);
|
|
1411
1767
|
}
|
|
1412
1768
|
if (typeof data.qurl_link !== "string" || data.qurl_link.trim() === "") {
|
|
1413
1769
|
throw unexpectedResponseError(`${method}: response is missing qurl_link`);
|
|
1414
1770
|
}
|
|
1771
|
+
await verifyResourceIdentity(data, method, expectedCrid);
|
|
1415
1772
|
return {
|
|
1416
|
-
|
|
1773
|
+
crid: data.crid,
|
|
1774
|
+
resourcePublicKey: data.resource_id,
|
|
1417
1775
|
link: data.qurl_link,
|
|
1418
1776
|
site: data.qurl_site,
|
|
1419
1777
|
expiresAt: parseApiDate(data.expires_at),
|
|
@@ -1424,10 +1782,9 @@ function parsePortal(data, method) {
|
|
|
1424
1782
|
};
|
|
1425
1783
|
}
|
|
1426
1784
|
/**
|
|
1427
|
-
* Validate portal options and build the create-portal wire body
|
|
1428
|
-
*
|
|
1429
|
-
*
|
|
1430
|
-
* createQurlForResource).
|
|
1785
|
+
* Validate portal options and build the create-portal wire body. The service
|
|
1786
|
+
* requires a JSON object even when every option is omitted; `{}` selects the
|
|
1787
|
+
* owner's plan defaults and matches qurl-go's zero-value request struct.
|
|
1431
1788
|
*/
|
|
1432
1789
|
function buildCreatePortalBody(opts, method) {
|
|
1433
1790
|
requireObjectInput(opts, method);
|
|
@@ -1465,7 +1822,7 @@ function buildCreatePortalBody(opts, method) {
|
|
|
1465
1822
|
body.session_duration = wireSessionDuration;
|
|
1466
1823
|
if (targetPath !== undefined)
|
|
1467
1824
|
body.target_path = targetPath;
|
|
1468
|
-
return
|
|
1825
|
+
return body;
|
|
1469
1826
|
}
|
|
1470
1827
|
/** qURL API client. */
|
|
1471
1828
|
export class QURLClient {
|
|
@@ -1761,76 +2118,136 @@ export class QURLClient {
|
|
|
1761
2118
|
body.alias = normalized.alias;
|
|
1762
2119
|
validateResourceWriteFields(body);
|
|
1763
2120
|
const resource = await this.request("POST", "/v1/resources", body, requestOptions);
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
}
|
|
1767
|
-
return new ProtectedResource(this, resource.resource_id,
|
|
2121
|
+
await verifyResourceIdentity(resource, "protectUrl");
|
|
2122
|
+
return new ProtectedResource(this, resource.crid,
|
|
1768
2123
|
// Some resource types redact target_url in responses; fall back to
|
|
1769
2124
|
// the caller-supplied URL so the handle stays useful.
|
|
1770
2125
|
resource.target_url || targetUrl, resource);
|
|
1771
2126
|
}
|
|
1772
2127
|
/**
|
|
1773
|
-
* Return a portal-minting handle for a stored
|
|
2128
|
+
* Return a portal-minting handle for a stored CRID, without an API
|
|
1774
2129
|
* call.
|
|
1775
2130
|
*
|
|
1776
|
-
* Use when you persisted a LayerV
|
|
1777
|
-
* portals for it (qurl-go: `Client.
|
|
2131
|
+
* Use when you persisted a LayerV CRID and want to mint more
|
|
2132
|
+
* portals for it (qurl-go: `Client.ResourceByCRID`):
|
|
1778
2133
|
*
|
|
1779
2134
|
* ```ts
|
|
1780
|
-
* const resource = client.
|
|
2135
|
+
* const resource = client.resourceByCrid(storedCrid);
|
|
1781
2136
|
* const portal = await resource.createPortal({ validFor: "1h" });
|
|
1782
2137
|
* ```
|
|
1783
2138
|
*
|
|
1784
2139
|
* The handle carries no server metadata; use {@link getResource} when you
|
|
1785
2140
|
* need the full resource details.
|
|
1786
2141
|
*/
|
|
1787
|
-
|
|
1788
|
-
|
|
2142
|
+
resourceByCrid(id) {
|
|
2143
|
+
requireResourceCrid(id, "resourceByCrid");
|
|
1789
2144
|
return new ProtectedResource(this, id);
|
|
1790
2145
|
}
|
|
1791
|
-
/**
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
2146
|
+
/** Find or create the active qURL Connector resource for an immutable slug. */
|
|
2147
|
+
async ensureConnectorResource(slug, requestOptions) {
|
|
2148
|
+
requireConnectorSlug(slug, "ensureConnectorResource");
|
|
2149
|
+
// Response validation imports the producer's P-256 resource key. Preflight
|
|
2150
|
+
// before POST so a missing runtime capability cannot orphan a committed row.
|
|
2151
|
+
requireConnectorSubtleCrypto("ensureConnectorResource");
|
|
2152
|
+
// Keep invalid options in the known pre-dispatch error arm. rawRequest
|
|
2153
|
+
// validates again when building the request, which protects future callers
|
|
2154
|
+
// that do not have this mutation-specific outcome contract.
|
|
2155
|
+
validateRequestOptions(requestOptions);
|
|
2156
|
+
let response;
|
|
2157
|
+
let resource;
|
|
2158
|
+
try {
|
|
2159
|
+
response = await this.rawRequest("POST", "/v1/resources", {
|
|
2160
|
+
type: "tunnel",
|
|
2161
|
+
slug,
|
|
2162
|
+
find_or_create: true,
|
|
2163
|
+
}, { requestOptions, retry: false, generateIdempotencyKey: false });
|
|
2164
|
+
// The find-or-create endpoint returns 201 for both newly-created
|
|
2165
|
+
// and found-existing rows; meta.found_existing distinguishes the arms.
|
|
2166
|
+
if (response.__http_status !== 201) {
|
|
2167
|
+
throw unexpectedResponseError(`ensureConnectorResource: expected HTTP 201, got ${response.__http_status ?? "unknown"}`);
|
|
2168
|
+
}
|
|
2169
|
+
resource = await parseConnectorResource(this, response.data, "ensureConnectorResource", {
|
|
2170
|
+
slug,
|
|
2171
|
+
});
|
|
2172
|
+
}
|
|
2173
|
+
catch (error) {
|
|
2174
|
+
classifyConnectorMutationFailure("ensure", error);
|
|
2175
|
+
}
|
|
2176
|
+
if (response.meta !== undefined &&
|
|
2177
|
+
response.meta !== null &&
|
|
2178
|
+
(typeof response.meta !== "object" || Array.isArray(response.meta))) {
|
|
2179
|
+
classifyConnectorMutationFailure("ensure", unexpectedResponseError("ensureConnectorResource: response has invalid meta"));
|
|
2180
|
+
}
|
|
2181
|
+
const foundExisting = response.meta
|
|
2182
|
+
?.found_existing;
|
|
2183
|
+
if (foundExisting === undefined || foundExisting === null) {
|
|
2184
|
+
throw unexpectedResponseError("ensureConnectorResource: response is missing meta.found_existing; the validated resource exists, so reconcile by slug before retrying");
|
|
2185
|
+
}
|
|
2186
|
+
if (typeof foundExisting !== "boolean") {
|
|
2187
|
+
classifyConnectorMutationFailure("ensure", unexpectedResponseError("ensureConnectorResource: response has invalid meta.found_existing"));
|
|
2188
|
+
}
|
|
2189
|
+
return { resource, foundExisting };
|
|
2190
|
+
}
|
|
2191
|
+
/** Fetch a qURL Connector resource by its CRID. */
|
|
2192
|
+
async getConnectorResource(crid) {
|
|
2193
|
+
requireResourceCrid(crid, "getConnectorResource");
|
|
2194
|
+
requireConnectorSubtleCrypto("getConnectorResource");
|
|
2195
|
+
const { data, __http_status } = await this.rawRequest("GET", `/v1/resources/${encodeURIComponent(crid)}`, undefined, { retry: false });
|
|
2196
|
+
if (__http_status !== 200) {
|
|
2197
|
+
throw unexpectedResponseError(`getConnectorResource: expected HTTP 200, got ${__http_status ?? "unknown"}`);
|
|
2198
|
+
}
|
|
2199
|
+
return parseConnectorResource(this, data?.resource, "getConnectorResource", {
|
|
2200
|
+
crid,
|
|
2201
|
+
revokedIsLifecycleError: true,
|
|
2202
|
+
});
|
|
2203
|
+
}
|
|
2204
|
+
/** Fetch the single active qURL Connector resource for an immutable slug. */
|
|
2205
|
+
async getConnectorResourceBySlug(slug) {
|
|
2206
|
+
requireConnectorSlug(slug, "getConnectorResourceBySlug");
|
|
2207
|
+
// Parsing validates the returned P-256 resource key; avoid dispatching a
|
|
2208
|
+
// read that this runtime cannot safely consume.
|
|
2209
|
+
requireConnectorSubtleCrypto("getConnectorResourceBySlug");
|
|
2210
|
+
// The slug point lookup is intrinsically active-only. It also
|
|
2211
|
+
// rejects combining `slug` with `status`, so this query must stay slug-only.
|
|
2212
|
+
const { data, __http_status } = await this.rawRequest("GET", `/v1/resources?slug=${encodeURIComponent(slug)}`, undefined, { retry: false });
|
|
2213
|
+
if (__http_status !== 200) {
|
|
2214
|
+
throw unexpectedResponseError(`getConnectorResourceBySlug: expected HTTP 200, got ${__http_status ?? "unknown"}`);
|
|
2215
|
+
}
|
|
2216
|
+
if (!Array.isArray(data)) {
|
|
2217
|
+
throw unexpectedResponseError("getConnectorResourceBySlug: response has missing or invalid data");
|
|
2218
|
+
}
|
|
2219
|
+
if (data.length === 0) {
|
|
1811
2220
|
throw new NotFoundError({
|
|
1812
2221
|
status: 0,
|
|
1813
2222
|
code: ERROR_CODE_RESOURCE_NOT_FOUND,
|
|
1814
2223
|
title: "Resource Not Found",
|
|
1815
|
-
detail:
|
|
2224
|
+
detail: "getConnectorResourceBySlug: no active resource exists for the requested slug",
|
|
1816
2225
|
});
|
|
1817
2226
|
}
|
|
1818
|
-
if (
|
|
2227
|
+
if (data.length > 1) {
|
|
1819
2228
|
throw new QURLError({
|
|
1820
2229
|
status: 0,
|
|
1821
2230
|
code: ERROR_CODE_AMBIGUOUS_RESOURCE,
|
|
1822
2231
|
title: "Ambiguous Resource",
|
|
1823
|
-
detail: `
|
|
2232
|
+
detail: `getConnectorResourceBySlug: expected one active resource, got ${data.length}`,
|
|
1824
2233
|
});
|
|
1825
2234
|
}
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
2235
|
+
return parseConnectorResource(this, data[0], "getConnectorResourceBySlug", { slug });
|
|
2236
|
+
}
|
|
2237
|
+
/**
|
|
2238
|
+
* Revoke a qURL Connector resource by CRID.
|
|
2239
|
+
*
|
|
2240
|
+
* The API does not apply Idempotency-Key replay to DELETE. After an
|
|
2241
|
+
* outcome-unknown failure, reconcile by ID before issuing a deliberate retry.
|
|
2242
|
+
*/
|
|
2243
|
+
async deleteConnectorResource(crid) {
|
|
2244
|
+
requireResourceCrid(crid, "deleteConnectorResource");
|
|
2245
|
+
try {
|
|
2246
|
+
await this.requestNoContent(`/v1/resources/${encodeURIComponent(crid)}`);
|
|
1829
2247
|
}
|
|
1830
|
-
|
|
1831
|
-
|
|
2248
|
+
catch (error) {
|
|
2249
|
+
classifyConnectorMutationFailure("delete", error);
|
|
1832
2250
|
}
|
|
1833
|
-
return new ProtectedResource(this, resource.resource_id, resource.target_url, resource);
|
|
1834
2251
|
}
|
|
1835
2252
|
/**
|
|
1836
2253
|
* Mint a short-lived qURL link (a portal) for a protected resource.
|
|
@@ -1839,7 +2256,7 @@ export class QURLClient {
|
|
|
1839
2256
|
* reach one private resource. Recipients open `portal.link` directly and
|
|
1840
2257
|
* need no LayerV credentials. Prefer short lifetimes such as
|
|
1841
2258
|
* `{ validFor: "5m" }`. Accepts a {@link ProtectedResource} handle or a
|
|
1842
|
-
*
|
|
2259
|
+
* CRID string. REST-shaped equivalents: {@link mintLink} /
|
|
1843
2260
|
* {@link createQurlForResource}.
|
|
1844
2261
|
*
|
|
1845
2262
|
* Duration options take a string (`"5m"`, `"24h"`; server-validated) or a
|
|
@@ -1858,22 +2275,22 @@ export class QURLClient {
|
|
|
1858
2275
|
if (!ProtectedResource.isBoundTo(resource, this)) {
|
|
1859
2276
|
throw clientValidationError("createPortal: resource is bound to a different client");
|
|
1860
2277
|
}
|
|
1861
|
-
resourceId = resource.
|
|
2278
|
+
resourceId = resource.crid;
|
|
1862
2279
|
}
|
|
1863
2280
|
else {
|
|
1864
|
-
throw clientValidationError(`createPortal: resource must be a ProtectedResource handle or a
|
|
2281
|
+
throw clientValidationError(`createPortal: resource must be a ProtectedResource handle or a CRID string (got ${describeShape(resource)})`);
|
|
1865
2282
|
}
|
|
1866
|
-
|
|
2283
|
+
requireResourceCrid(resourceId, "createPortal");
|
|
1867
2284
|
const body = buildCreatePortalBody(opts, "createPortal");
|
|
1868
2285
|
const data = await this.request("POST", `/v1/resources/${encodeURIComponent(resourceId)}/qurls`, body, requestOptions);
|
|
1869
|
-
return parsePortal(data, "createPortal");
|
|
2286
|
+
return parsePortal(data, "createPortal", resourceId);
|
|
1870
2287
|
}
|
|
1871
2288
|
/**
|
|
1872
2289
|
* Protect `targetUrl` and mint a portal in one API call.
|
|
1873
2290
|
*
|
|
1874
2291
|
* Convenience for one-off scripts (qurl-go: `Client.CreatePortalForURL`).
|
|
1875
2292
|
* The returned resource handle is reusable for minting more portals but
|
|
1876
|
-
* carries
|
|
2293
|
+
* carries the CRID, verification key, and the caller-supplied target URL; use
|
|
1877
2294
|
* {@link protectUrl} when you need the full server-populated resource
|
|
1878
2295
|
* metadata. Portal options match {@link createPortal}; `targetUrl` gets
|
|
1879
2296
|
* the same malformed-URL and embedded-credentials rejection as
|
|
@@ -1888,10 +2305,10 @@ export class QURLClient {
|
|
|
1888
2305
|
...buildCreatePortalBody(opts, "createPortalForUrl"),
|
|
1889
2306
|
};
|
|
1890
2307
|
const data = await this.request("POST", "/v1/qurls", body, requestOptions);
|
|
1891
|
-
const portal = parsePortal(data, "createPortalForUrl");
|
|
2308
|
+
const portal = await parsePortal(data, "createPortalForUrl");
|
|
1892
2309
|
return {
|
|
1893
2310
|
portal,
|
|
1894
|
-
resource: new ProtectedResource(this, portal.
|
|
2311
|
+
resource: new ProtectedResource(this, portal.crid, targetUrl),
|
|
1895
2312
|
};
|
|
1896
2313
|
}
|
|
1897
2314
|
/**
|
|
@@ -1925,7 +2342,7 @@ export class QURLClient {
|
|
|
1925
2342
|
return {
|
|
1926
2343
|
resourceUrl: resolved.target_url,
|
|
1927
2344
|
openSeconds: resolved.access_grant?.expires_in ?? 0,
|
|
1928
|
-
|
|
2345
|
+
resourcePublicKey: resolved.resource_id,
|
|
1929
2346
|
};
|
|
1930
2347
|
}
|
|
1931
2348
|
// --- Public API ---
|
|
@@ -2088,11 +2505,12 @@ export class QURLClient {
|
|
|
2088
2505
|
/**
|
|
2089
2506
|
* Get a qURL resource and its access tokens.
|
|
2090
2507
|
*
|
|
2091
|
-
* Accepts
|
|
2092
|
-
* prefix); the API resolves
|
|
2508
|
+
* Accepts a CRID or qURL
|
|
2509
|
+
* display ID (`q_` prefix); the API resolves display IDs to the parent
|
|
2510
|
+
* resource automatically.
|
|
2093
2511
|
*/
|
|
2094
2512
|
async get(id) {
|
|
2095
|
-
|
|
2513
|
+
validatePathId(id, "get", "id", RESOURCE_OR_QURL_ID_PATH_OPTIONS);
|
|
2096
2514
|
const raw = await this.request("GET", `/v1/qurls/${encodeURIComponent(id)}`);
|
|
2097
2515
|
return this.mapQurlsField(raw);
|
|
2098
2516
|
}
|
|
@@ -2134,49 +2552,20 @@ export class QURLClient {
|
|
|
2134
2552
|
/**
|
|
2135
2553
|
* Delete (revoke) a qURL resource and all its access tokens.
|
|
2136
2554
|
*
|
|
2137
|
-
*
|
|
2138
|
-
*
|
|
2139
|
-
*
|
|
2140
|
-
*
|
|
2555
|
+
* Requires a CRID returned by the API. To revoke one qURL, use
|
|
2556
|
+
* {@link revokeResourceQurl} instead.
|
|
2557
|
+
*
|
|
2558
|
+
* @throws {ValidationError} If `id` is not a canonical CRID.
|
|
2141
2559
|
*/
|
|
2142
2560
|
async delete(id) {
|
|
2143
|
-
|
|
2144
|
-
// is a raw TypeError instead of the structured ValidationError the
|
|
2145
|
-
// rest of the surface produces.
|
|
2146
|
-
if (typeof id !== "string") {
|
|
2147
|
-
throw clientValidationError(`delete: requires a resource ID (${RESOURCE_ID_PREFIX} prefix + suffix) — got ${id === null ? "null" : typeof id}`);
|
|
2148
|
-
}
|
|
2149
|
-
if (id.trim() !== id) {
|
|
2150
|
-
throw clientValidationError("delete: id must not include leading or trailing whitespace");
|
|
2151
|
-
}
|
|
2152
|
-
// Too-short check runs BEFORE the prefix check so it catches
|
|
2153
|
-
// bare-prefix inputs like `"r_"` (right prefix, no suffix) in
|
|
2154
|
-
// addition to `""` / `"x"` / `"ab"` / `"q_"`. Without this
|
|
2155
|
-
// ordering, an exact `"r_"` would pass the startsWith check and
|
|
2156
|
-
// the SDK would send `DELETE /v1/qurls/r_` to the server —
|
|
2157
|
-
// rejected server-side, but this catches it client-side without
|
|
2158
|
-
// a round-trip.
|
|
2159
|
-
if (id.length <= RESOURCE_ID_PREFIX.length) {
|
|
2160
|
-
throw clientValidationError(`delete: requires a resource ID (${RESOURCE_ID_PREFIX} prefix + suffix) — got ${id.length} character${id.length === 1 ? "" : "s"}`);
|
|
2161
|
-
}
|
|
2162
|
-
if (!id.startsWith(RESOURCE_ID_PREFIX)) {
|
|
2163
|
-
// Wrong-prefix branch: the input is long enough to plausibly be
|
|
2164
|
-
// an ID but has the wrong prefix (e.g. `q_3a7f2c8e91b`).
|
|
2165
|
-
// Echo only the 2-char prefix — never the raw ID
|
|
2166
|
-
// — so observability pipelines don't end up with caller-supplied
|
|
2167
|
-
// identifiers in error logs.
|
|
2168
|
-
const observedPrefix = id.slice(0, RESOURCE_ID_PREFIX.length);
|
|
2169
|
-
throw clientValidationError(`delete: only resource IDs (${RESOURCE_ID_PREFIX} prefix) are accepted — ` +
|
|
2170
|
-
`got an ID starting with "${observedPrefix}". ` +
|
|
2171
|
-
"To revoke a single access token, use the resource-scoped token endpoint.");
|
|
2172
|
-
}
|
|
2561
|
+
requireResourceCrid(id, "delete");
|
|
2173
2562
|
await this.requestNoContent(`/v1/qurls/${encodeURIComponent(id)}`);
|
|
2174
2563
|
}
|
|
2175
2564
|
/**
|
|
2176
2565
|
* Extend a qURL's expiration.
|
|
2177
2566
|
*
|
|
2178
|
-
* Accepts
|
|
2179
|
-
* prefix).
|
|
2567
|
+
* Accepts a CRID or qURL
|
|
2568
|
+
* display ID (`q_` prefix). Delegates to {@link update} with only the
|
|
2180
2569
|
* expiration fields. `ExtendInput` shares its `extend_by` / `expires_at`
|
|
2181
2570
|
* fields with `UpdateInput` but is *narrower in two ways*: (1) exactly
|
|
2182
2571
|
* one of the two must be present (XOR via `?: never`), where `UpdateInput`
|
|
@@ -2188,7 +2577,7 @@ export class QURLClient {
|
|
|
2188
2577
|
* enforced separately by the runtime check inside `update()`.
|
|
2189
2578
|
*/
|
|
2190
2579
|
async extend(id, input, options) {
|
|
2191
|
-
|
|
2580
|
+
validatePathId(id, "extend", "id", RESOURCE_OR_QURL_ID_PATH_OPTIONS);
|
|
2192
2581
|
requireObjectInput(input, "extend");
|
|
2193
2582
|
const { extend_by, expires_at } = input;
|
|
2194
2583
|
if (extend_by === undefined && expires_at === undefined) {
|
|
@@ -2202,11 +2591,12 @@ export class QURLClient {
|
|
|
2202
2591
|
/**
|
|
2203
2592
|
* Update a qURL — extend expiration, change description, rename tags.
|
|
2204
2593
|
*
|
|
2205
|
-
* Accepts
|
|
2206
|
-
* prefix); the API resolves
|
|
2594
|
+
* Accepts a CRID or qURL
|
|
2595
|
+
* display ID (`q_` prefix); the API resolves display IDs to the parent
|
|
2596
|
+
* resource automatically.
|
|
2207
2597
|
*/
|
|
2208
2598
|
async update(id, input, options) {
|
|
2209
|
-
|
|
2599
|
+
validatePathId(id, "update", "id", RESOURCE_OR_QURL_ID_PATH_OPTIONS);
|
|
2210
2600
|
requireObjectInput(input, "update");
|
|
2211
2601
|
requireNoUnknownFields(input, UPDATE_FIELD_KEYS, "update");
|
|
2212
2602
|
// Normalize null → undefined so untyped-JS callers passing
|
|
@@ -2233,15 +2623,16 @@ export class QURLClient {
|
|
|
2233
2623
|
*
|
|
2234
2624
|
* Portal-flow equivalent: {@link createPortal}.
|
|
2235
2625
|
*
|
|
2236
|
-
* Accepts
|
|
2237
|
-
* prefix); the API resolves
|
|
2626
|
+
* Accepts a CRID or qURL
|
|
2627
|
+
* display ID (`q_` prefix); the API resolves display IDs to the parent
|
|
2628
|
+
* resource automatically.
|
|
2238
2629
|
*
|
|
2239
2630
|
* Passing `{}` or an object with all fields `null`/`undefined` is
|
|
2240
2631
|
* equivalent to omitting the second argument: no body is sent, and the
|
|
2241
2632
|
* server applies its 24h default expiration.
|
|
2242
2633
|
*/
|
|
2243
2634
|
async mintLink(id, input, options) {
|
|
2244
|
-
|
|
2635
|
+
validatePathId(id, "mintLink", "id", RESOURCE_OR_QURL_ID_PATH_OPTIONS);
|
|
2245
2636
|
// Normalize null → omitted so untyped-JS callers passing
|
|
2246
2637
|
// `{ expires_in: null, expires_at: "..." }` don't leak null into
|
|
2247
2638
|
// the wire body via JSON.stringify and don't bypass the XOR check
|
|
@@ -2355,12 +2746,12 @@ export class QURLClient {
|
|
|
2355
2746
|
}
|
|
2356
2747
|
/** Get one resource plus its bounded qURL preview. */
|
|
2357
2748
|
async getResource(id) {
|
|
2358
|
-
|
|
2749
|
+
requireResourceCrid(id, "getResource");
|
|
2359
2750
|
return this.request("GET", `/v1/resources/${encodeURIComponent(id)}`);
|
|
2360
2751
|
}
|
|
2361
2752
|
/** Update resource metadata. */
|
|
2362
2753
|
async updateResource(id, input, options) {
|
|
2363
|
-
|
|
2754
|
+
requireResourceCrid(id, "updateResource");
|
|
2364
2755
|
requireObjectInput(input, "updateResource");
|
|
2365
2756
|
requireNoUnknownFields(input, UPDATE_RESOURCE_FIELD_KEYS, "updateResource");
|
|
2366
2757
|
const validationInput = normalizePatchFields(input, UPDATE_RESOURCE_FIELD_KEYS, { preserveNullFields: ["alias"] });
|
|
@@ -2374,7 +2765,7 @@ export class QURLClient {
|
|
|
2374
2765
|
}
|
|
2375
2766
|
/** Revoke a resource and all of its qURLs. */
|
|
2376
2767
|
async deleteResource(id) {
|
|
2377
|
-
|
|
2768
|
+
requireResourceCrid(id, "deleteResource");
|
|
2378
2769
|
await this.requestNoContent(`/v1/resources/${encodeURIComponent(id)}`);
|
|
2379
2770
|
}
|
|
2380
2771
|
/**
|
|
@@ -2383,7 +2774,7 @@ export class QURLClient {
|
|
|
2383
2774
|
* Portal-flow equivalent: {@link createPortal} (same endpoint).
|
|
2384
2775
|
*/
|
|
2385
2776
|
async createQurlForResource(id, input, options) {
|
|
2386
|
-
|
|
2777
|
+
requireResourceCrid(id, "createQurlForResource");
|
|
2387
2778
|
let normalized = input;
|
|
2388
2779
|
if (input !== undefined) {
|
|
2389
2780
|
requireObjectInput(input, "createQurlForResource");
|
|
@@ -2394,16 +2785,83 @@ export class QURLClient {
|
|
|
2394
2785
|
validateQurlTokenOptions(normalized);
|
|
2395
2786
|
return this.request("POST", `/v1/resources/${encodeURIComponent(id)}/qurls`, normalized, options);
|
|
2396
2787
|
}
|
|
2788
|
+
/**
|
|
2789
|
+
* Mint a fresh, short-lived share link for an existing CRID.
|
|
2790
|
+
* The returned link is secret and is not retrievable after this response.
|
|
2791
|
+
* If you hold the trusted resource DER SPKI, call `ShareLink.verifyCrid()`
|
|
2792
|
+
* before delivery to verify that the response CRID derives from that key.
|
|
2793
|
+
*/
|
|
2794
|
+
async shareResource(id, options = {}, requestOptions) {
|
|
2795
|
+
requireResourceCrid(id, "shareResource");
|
|
2796
|
+
requireObjectInput(options, "shareResource");
|
|
2797
|
+
requireNoUnknownFields(options, SHARE_RESOURCE_OPTION_KEYS, "shareResource");
|
|
2798
|
+
const body = {};
|
|
2799
|
+
if (options.ttlSeconds !== undefined) {
|
|
2800
|
+
if (typeof options.ttlSeconds !== "number" ||
|
|
2801
|
+
!Number.isSafeInteger(options.ttlSeconds) ||
|
|
2802
|
+
options.ttlSeconds <= 0) {
|
|
2803
|
+
throw clientValidationError("shareResource: ttlSeconds must be a positive safe integer");
|
|
2804
|
+
}
|
|
2805
|
+
body.ttl_seconds = options.ttlSeconds;
|
|
2806
|
+
}
|
|
2807
|
+
const data = await this.request("POST", `/v1/resources/${encodeURIComponent(id)}/share`, body, requestOptions);
|
|
2808
|
+
if (typeof data?.qurl !== "string" || data.qurl.trim() === "") {
|
|
2809
|
+
throw unexpectedResponseError("shareResource: response is missing qurl");
|
|
2810
|
+
}
|
|
2811
|
+
if (data.crid !== id) {
|
|
2812
|
+
throw unexpectedResponseError("shareResource: response CRID does not match the requested CRID");
|
|
2813
|
+
}
|
|
2814
|
+
const wire = data;
|
|
2815
|
+
for (const field of ["qurl_id", "type", "expires_at"]) {
|
|
2816
|
+
const value = wire[field];
|
|
2817
|
+
if (value !== undefined && value !== null && typeof value !== "string") {
|
|
2818
|
+
throw unexpectedResponseError(`shareResource: response has invalid ${field}`);
|
|
2819
|
+
}
|
|
2820
|
+
}
|
|
2821
|
+
for (const field of ["qurl_id", "type"]) {
|
|
2822
|
+
const value = wire[field];
|
|
2823
|
+
if (typeof value === "string" && (value.trim() === "" || value.trim() !== value)) {
|
|
2824
|
+
throw unexpectedResponseError(`shareResource: response has invalid ${field}`);
|
|
2825
|
+
}
|
|
2826
|
+
}
|
|
2827
|
+
if (wire.expires_in_seconds !== undefined &&
|
|
2828
|
+
wire.expires_in_seconds !== null &&
|
|
2829
|
+
(typeof wire.expires_in_seconds !== "number" ||
|
|
2830
|
+
!Number.isSafeInteger(wire.expires_in_seconds) ||
|
|
2831
|
+
wire.expires_in_seconds < 0)) {
|
|
2832
|
+
throw unexpectedResponseError("shareResource: response has invalid expires_in_seconds");
|
|
2833
|
+
}
|
|
2834
|
+
if (wire.single_use !== undefined &&
|
|
2835
|
+
wire.single_use !== null &&
|
|
2836
|
+
typeof wire.single_use !== "boolean") {
|
|
2837
|
+
throw unexpectedResponseError("shareResource: response has invalid single_use");
|
|
2838
|
+
}
|
|
2839
|
+
const expiresAt = parseApiDate(typeof wire.expires_at === "string" ? wire.expires_at : undefined);
|
|
2840
|
+
if (typeof wire.expires_at === "string" && expiresAt === undefined) {
|
|
2841
|
+
throw unexpectedResponseError("shareResource: response has invalid expires_at");
|
|
2842
|
+
}
|
|
2843
|
+
return new ShareLink({
|
|
2844
|
+
// Repair transport-only edge whitespace on the one-time credential;
|
|
2845
|
+
// ancillary identifiers fail closed instead of being normalized.
|
|
2846
|
+
link: data.qurl.trim(),
|
|
2847
|
+
qurlId: typeof wire.qurl_id === "string" ? wire.qurl_id : undefined,
|
|
2848
|
+
crid: id,
|
|
2849
|
+
type: typeof wire.type === "string" ? wire.type : undefined,
|
|
2850
|
+
expiresAt,
|
|
2851
|
+
expiresInSeconds: typeof wire.expires_in_seconds === "number" ? wire.expires_in_seconds : undefined,
|
|
2852
|
+
singleUse: typeof wire.single_use === "boolean" ? wire.single_use : undefined,
|
|
2853
|
+
});
|
|
2854
|
+
}
|
|
2397
2855
|
/** Revoke a specific qURL token on a resource. */
|
|
2398
2856
|
async revokeResourceQurl(id, qurlId) {
|
|
2399
|
-
|
|
2400
|
-
|
|
2857
|
+
requireResourceCrid(id, "revokeResourceQurl");
|
|
2858
|
+
validatePathId(qurlId, "revokeResourceQurl", "qurl id", QURL_ID_PATH_OPTIONS);
|
|
2401
2859
|
await this.requestNoContent(`/v1/resources/${encodeURIComponent(id)}/qurls/${encodeURIComponent(qurlId)}`);
|
|
2402
2860
|
}
|
|
2403
2861
|
/** Update a specific qURL token on a resource. */
|
|
2404
2862
|
async updateResourceQurl(id, qurlId, input, options) {
|
|
2405
|
-
|
|
2406
|
-
|
|
2863
|
+
requireResourceCrid(id, "updateResourceQurl");
|
|
2864
|
+
validatePathId(qurlId, "updateResourceQurl", "qurl id", QURL_ID_PATH_OPTIONS);
|
|
2407
2865
|
requireObjectInput(input, "updateResourceQurl");
|
|
2408
2866
|
requireNoUnknownFields(input, UPDATE_RESOURCE_QURL_FIELD_KEYS, "updateResourceQurl");
|
|
2409
2867
|
const normalized = normalizePatchFields(input, UPDATE_RESOURCE_QURL_FIELD_KEYS);
|
|
@@ -2419,7 +2877,7 @@ export class QURLClient {
|
|
|
2419
2877
|
* this page and emits a debug log rather than pretending it fetched all pages.
|
|
2420
2878
|
*/
|
|
2421
2879
|
async listResourceSessions(id) {
|
|
2422
|
-
|
|
2880
|
+
requireResourceCrid(id, "listResourceSessions");
|
|
2423
2881
|
const { data, meta } = await this.rawRequest("GET", `/v1/resources/${encodeURIComponent(id)}/sessions`);
|
|
2424
2882
|
if (meta?.has_more || meta?.next_cursor) {
|
|
2425
2883
|
this.log("listResourceSessions: pagination metadata surfaced on unpaginated endpoint", {
|
|
@@ -2436,7 +2894,7 @@ export class QURLClient {
|
|
|
2436
2894
|
}
|
|
2437
2895
|
/** Terminate all active sessions for a resource and return the server count. */
|
|
2438
2896
|
async terminateAllResourceSessions(id) {
|
|
2439
|
-
|
|
2897
|
+
requireResourceCrid(id, "terminateAllResourceSessions");
|
|
2440
2898
|
const path = `/v1/resources/${encodeURIComponent(id)}/sessions`;
|
|
2441
2899
|
const { data, meta, __http_status } = await this.rawRequest("DELETE", path);
|
|
2442
2900
|
if (__http_status === 204) {
|
|
@@ -2451,8 +2909,8 @@ export class QURLClient {
|
|
|
2451
2909
|
}
|
|
2452
2910
|
/** Terminate a specific resource session. */
|
|
2453
2911
|
async terminateResourceSession(id, sessionId) {
|
|
2454
|
-
|
|
2455
|
-
|
|
2912
|
+
requireResourceCrid(id, "terminateResourceSession");
|
|
2913
|
+
validatePathId(sessionId, "terminateResourceSession", "session id");
|
|
2456
2914
|
await this.requestNoContent(`/v1/resources/${encodeURIComponent(id)}/sessions/${encodeURIComponent(sessionId)}`);
|
|
2457
2915
|
}
|
|
2458
2916
|
/** List connector installations. */
|
|
@@ -2555,22 +3013,22 @@ export class QURLClient {
|
|
|
2555
3013
|
}
|
|
2556
3014
|
/** Get custom domain status. */
|
|
2557
3015
|
async getDomain(domain) {
|
|
2558
|
-
|
|
3016
|
+
validatePathId(domain, "getDomain", "domain");
|
|
2559
3017
|
return this.request("GET", `/v1/domains/${encodeURIComponent(domain)}`);
|
|
2560
3018
|
}
|
|
2561
3019
|
/** Remove a custom domain. */
|
|
2562
3020
|
async deleteDomain(domain) {
|
|
2563
|
-
|
|
3021
|
+
validatePathId(domain, "deleteDomain", "domain");
|
|
2564
3022
|
await this.requestNoContent(`/v1/domains/${encodeURIComponent(domain)}`);
|
|
2565
3023
|
}
|
|
2566
3024
|
/** Trigger DNS verification for a custom domain. */
|
|
2567
3025
|
async verifyDomain(domain, options) {
|
|
2568
|
-
|
|
3026
|
+
validatePathId(domain, "verifyDomain", "domain");
|
|
2569
3027
|
return this.request("POST", `/v1/domains/${encodeURIComponent(domain)}/verify`, undefined, options);
|
|
2570
3028
|
}
|
|
2571
3029
|
/** Regenerate a domain verification token. */
|
|
2572
3030
|
async regenerateDomainToken(domain, options) {
|
|
2573
|
-
|
|
3031
|
+
validatePathId(domain, "regenerateDomainToken", "domain");
|
|
2574
3032
|
return this.request("POST", `/v1/domains/${encodeURIComponent(domain)}/regenerate-token`, undefined, options);
|
|
2575
3033
|
}
|
|
2576
3034
|
/** List webhooks. */
|
|
@@ -2606,12 +3064,12 @@ export class QURLClient {
|
|
|
2606
3064
|
}
|
|
2607
3065
|
/** Get a webhook. */
|
|
2608
3066
|
async getWebhook(id) {
|
|
2609
|
-
|
|
3067
|
+
validatePathId(id, "getWebhook");
|
|
2610
3068
|
return this.request("GET", `/v1/webhooks/${encodeURIComponent(id)}`);
|
|
2611
3069
|
}
|
|
2612
3070
|
/** Update a webhook. */
|
|
2613
3071
|
async updateWebhook(id, input, options) {
|
|
2614
|
-
|
|
3072
|
+
validatePathId(id, "updateWebhook");
|
|
2615
3073
|
requireObjectInput(input, "updateWebhook");
|
|
2616
3074
|
requireNoUnknownFields(input, UPDATE_WEBHOOK_FIELD_KEYS, "updateWebhook");
|
|
2617
3075
|
const normalized = normalizePatchFields(input, UPDATE_WEBHOOK_FIELD_KEYS);
|
|
@@ -2621,17 +3079,17 @@ export class QURLClient {
|
|
|
2621
3079
|
}
|
|
2622
3080
|
/** Delete a webhook. */
|
|
2623
3081
|
async deleteWebhook(id) {
|
|
2624
|
-
|
|
3082
|
+
validatePathId(id, "deleteWebhook");
|
|
2625
3083
|
await this.requestNoContent(`/v1/webhooks/${encodeURIComponent(id)}`);
|
|
2626
3084
|
}
|
|
2627
3085
|
/** Regenerate a webhook signing secret. */
|
|
2628
3086
|
async regenerateWebhookSecret(id, options) {
|
|
2629
|
-
|
|
3087
|
+
validatePathId(id, "regenerateWebhookSecret");
|
|
2630
3088
|
return this.request("POST", `/v1/webhooks/${encodeURIComponent(id)}/secret`, undefined, options);
|
|
2631
3089
|
}
|
|
2632
3090
|
/** List delivery attempts for a webhook. */
|
|
2633
3091
|
async listWebhookDeliveries(id, input = {}) {
|
|
2634
|
-
|
|
3092
|
+
validatePathId(id, "listWebhookDeliveries");
|
|
2635
3093
|
const { data, meta } = await this.rawRequest("GET", appendQuery(`/v1/webhooks/${encodeURIComponent(id)}/deliveries`, input, WEBHOOK_DELIVERY_LIST_PARAM_KEYS, "listWebhookDeliveries"));
|
|
2636
3094
|
return pageFromMeta({ deliveries: data ?? [] }, meta);
|
|
2637
3095
|
}
|
|
@@ -2642,7 +3100,7 @@ export class QURLClient {
|
|
|
2642
3100
|
* iteration begins.
|
|
2643
3101
|
*/
|
|
2644
3102
|
listAllWebhookDeliveries(id, input = {}) {
|
|
2645
|
-
|
|
3103
|
+
validatePathId(id, "listAllWebhookDeliveries");
|
|
2646
3104
|
validateListAllInput(input, WEBHOOK_DELIVERY_LIST_PARAM_KEYS, "listAllWebhookDeliveries");
|
|
2647
3105
|
return this.paginateAll("listAllWebhookDeliveries", input, (pageInput) => this.listWebhookDeliveries(id, pageInput), (page) => page.deliveries);
|
|
2648
3106
|
}
|
|
@@ -2651,10 +3109,34 @@ export class QURLClient {
|
|
|
2651
3109
|
requireObjectInput(input, "createApiKey");
|
|
2652
3110
|
requireNoUnknownFields(input, CREATE_API_KEY_FIELD_KEYS, "createApiKey");
|
|
2653
3111
|
const normalizedRecord = normalizePatchFields(input, CREATE_API_KEY_FIELD_KEYS);
|
|
3112
|
+
// `kind` is required by the server and has no default there. Filling it
|
|
3113
|
+
// in keeps the common durable-key call site unchanged.
|
|
3114
|
+
normalizedRecord.kind ??= "api_key";
|
|
3115
|
+
if (normalizedRecord.kind !== "api_key" && normalizedRecord.kind !== "enrollment_token") {
|
|
3116
|
+
throw clientValidationError("createApiKey: kind must be 'api_key' or 'enrollment_token'");
|
|
3117
|
+
}
|
|
3118
|
+
// `scopes` and `target`/`claims` are mutually exclusive by kind, and the
|
|
3119
|
+
// server rejects either mismatch with 400 invalid_input. Fail both
|
|
3120
|
+
// directions locally rather than paying a round trip.
|
|
3121
|
+
const isEnrollmentToken = normalizedRecord.kind === "enrollment_token";
|
|
3122
|
+
if (isEnrollmentToken) {
|
|
3123
|
+
if (normalizedRecord.scopes !== undefined) {
|
|
3124
|
+
throw clientValidationError("createApiKey: scopes is not accepted for kind 'enrollment_token'; " +
|
|
3125
|
+
"the server assigns them from target");
|
|
3126
|
+
}
|
|
3127
|
+
validateEnrollmentTokenFields(normalizedRecord, "createApiKey");
|
|
3128
|
+
}
|
|
3129
|
+
else {
|
|
3130
|
+
for (const field of ["target", "claims", "expires_in"]) {
|
|
3131
|
+
if (normalizedRecord[field] !== undefined) {
|
|
3132
|
+
throw clientValidationError(`createApiKey: ${field} is only accepted for kind 'enrollment_token'`);
|
|
3133
|
+
}
|
|
3134
|
+
}
|
|
3135
|
+
}
|
|
2654
3136
|
const normalized = normalizedRecord;
|
|
2655
3137
|
validateApiKeyWriteFields(normalizedRecord, "createApiKey", {
|
|
2656
3138
|
name: true,
|
|
2657
|
-
scopes:
|
|
3139
|
+
scopes: !isEnrollmentToken,
|
|
2658
3140
|
});
|
|
2659
3141
|
return this.request("POST", "/v1/api-keys", normalized, options);
|
|
2660
3142
|
}
|
|
@@ -2675,7 +3157,7 @@ export class QURLClient {
|
|
|
2675
3157
|
}
|
|
2676
3158
|
/** Update an API key. */
|
|
2677
3159
|
async updateApiKey(keyId, input, options) {
|
|
2678
|
-
|
|
3160
|
+
validatePathId(keyId, "updateApiKey");
|
|
2679
3161
|
requireObjectInput(input, "updateApiKey");
|
|
2680
3162
|
requireNoUnknownFields(input, UPDATE_API_KEY_FIELD_KEYS, "updateApiKey");
|
|
2681
3163
|
const normalized = normalizePatchFields(input, UPDATE_API_KEY_FIELD_KEYS);
|
|
@@ -2685,7 +3167,7 @@ export class QURLClient {
|
|
|
2685
3167
|
}
|
|
2686
3168
|
/** Revoke an API key. */
|
|
2687
3169
|
async revokeApiKey(keyId) {
|
|
2688
|
-
|
|
3170
|
+
validatePathId(keyId, "revokeApiKey");
|
|
2689
3171
|
await this.requestNoContent(`/v1/api-keys/${encodeURIComponent(keyId)}`);
|
|
2690
3172
|
}
|
|
2691
3173
|
/** Redeem an access code. */
|
|
@@ -2703,7 +3185,7 @@ export class QURLClient {
|
|
|
2703
3185
|
requireNoUnknownFields(input, CREATE_ACCESS_CODE_FIELD_KEYS, "createAccessCode");
|
|
2704
3186
|
const normalizedRecord = normalizePatchFields(input, CREATE_ACCESS_CODE_FIELD_KEYS);
|
|
2705
3187
|
const normalized = normalizedRecord;
|
|
2706
|
-
|
|
3188
|
+
requireResourceCrid(normalizedRecord.resource_id, "createAccessCode", "resource_id");
|
|
2707
3189
|
return this.request("POST", "/v1/access-codes", normalized, options);
|
|
2708
3190
|
}
|
|
2709
3191
|
/**
|
|
@@ -2730,7 +3212,7 @@ export class QURLClient {
|
|
|
2730
3212
|
}
|
|
2731
3213
|
/** Revoke an access code. */
|
|
2732
3214
|
async revokeAccessCode(id) {
|
|
2733
|
-
|
|
3215
|
+
validatePathId(id, "revokeAccessCode");
|
|
2734
3216
|
await this.requestNoContent(`/v1/access-codes/${encodeURIComponent(id)}`);
|
|
2735
3217
|
}
|
|
2736
3218
|
// --- Internal HTTP plumbing ---
|
|
@@ -2772,8 +3254,8 @@ export class QURLClient {
|
|
|
2772
3254
|
*/
|
|
2773
3255
|
async rawRequest(method, path, body, rawOptions = {}) {
|
|
2774
3256
|
const url = `${this.baseUrl}${path}`;
|
|
2775
|
-
const { passthroughStatuses = NO_PASSTHROUGH_STATUSES, requestOptions, allowEmptySuccessBody = false, } = rawOptions;
|
|
2776
|
-
const idempotencyKey = idempotencyKeyForRequest(method, requestOptions);
|
|
3257
|
+
const { passthroughStatuses = NO_PASSTHROUGH_STATUSES, requestOptions, allowEmptySuccessBody = false, retry = true, generateIdempotencyKey = true, } = rawOptions;
|
|
3258
|
+
const idempotencyKey = idempotencyKeyForRequest(method, requestOptions, generateIdempotencyKey);
|
|
2777
3259
|
const headers = {
|
|
2778
3260
|
Authorization: `Bearer ${this.apiKey}`,
|
|
2779
3261
|
Accept: "application/json",
|
|
@@ -2792,13 +3274,15 @@ export class QURLClient {
|
|
|
2792
3274
|
// retry rule and avoids SDK pacing on an application request path. The
|
|
2793
3275
|
// caller receives retryAfter when supplied and must reconcile resource
|
|
2794
3276
|
// state before it chooses to issue another delete.
|
|
2795
|
-
const idempotencyKeyBackedRetry = MUTATING_RETRY_METHODS.has(method);
|
|
2796
|
-
const retryFetchFailure = method === "GET" || idempotencyKeyBackedRetry;
|
|
2797
|
-
const retryable =
|
|
2798
|
-
?
|
|
2799
|
-
:
|
|
2800
|
-
?
|
|
2801
|
-
:
|
|
3277
|
+
const idempotencyKeyBackedRetry = retry && MUTATING_RETRY_METHODS.has(method);
|
|
3278
|
+
const retryFetchFailure = retry && (method === "GET" || idempotencyKeyBackedRetry);
|
|
3279
|
+
const retryable = !retry
|
|
3280
|
+
? NO_RETRYABLE_STATUSES
|
|
3281
|
+
: method === "GET"
|
|
3282
|
+
? RETRYABLE_STATUS
|
|
3283
|
+
: idempotencyKeyBackedRetry
|
|
3284
|
+
? RETRYABLE_STATUS_MUTATING
|
|
3285
|
+
: NO_RETRYABLE_STATUSES;
|
|
2802
3286
|
const serializedBody = body !== undefined ? JSON.stringify(body) : undefined;
|
|
2803
3287
|
let lastError;
|
|
2804
3288
|
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
@@ -3215,8 +3699,7 @@ export class QURLClient {
|
|
|
3215
3699
|
/**
|
|
3216
3700
|
* A LayerV-protected resource, bound to the client that produced it.
|
|
3217
3701
|
*
|
|
3218
|
-
* Obtained from {@link QURLClient.protectUrl}
|
|
3219
|
-
* {@link QURLClient.connectorResource}, or {@link QURLClient.resourceById} —
|
|
3702
|
+
* Obtained from {@link QURLClient.protectUrl} or {@link QURLClient.resourceByCrid} —
|
|
3220
3703
|
* not constructed directly. Mint short-lived access links for the resource
|
|
3221
3704
|
* with {@link createPortal} (qurl-go: `qurl.Resource`).
|
|
3222
3705
|
*/
|
|
@@ -3225,19 +3708,19 @@ export class ProtectedResource {
|
|
|
3225
3708
|
// CONTRIBUTING.md's dual-build rule bars) so the binding stays on the
|
|
3226
3709
|
// instance and out of Object.keys/JSON.stringify output.
|
|
3227
3710
|
#client;
|
|
3228
|
-
/** The
|
|
3229
|
-
|
|
3711
|
+
/** The public resource CRID. */
|
|
3712
|
+
crid;
|
|
3230
3713
|
/** The private URL protected by this resource, when known. */
|
|
3231
3714
|
targetUrl;
|
|
3232
3715
|
/**
|
|
3233
3716
|
* Full server-populated {@link Resource} metadata when the handle came
|
|
3234
|
-
* from an API response; `undefined` for {@link QURLClient.
|
|
3717
|
+
* from an API response; `undefined` for {@link QURLClient.resourceByCrid}
|
|
3235
3718
|
* handles and for the one-call {@link QURLClient.createPortalForUrl} path.
|
|
3236
3719
|
*/
|
|
3237
3720
|
details;
|
|
3238
3721
|
constructor(client, id, targetUrl, details) {
|
|
3239
3722
|
this.#client = client;
|
|
3240
|
-
this.
|
|
3723
|
+
this.crid = id;
|
|
3241
3724
|
this.targetUrl = targetUrl;
|
|
3242
3725
|
this.details = details;
|
|
3243
3726
|
}
|
|
@@ -3258,4 +3741,39 @@ export class ProtectedResource {
|
|
|
3258
3741
|
return this.#client.createPortal(this, opts, requestOptions);
|
|
3259
3742
|
}
|
|
3260
3743
|
}
|
|
3744
|
+
const CONNECTOR_RESOURCE_CONSTRUCTOR_TOKEN = Symbol("validated ConnectorResource");
|
|
3745
|
+
/**
|
|
3746
|
+
* A validated qURL Connector resource bound to the client that loaded it.
|
|
3747
|
+
*
|
|
3748
|
+
* Resource identity, reverse-routing identity, and NHP admission identity are
|
|
3749
|
+
* distinct server-issued values. Callers must consume each field verbatim and
|
|
3750
|
+
* must never derive one from another.
|
|
3751
|
+
*/
|
|
3752
|
+
export class ConnectorResource {
|
|
3753
|
+
#client;
|
|
3754
|
+
resourcePublicKey;
|
|
3755
|
+
/** Required public identifier, verified against the returned resource key. */
|
|
3756
|
+
crid;
|
|
3757
|
+
connectorRoutingId;
|
|
3758
|
+
knockResourceId;
|
|
3759
|
+
slug;
|
|
3760
|
+
alias;
|
|
3761
|
+
/** @internal Instances are returned by QURLClient after wire validation. */
|
|
3762
|
+
constructor(client, details, validationToken) {
|
|
3763
|
+
if (validationToken !== CONNECTOR_RESOURCE_CONSTRUCTOR_TOKEN) {
|
|
3764
|
+
throw clientValidationError("ConnectorResource cannot be constructed directly; load it through QURLClient");
|
|
3765
|
+
}
|
|
3766
|
+
this.#client = client;
|
|
3767
|
+
this.resourcePublicKey = details.resource_id;
|
|
3768
|
+
this.crid = details.crid;
|
|
3769
|
+
this.connectorRoutingId = details.connector_routing_id;
|
|
3770
|
+
this.knockResourceId = details.knock_resource_id;
|
|
3771
|
+
this.slug = details.slug;
|
|
3772
|
+
this.alias = details.alias ?? undefined;
|
|
3773
|
+
}
|
|
3774
|
+
/** Mint a short-lived portal for this Connector resource. */
|
|
3775
|
+
async createPortal(opts = {}, requestOptions) {
|
|
3776
|
+
return this.#client.createPortal(this.crid, opts, requestOptions);
|
|
3777
|
+
}
|
|
3778
|
+
}
|
|
3261
3779
|
//# sourceMappingURL=client.js.map
|