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