@layervai/qurl 0.6.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 +121 -15
- package/dist/cjs/client.d.ts +44 -31
- package/dist/cjs/client.js +412 -154
- package/dist/cjs/crid.d.ts +2 -0
- package/dist/cjs/crid.js +53 -0
- package/dist/cjs/errors.d.ts +10 -2
- package/dist/cjs/errors.js +11 -3
- package/dist/cjs/index.d.ts +5 -3
- package/dist/cjs/index.js +9 -1
- package/dist/cjs/share.d.ts +56 -0
- package/dist/cjs/share.js +113 -0
- package/dist/cjs/types.d.ts +76 -13
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/client.d.ts +44 -31
- package/dist/esm/client.d.ts.map +1 -1
- package/dist/esm/client.js +411 -154
- 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 +10 -2
- package/dist/esm/errors.d.ts.map +1 -1
- package/dist/esm/errors.js +10 -2
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/index.d.ts +5 -3
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +3 -2
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/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 +76 -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
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 = [
|
|
@@ -718,6 +726,41 @@ 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;
|
|
@@ -735,7 +778,6 @@ const CONNECTOR_ROUTING_ID_PATTERN = /^c-[a-z2-7]{51}[aq]$/;
|
|
|
735
778
|
// CreateQurlRequest.target_url pattern is loose (just a URI) but
|
|
736
779
|
// UpdateQurlRequest.tags pattern is specific — enforce it here.
|
|
737
780
|
const TAG_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9 _-]*$/;
|
|
738
|
-
const RESOURCE_ID_PREFIX = "r_";
|
|
739
781
|
function requireMaxLength(value, field, max) {
|
|
740
782
|
if (value === undefined)
|
|
741
783
|
return;
|
|
@@ -836,22 +878,129 @@ function pageFromMeta(out, meta) {
|
|
|
836
878
|
page_size: meta?.page_size,
|
|
837
879
|
};
|
|
838
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
|
+
};
|
|
839
910
|
/**
|
|
840
911
|
* Validates that a path-parameter argument is a non-empty string. Some callers
|
|
841
912
|
* pass resource/qURL display IDs, while newer endpoints also pass domains,
|
|
842
913
|
* session IDs, webhook IDs, and API-key IDs, so this intentionally enforces
|
|
843
|
-
* 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.
|
|
844
916
|
*/
|
|
845
|
-
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
|
+
}
|
|
846
922
|
// `.trim()` catches whitespace-only and padded IDs before they round-trip as
|
|
847
923
|
// `%20...%20` paths that the server can only reject with a less useful 404.
|
|
848
924
|
// The pre-flight error is more actionable than the 404.
|
|
849
|
-
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() === "") {
|
|
850
930
|
throw clientValidationError(`${method}: ${field} is required`);
|
|
851
931
|
}
|
|
852
932
|
if (id.trim() !== id) {
|
|
853
933
|
throw clientValidationError(`${method}: ${field} must not include leading or trailing whitespace`);
|
|
854
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;
|
|
855
1004
|
}
|
|
856
1005
|
function requireConnectorSlug(slug, method) {
|
|
857
1006
|
if (typeof slug !== "string" || !CONNECTOR_SLUG_PATTERN.test(slug)) {
|
|
@@ -898,29 +1047,23 @@ function decodeCanonicalBase64Url(value) {
|
|
|
898
1047
|
return undefined;
|
|
899
1048
|
}
|
|
900
1049
|
}
|
|
901
|
-
async function isValidConnectorResourceId(value) {
|
|
902
|
-
const der = decodeCanonicalBase64Url(value);
|
|
903
|
-
if (!der)
|
|
904
|
-
return false;
|
|
905
|
-
if (!globalThis.crypto?.subtle) {
|
|
906
|
-
throw new errors_js_1.RuntimeError("qURL Connector resource validation requires the Web Crypto SubtleCrypto API");
|
|
907
|
-
}
|
|
908
|
-
return importsAsConnectorPublicKey(der);
|
|
909
|
-
}
|
|
910
1050
|
function requireConnectorSubtleCrypto(method) {
|
|
911
1051
|
if (!globalThis.crypto?.subtle) {
|
|
912
1052
|
throw new errors_js_1.RuntimeError(`${method}: requires the Web Crypto SubtleCrypto API`);
|
|
913
1053
|
}
|
|
914
1054
|
}
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
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`);
|
|
924
1067
|
}
|
|
925
1068
|
async function importsAsConnectorPublicKey(der) {
|
|
926
1069
|
try {
|
|
@@ -939,23 +1082,28 @@ function containsControlCharacter(value) {
|
|
|
939
1082
|
return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);
|
|
940
1083
|
});
|
|
941
1084
|
}
|
|
942
|
-
async function
|
|
943
|
-
if (typeof
|
|
944
|
-
throw unexpectedResponseError(`${method}: response
|
|
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`);
|
|
945
1088
|
}
|
|
946
|
-
const
|
|
947
|
-
|
|
1089
|
+
const resourceKey = decodeCanonicalBase64Url(resource.resource_id);
|
|
1090
|
+
requireConnectorSubtleCrypto(method);
|
|
1091
|
+
if (!resourceKey || !(await importsAsConnectorPublicKey(resourceKey))) {
|
|
948
1092
|
throw unexpectedResponseError(`${method}: response has missing or invalid resource_id`);
|
|
949
1093
|
}
|
|
950
|
-
if (
|
|
951
|
-
throw unexpectedResponseError(`${method}: response
|
|
1094
|
+
if (expectedCrid !== undefined && resource.crid !== expectedCrid) {
|
|
1095
|
+
throw unexpectedResponseError(`${method}: response crid does not match the request`);
|
|
952
1096
|
}
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
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`);
|
|
958
1104
|
}
|
|
1105
|
+
const resource = value;
|
|
1106
|
+
await verifyResourceIdentity(resource, method, expectation.crid);
|
|
959
1107
|
if (typeof resource.connector_routing_id !== "string" ||
|
|
960
1108
|
!CONNECTOR_ROUTING_ID_PATTERN.test(resource.connector_routing_id)) {
|
|
961
1109
|
throw unexpectedResponseError(`${method}: response has missing or invalid connector_routing_id`);
|
|
@@ -988,12 +1136,6 @@ async function parseConnectorResource(client, value, method, expectation = {}) {
|
|
|
988
1136
|
// could not create or update.
|
|
989
1137
|
throw unexpectedResponseError(`${method}: response has invalid alias`);
|
|
990
1138
|
}
|
|
991
|
-
if (resource.crid !== undefined && typeof resource.crid !== "string") {
|
|
992
|
-
throw unexpectedResponseError(`${method}: response has invalid crid`);
|
|
993
|
-
}
|
|
994
|
-
// Match qurl-go: ConnectorResource carries an optional producer CRID
|
|
995
|
-
// verbatim. Consumers that possess a trusted delivered key can verify the
|
|
996
|
-
// binding separately; this management-plane row is not itself a trust root.
|
|
997
1139
|
if (resource.status === "revoked") {
|
|
998
1140
|
if (expectation.revokedIsLifecycleError !== true) {
|
|
999
1141
|
throw unexpectedResponseError(`${method}: active-only operation returned a revoked connector resource`);
|
|
@@ -1378,6 +1520,56 @@ function validateApiKeyWriteFields(input, method, requiredFields = {}) {
|
|
|
1378
1520
|
if (requiredFields.scopes || input.scopes !== undefined) {
|
|
1379
1521
|
requireNonEmptyArrayField(input, "scopes", method);
|
|
1380
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
|
+
}
|
|
1381
1573
|
}
|
|
1382
1574
|
}
|
|
1383
1575
|
/**
|
|
@@ -1573,15 +1765,17 @@ function parseApiDate(value) {
|
|
|
1573
1765
|
* check; everything else is optional because the two mint endpoints differ
|
|
1574
1766
|
* in which ancillary fields they return.
|
|
1575
1767
|
*/
|
|
1576
|
-
function parsePortal(data, method) {
|
|
1768
|
+
async function parsePortal(data, method, expectedCrid) {
|
|
1577
1769
|
if (typeof data?.resource_id !== "string" || data.resource_id.trim() === "") {
|
|
1578
1770
|
throw unexpectedResponseError(`${method}: response is missing resource_id`);
|
|
1579
1771
|
}
|
|
1580
1772
|
if (typeof data.qurl_link !== "string" || data.qurl_link.trim() === "") {
|
|
1581
1773
|
throw unexpectedResponseError(`${method}: response is missing qurl_link`);
|
|
1582
1774
|
}
|
|
1775
|
+
await verifyResourceIdentity(data, method, expectedCrid);
|
|
1583
1776
|
return {
|
|
1584
|
-
|
|
1777
|
+
crid: data.crid,
|
|
1778
|
+
resourcePublicKey: data.resource_id,
|
|
1585
1779
|
link: data.qurl_link,
|
|
1586
1780
|
site: data.qurl_site,
|
|
1587
1781
|
expiresAt: parseApiDate(data.expires_at),
|
|
@@ -1928,31 +2122,29 @@ class QURLClient {
|
|
|
1928
2122
|
body.alias = normalized.alias;
|
|
1929
2123
|
validateResourceWriteFields(body);
|
|
1930
2124
|
const resource = await this.request("POST", "/v1/resources", body, requestOptions);
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
}
|
|
1934
|
-
return new ProtectedResource(this, resource.resource_id,
|
|
2125
|
+
await verifyResourceIdentity(resource, "protectUrl");
|
|
2126
|
+
return new ProtectedResource(this, resource.crid,
|
|
1935
2127
|
// Some resource types redact target_url in responses; fall back to
|
|
1936
2128
|
// the caller-supplied URL so the handle stays useful.
|
|
1937
2129
|
resource.target_url || targetUrl, resource);
|
|
1938
2130
|
}
|
|
1939
2131
|
/**
|
|
1940
|
-
* Return a portal-minting handle for a stored
|
|
2132
|
+
* Return a portal-minting handle for a stored CRID, without an API
|
|
1941
2133
|
* call.
|
|
1942
2134
|
*
|
|
1943
|
-
* Use when you persisted a LayerV
|
|
1944
|
-
* 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`):
|
|
1945
2137
|
*
|
|
1946
2138
|
* ```ts
|
|
1947
|
-
* const resource = client.
|
|
2139
|
+
* const resource = client.resourceByCrid(storedCrid);
|
|
1948
2140
|
* const portal = await resource.createPortal({ validFor: "1h" });
|
|
1949
2141
|
* ```
|
|
1950
2142
|
*
|
|
1951
2143
|
* The handle carries no server metadata; use {@link getResource} when you
|
|
1952
2144
|
* need the full resource details.
|
|
1953
2145
|
*/
|
|
1954
|
-
|
|
1955
|
-
|
|
2146
|
+
resourceByCrid(id) {
|
|
2147
|
+
requireResourceCrid(id, "resourceByCrid");
|
|
1956
2148
|
return new ProtectedResource(this, id);
|
|
1957
2149
|
}
|
|
1958
2150
|
/** Find or create the active qURL Connector resource for an immutable slug. */
|
|
@@ -2000,15 +2192,16 @@ class QURLClient {
|
|
|
2000
2192
|
}
|
|
2001
2193
|
return { resource, foundExisting };
|
|
2002
2194
|
}
|
|
2003
|
-
/** Fetch a qURL Connector resource by its
|
|
2004
|
-
async getConnectorResource(
|
|
2005
|
-
|
|
2006
|
-
|
|
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 });
|
|
2007
2200
|
if (__http_status !== 200) {
|
|
2008
2201
|
throw unexpectedResponseError(`getConnectorResource: expected HTTP 200, got ${__http_status ?? "unknown"}`);
|
|
2009
2202
|
}
|
|
2010
2203
|
return parseConnectorResource(this, data?.resource, "getConnectorResource", {
|
|
2011
|
-
|
|
2204
|
+
crid,
|
|
2012
2205
|
revokedIsLifecycleError: true,
|
|
2013
2206
|
});
|
|
2014
2207
|
}
|
|
@@ -2046,15 +2239,15 @@ class QURLClient {
|
|
|
2046
2239
|
return parseConnectorResource(this, data[0], "getConnectorResourceBySlug", { slug });
|
|
2047
2240
|
}
|
|
2048
2241
|
/**
|
|
2049
|
-
* Revoke a qURL Connector resource by
|
|
2242
|
+
* Revoke a qURL Connector resource by CRID.
|
|
2050
2243
|
*
|
|
2051
2244
|
* The API does not apply Idempotency-Key replay to DELETE. After an
|
|
2052
2245
|
* outcome-unknown failure, reconcile by ID before issuing a deliberate retry.
|
|
2053
2246
|
*/
|
|
2054
|
-
async deleteConnectorResource(
|
|
2055
|
-
|
|
2247
|
+
async deleteConnectorResource(crid) {
|
|
2248
|
+
requireResourceCrid(crid, "deleteConnectorResource");
|
|
2056
2249
|
try {
|
|
2057
|
-
await this.requestNoContent(`/v1/resources/${encodeURIComponent(
|
|
2250
|
+
await this.requestNoContent(`/v1/resources/${encodeURIComponent(crid)}`);
|
|
2058
2251
|
}
|
|
2059
2252
|
catch (error) {
|
|
2060
2253
|
classifyConnectorMutationFailure("delete", error);
|
|
@@ -2067,7 +2260,7 @@ class QURLClient {
|
|
|
2067
2260
|
* reach one private resource. Recipients open `portal.link` directly and
|
|
2068
2261
|
* need no LayerV credentials. Prefer short lifetimes such as
|
|
2069
2262
|
* `{ validFor: "5m" }`. Accepts a {@link ProtectedResource} handle or a
|
|
2070
|
-
*
|
|
2263
|
+
* CRID string. REST-shaped equivalents: {@link mintLink} /
|
|
2071
2264
|
* {@link createQurlForResource}.
|
|
2072
2265
|
*
|
|
2073
2266
|
* Duration options take a string (`"5m"`, `"24h"`; server-validated) or a
|
|
@@ -2086,22 +2279,22 @@ class QURLClient {
|
|
|
2086
2279
|
if (!ProtectedResource.isBoundTo(resource, this)) {
|
|
2087
2280
|
throw clientValidationError("createPortal: resource is bound to a different client");
|
|
2088
2281
|
}
|
|
2089
|
-
resourceId = resource.
|
|
2282
|
+
resourceId = resource.crid;
|
|
2090
2283
|
}
|
|
2091
2284
|
else {
|
|
2092
|
-
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)})`);
|
|
2093
2286
|
}
|
|
2094
|
-
|
|
2287
|
+
requireResourceCrid(resourceId, "createPortal");
|
|
2095
2288
|
const body = buildCreatePortalBody(opts, "createPortal");
|
|
2096
2289
|
const data = await this.request("POST", `/v1/resources/${encodeURIComponent(resourceId)}/qurls`, body, requestOptions);
|
|
2097
|
-
return parsePortal(data, "createPortal");
|
|
2290
|
+
return parsePortal(data, "createPortal", resourceId);
|
|
2098
2291
|
}
|
|
2099
2292
|
/**
|
|
2100
2293
|
* Protect `targetUrl` and mint a portal in one API call.
|
|
2101
2294
|
*
|
|
2102
2295
|
* Convenience for one-off scripts (qurl-go: `Client.CreatePortalForURL`).
|
|
2103
2296
|
* The returned resource handle is reusable for minting more portals but
|
|
2104
|
-
* carries
|
|
2297
|
+
* carries the CRID, verification key, and the caller-supplied target URL; use
|
|
2105
2298
|
* {@link protectUrl} when you need the full server-populated resource
|
|
2106
2299
|
* metadata. Portal options match {@link createPortal}; `targetUrl` gets
|
|
2107
2300
|
* the same malformed-URL and embedded-credentials rejection as
|
|
@@ -2116,10 +2309,10 @@ class QURLClient {
|
|
|
2116
2309
|
...buildCreatePortalBody(opts, "createPortalForUrl"),
|
|
2117
2310
|
};
|
|
2118
2311
|
const data = await this.request("POST", "/v1/qurls", body, requestOptions);
|
|
2119
|
-
const portal = parsePortal(data, "createPortalForUrl");
|
|
2312
|
+
const portal = await parsePortal(data, "createPortalForUrl");
|
|
2120
2313
|
return {
|
|
2121
2314
|
portal,
|
|
2122
|
-
resource: new ProtectedResource(this, portal.
|
|
2315
|
+
resource: new ProtectedResource(this, portal.crid, targetUrl),
|
|
2123
2316
|
};
|
|
2124
2317
|
}
|
|
2125
2318
|
/**
|
|
@@ -2153,7 +2346,7 @@ class QURLClient {
|
|
|
2153
2346
|
return {
|
|
2154
2347
|
resourceUrl: resolved.target_url,
|
|
2155
2348
|
openSeconds: resolved.access_grant?.expires_in ?? 0,
|
|
2156
|
-
|
|
2349
|
+
resourcePublicKey: resolved.resource_id,
|
|
2157
2350
|
};
|
|
2158
2351
|
}
|
|
2159
2352
|
// --- Public API ---
|
|
@@ -2316,11 +2509,12 @@ class QURLClient {
|
|
|
2316
2509
|
/**
|
|
2317
2510
|
* Get a qURL resource and its access tokens.
|
|
2318
2511
|
*
|
|
2319
|
-
* Accepts
|
|
2320
|
-
* 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.
|
|
2321
2515
|
*/
|
|
2322
2516
|
async get(id) {
|
|
2323
|
-
|
|
2517
|
+
validatePathId(id, "get", "id", RESOURCE_OR_QURL_ID_PATH_OPTIONS);
|
|
2324
2518
|
const raw = await this.request("GET", `/v1/qurls/${encodeURIComponent(id)}`);
|
|
2325
2519
|
return this.mapQurlsField(raw);
|
|
2326
2520
|
}
|
|
@@ -2362,49 +2556,20 @@ class QURLClient {
|
|
|
2362
2556
|
/**
|
|
2363
2557
|
* Delete (revoke) a qURL resource and all its access tokens.
|
|
2364
2558
|
*
|
|
2365
|
-
*
|
|
2366
|
-
*
|
|
2367
|
-
*
|
|
2368
|
-
*
|
|
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.
|
|
2369
2563
|
*/
|
|
2370
2564
|
async delete(id) {
|
|
2371
|
-
|
|
2372
|
-
// is a raw TypeError instead of the structured ValidationError the
|
|
2373
|
-
// rest of the surface produces.
|
|
2374
|
-
if (typeof id !== "string") {
|
|
2375
|
-
throw clientValidationError(`delete: requires a resource ID (${RESOURCE_ID_PREFIX} prefix + suffix) — got ${id === null ? "null" : typeof id}`);
|
|
2376
|
-
}
|
|
2377
|
-
if (id.trim() !== id) {
|
|
2378
|
-
throw clientValidationError("delete: id must not include leading or trailing whitespace");
|
|
2379
|
-
}
|
|
2380
|
-
// Too-short check runs BEFORE the prefix check so it catches
|
|
2381
|
-
// bare-prefix inputs like `"r_"` (right prefix, no suffix) in
|
|
2382
|
-
// addition to `""` / `"x"` / `"ab"` / `"q_"`. Without this
|
|
2383
|
-
// ordering, an exact `"r_"` would pass the startsWith check and
|
|
2384
|
-
// the SDK would send `DELETE /v1/qurls/r_` to the server —
|
|
2385
|
-
// rejected server-side, but this catches it client-side without
|
|
2386
|
-
// a round-trip.
|
|
2387
|
-
if (id.length <= RESOURCE_ID_PREFIX.length) {
|
|
2388
|
-
throw clientValidationError(`delete: requires a resource ID (${RESOURCE_ID_PREFIX} prefix + suffix) — got ${id.length} character${id.length === 1 ? "" : "s"}`);
|
|
2389
|
-
}
|
|
2390
|
-
if (!id.startsWith(RESOURCE_ID_PREFIX)) {
|
|
2391
|
-
// Wrong-prefix branch: the input is long enough to plausibly be
|
|
2392
|
-
// an ID but has the wrong prefix (e.g. `q_3a7f2c8e91b`).
|
|
2393
|
-
// Echo only the 2-char prefix — never the raw ID
|
|
2394
|
-
// — so observability pipelines don't end up with caller-supplied
|
|
2395
|
-
// identifiers in error logs.
|
|
2396
|
-
const observedPrefix = id.slice(0, RESOURCE_ID_PREFIX.length);
|
|
2397
|
-
throw clientValidationError(`delete: only resource IDs (${RESOURCE_ID_PREFIX} prefix) are accepted — ` +
|
|
2398
|
-
`got an ID starting with "${observedPrefix}". ` +
|
|
2399
|
-
"To revoke a single access token, use the resource-scoped token endpoint.");
|
|
2400
|
-
}
|
|
2565
|
+
requireResourceCrid(id, "delete");
|
|
2401
2566
|
await this.requestNoContent(`/v1/qurls/${encodeURIComponent(id)}`);
|
|
2402
2567
|
}
|
|
2403
2568
|
/**
|
|
2404
2569
|
* Extend a qURL's expiration.
|
|
2405
2570
|
*
|
|
2406
|
-
* Accepts
|
|
2407
|
-
* prefix).
|
|
2571
|
+
* Accepts a CRID or qURL
|
|
2572
|
+
* display ID (`q_` prefix). Delegates to {@link update} with only the
|
|
2408
2573
|
* expiration fields. `ExtendInput` shares its `extend_by` / `expires_at`
|
|
2409
2574
|
* fields with `UpdateInput` but is *narrower in two ways*: (1) exactly
|
|
2410
2575
|
* one of the two must be present (XOR via `?: never`), where `UpdateInput`
|
|
@@ -2416,7 +2581,7 @@ class QURLClient {
|
|
|
2416
2581
|
* enforced separately by the runtime check inside `update()`.
|
|
2417
2582
|
*/
|
|
2418
2583
|
async extend(id, input, options) {
|
|
2419
|
-
|
|
2584
|
+
validatePathId(id, "extend", "id", RESOURCE_OR_QURL_ID_PATH_OPTIONS);
|
|
2420
2585
|
requireObjectInput(input, "extend");
|
|
2421
2586
|
const { extend_by, expires_at } = input;
|
|
2422
2587
|
if (extend_by === undefined && expires_at === undefined) {
|
|
@@ -2430,11 +2595,12 @@ class QURLClient {
|
|
|
2430
2595
|
/**
|
|
2431
2596
|
* Update a qURL — extend expiration, change description, rename tags.
|
|
2432
2597
|
*
|
|
2433
|
-
* Accepts
|
|
2434
|
-
* 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.
|
|
2435
2601
|
*/
|
|
2436
2602
|
async update(id, input, options) {
|
|
2437
|
-
|
|
2603
|
+
validatePathId(id, "update", "id", RESOURCE_OR_QURL_ID_PATH_OPTIONS);
|
|
2438
2604
|
requireObjectInput(input, "update");
|
|
2439
2605
|
requireNoUnknownFields(input, UPDATE_FIELD_KEYS, "update");
|
|
2440
2606
|
// Normalize null → undefined so untyped-JS callers passing
|
|
@@ -2461,15 +2627,16 @@ class QURLClient {
|
|
|
2461
2627
|
*
|
|
2462
2628
|
* Portal-flow equivalent: {@link createPortal}.
|
|
2463
2629
|
*
|
|
2464
|
-
* Accepts
|
|
2465
|
-
* 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.
|
|
2466
2633
|
*
|
|
2467
2634
|
* Passing `{}` or an object with all fields `null`/`undefined` is
|
|
2468
2635
|
* equivalent to omitting the second argument: no body is sent, and the
|
|
2469
2636
|
* server applies its 24h default expiration.
|
|
2470
2637
|
*/
|
|
2471
2638
|
async mintLink(id, input, options) {
|
|
2472
|
-
|
|
2639
|
+
validatePathId(id, "mintLink", "id", RESOURCE_OR_QURL_ID_PATH_OPTIONS);
|
|
2473
2640
|
// Normalize null → omitted so untyped-JS callers passing
|
|
2474
2641
|
// `{ expires_in: null, expires_at: "..." }` don't leak null into
|
|
2475
2642
|
// the wire body via JSON.stringify and don't bypass the XOR check
|
|
@@ -2583,12 +2750,12 @@ class QURLClient {
|
|
|
2583
2750
|
}
|
|
2584
2751
|
/** Get one resource plus its bounded qURL preview. */
|
|
2585
2752
|
async getResource(id) {
|
|
2586
|
-
|
|
2753
|
+
requireResourceCrid(id, "getResource");
|
|
2587
2754
|
return this.request("GET", `/v1/resources/${encodeURIComponent(id)}`);
|
|
2588
2755
|
}
|
|
2589
2756
|
/** Update resource metadata. */
|
|
2590
2757
|
async updateResource(id, input, options) {
|
|
2591
|
-
|
|
2758
|
+
requireResourceCrid(id, "updateResource");
|
|
2592
2759
|
requireObjectInput(input, "updateResource");
|
|
2593
2760
|
requireNoUnknownFields(input, UPDATE_RESOURCE_FIELD_KEYS, "updateResource");
|
|
2594
2761
|
const validationInput = normalizePatchFields(input, UPDATE_RESOURCE_FIELD_KEYS, { preserveNullFields: ["alias"] });
|
|
@@ -2602,7 +2769,7 @@ class QURLClient {
|
|
|
2602
2769
|
}
|
|
2603
2770
|
/** Revoke a resource and all of its qURLs. */
|
|
2604
2771
|
async deleteResource(id) {
|
|
2605
|
-
|
|
2772
|
+
requireResourceCrid(id, "deleteResource");
|
|
2606
2773
|
await this.requestNoContent(`/v1/resources/${encodeURIComponent(id)}`);
|
|
2607
2774
|
}
|
|
2608
2775
|
/**
|
|
@@ -2611,7 +2778,7 @@ class QURLClient {
|
|
|
2611
2778
|
* Portal-flow equivalent: {@link createPortal} (same endpoint).
|
|
2612
2779
|
*/
|
|
2613
2780
|
async createQurlForResource(id, input, options) {
|
|
2614
|
-
|
|
2781
|
+
requireResourceCrid(id, "createQurlForResource");
|
|
2615
2782
|
let normalized = input;
|
|
2616
2783
|
if (input !== undefined) {
|
|
2617
2784
|
requireObjectInput(input, "createQurlForResource");
|
|
@@ -2622,16 +2789,83 @@ class QURLClient {
|
|
|
2622
2789
|
validateQurlTokenOptions(normalized);
|
|
2623
2790
|
return this.request("POST", `/v1/resources/${encodeURIComponent(id)}/qurls`, normalized, options);
|
|
2624
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
|
+
}
|
|
2625
2859
|
/** Revoke a specific qURL token on a resource. */
|
|
2626
2860
|
async revokeResourceQurl(id, qurlId) {
|
|
2627
|
-
|
|
2628
|
-
|
|
2861
|
+
requireResourceCrid(id, "revokeResourceQurl");
|
|
2862
|
+
validatePathId(qurlId, "revokeResourceQurl", "qurl id", QURL_ID_PATH_OPTIONS);
|
|
2629
2863
|
await this.requestNoContent(`/v1/resources/${encodeURIComponent(id)}/qurls/${encodeURIComponent(qurlId)}`);
|
|
2630
2864
|
}
|
|
2631
2865
|
/** Update a specific qURL token on a resource. */
|
|
2632
2866
|
async updateResourceQurl(id, qurlId, input, options) {
|
|
2633
|
-
|
|
2634
|
-
|
|
2867
|
+
requireResourceCrid(id, "updateResourceQurl");
|
|
2868
|
+
validatePathId(qurlId, "updateResourceQurl", "qurl id", QURL_ID_PATH_OPTIONS);
|
|
2635
2869
|
requireObjectInput(input, "updateResourceQurl");
|
|
2636
2870
|
requireNoUnknownFields(input, UPDATE_RESOURCE_QURL_FIELD_KEYS, "updateResourceQurl");
|
|
2637
2871
|
const normalized = normalizePatchFields(input, UPDATE_RESOURCE_QURL_FIELD_KEYS);
|
|
@@ -2647,7 +2881,7 @@ class QURLClient {
|
|
|
2647
2881
|
* this page and emits a debug log rather than pretending it fetched all pages.
|
|
2648
2882
|
*/
|
|
2649
2883
|
async listResourceSessions(id) {
|
|
2650
|
-
|
|
2884
|
+
requireResourceCrid(id, "listResourceSessions");
|
|
2651
2885
|
const { data, meta } = await this.rawRequest("GET", `/v1/resources/${encodeURIComponent(id)}/sessions`);
|
|
2652
2886
|
if (meta?.has_more || meta?.next_cursor) {
|
|
2653
2887
|
this.log("listResourceSessions: pagination metadata surfaced on unpaginated endpoint", {
|
|
@@ -2664,7 +2898,7 @@ class QURLClient {
|
|
|
2664
2898
|
}
|
|
2665
2899
|
/** Terminate all active sessions for a resource and return the server count. */
|
|
2666
2900
|
async terminateAllResourceSessions(id) {
|
|
2667
|
-
|
|
2901
|
+
requireResourceCrid(id, "terminateAllResourceSessions");
|
|
2668
2902
|
const path = `/v1/resources/${encodeURIComponent(id)}/sessions`;
|
|
2669
2903
|
const { data, meta, __http_status } = await this.rawRequest("DELETE", path);
|
|
2670
2904
|
if (__http_status === 204) {
|
|
@@ -2679,8 +2913,8 @@ class QURLClient {
|
|
|
2679
2913
|
}
|
|
2680
2914
|
/** Terminate a specific resource session. */
|
|
2681
2915
|
async terminateResourceSession(id, sessionId) {
|
|
2682
|
-
|
|
2683
|
-
|
|
2916
|
+
requireResourceCrid(id, "terminateResourceSession");
|
|
2917
|
+
validatePathId(sessionId, "terminateResourceSession", "session id");
|
|
2684
2918
|
await this.requestNoContent(`/v1/resources/${encodeURIComponent(id)}/sessions/${encodeURIComponent(sessionId)}`);
|
|
2685
2919
|
}
|
|
2686
2920
|
/** List connector installations. */
|
|
@@ -2783,22 +3017,22 @@ class QURLClient {
|
|
|
2783
3017
|
}
|
|
2784
3018
|
/** Get custom domain status. */
|
|
2785
3019
|
async getDomain(domain) {
|
|
2786
|
-
|
|
3020
|
+
validatePathId(domain, "getDomain", "domain");
|
|
2787
3021
|
return this.request("GET", `/v1/domains/${encodeURIComponent(domain)}`);
|
|
2788
3022
|
}
|
|
2789
3023
|
/** Remove a custom domain. */
|
|
2790
3024
|
async deleteDomain(domain) {
|
|
2791
|
-
|
|
3025
|
+
validatePathId(domain, "deleteDomain", "domain");
|
|
2792
3026
|
await this.requestNoContent(`/v1/domains/${encodeURIComponent(domain)}`);
|
|
2793
3027
|
}
|
|
2794
3028
|
/** Trigger DNS verification for a custom domain. */
|
|
2795
3029
|
async verifyDomain(domain, options) {
|
|
2796
|
-
|
|
3030
|
+
validatePathId(domain, "verifyDomain", "domain");
|
|
2797
3031
|
return this.request("POST", `/v1/domains/${encodeURIComponent(domain)}/verify`, undefined, options);
|
|
2798
3032
|
}
|
|
2799
3033
|
/** Regenerate a domain verification token. */
|
|
2800
3034
|
async regenerateDomainToken(domain, options) {
|
|
2801
|
-
|
|
3035
|
+
validatePathId(domain, "regenerateDomainToken", "domain");
|
|
2802
3036
|
return this.request("POST", `/v1/domains/${encodeURIComponent(domain)}/regenerate-token`, undefined, options);
|
|
2803
3037
|
}
|
|
2804
3038
|
/** List webhooks. */
|
|
@@ -2834,12 +3068,12 @@ class QURLClient {
|
|
|
2834
3068
|
}
|
|
2835
3069
|
/** Get a webhook. */
|
|
2836
3070
|
async getWebhook(id) {
|
|
2837
|
-
|
|
3071
|
+
validatePathId(id, "getWebhook");
|
|
2838
3072
|
return this.request("GET", `/v1/webhooks/${encodeURIComponent(id)}`);
|
|
2839
3073
|
}
|
|
2840
3074
|
/** Update a webhook. */
|
|
2841
3075
|
async updateWebhook(id, input, options) {
|
|
2842
|
-
|
|
3076
|
+
validatePathId(id, "updateWebhook");
|
|
2843
3077
|
requireObjectInput(input, "updateWebhook");
|
|
2844
3078
|
requireNoUnknownFields(input, UPDATE_WEBHOOK_FIELD_KEYS, "updateWebhook");
|
|
2845
3079
|
const normalized = normalizePatchFields(input, UPDATE_WEBHOOK_FIELD_KEYS);
|
|
@@ -2849,17 +3083,17 @@ class QURLClient {
|
|
|
2849
3083
|
}
|
|
2850
3084
|
/** Delete a webhook. */
|
|
2851
3085
|
async deleteWebhook(id) {
|
|
2852
|
-
|
|
3086
|
+
validatePathId(id, "deleteWebhook");
|
|
2853
3087
|
await this.requestNoContent(`/v1/webhooks/${encodeURIComponent(id)}`);
|
|
2854
3088
|
}
|
|
2855
3089
|
/** Regenerate a webhook signing secret. */
|
|
2856
3090
|
async regenerateWebhookSecret(id, options) {
|
|
2857
|
-
|
|
3091
|
+
validatePathId(id, "regenerateWebhookSecret");
|
|
2858
3092
|
return this.request("POST", `/v1/webhooks/${encodeURIComponent(id)}/secret`, undefined, options);
|
|
2859
3093
|
}
|
|
2860
3094
|
/** List delivery attempts for a webhook. */
|
|
2861
3095
|
async listWebhookDeliveries(id, input = {}) {
|
|
2862
|
-
|
|
3096
|
+
validatePathId(id, "listWebhookDeliveries");
|
|
2863
3097
|
const { data, meta } = await this.rawRequest("GET", appendQuery(`/v1/webhooks/${encodeURIComponent(id)}/deliveries`, input, WEBHOOK_DELIVERY_LIST_PARAM_KEYS, "listWebhookDeliveries"));
|
|
2864
3098
|
return pageFromMeta({ deliveries: data ?? [] }, meta);
|
|
2865
3099
|
}
|
|
@@ -2870,7 +3104,7 @@ class QURLClient {
|
|
|
2870
3104
|
* iteration begins.
|
|
2871
3105
|
*/
|
|
2872
3106
|
listAllWebhookDeliveries(id, input = {}) {
|
|
2873
|
-
|
|
3107
|
+
validatePathId(id, "listAllWebhookDeliveries");
|
|
2874
3108
|
validateListAllInput(input, WEBHOOK_DELIVERY_LIST_PARAM_KEYS, "listAllWebhookDeliveries");
|
|
2875
3109
|
return this.paginateAll("listAllWebhookDeliveries", input, (pageInput) => this.listWebhookDeliveries(id, pageInput), (page) => page.deliveries);
|
|
2876
3110
|
}
|
|
@@ -2879,10 +3113,34 @@ class QURLClient {
|
|
|
2879
3113
|
requireObjectInput(input, "createApiKey");
|
|
2880
3114
|
requireNoUnknownFields(input, CREATE_API_KEY_FIELD_KEYS, "createApiKey");
|
|
2881
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
|
+
}
|
|
2882
3140
|
const normalized = normalizedRecord;
|
|
2883
3141
|
validateApiKeyWriteFields(normalizedRecord, "createApiKey", {
|
|
2884
3142
|
name: true,
|
|
2885
|
-
scopes:
|
|
3143
|
+
scopes: !isEnrollmentToken,
|
|
2886
3144
|
});
|
|
2887
3145
|
return this.request("POST", "/v1/api-keys", normalized, options);
|
|
2888
3146
|
}
|
|
@@ -2903,7 +3161,7 @@ class QURLClient {
|
|
|
2903
3161
|
}
|
|
2904
3162
|
/** Update an API key. */
|
|
2905
3163
|
async updateApiKey(keyId, input, options) {
|
|
2906
|
-
|
|
3164
|
+
validatePathId(keyId, "updateApiKey");
|
|
2907
3165
|
requireObjectInput(input, "updateApiKey");
|
|
2908
3166
|
requireNoUnknownFields(input, UPDATE_API_KEY_FIELD_KEYS, "updateApiKey");
|
|
2909
3167
|
const normalized = normalizePatchFields(input, UPDATE_API_KEY_FIELD_KEYS);
|
|
@@ -2913,7 +3171,7 @@ class QURLClient {
|
|
|
2913
3171
|
}
|
|
2914
3172
|
/** Revoke an API key. */
|
|
2915
3173
|
async revokeApiKey(keyId) {
|
|
2916
|
-
|
|
3174
|
+
validatePathId(keyId, "revokeApiKey");
|
|
2917
3175
|
await this.requestNoContent(`/v1/api-keys/${encodeURIComponent(keyId)}`);
|
|
2918
3176
|
}
|
|
2919
3177
|
/** Redeem an access code. */
|
|
@@ -2931,7 +3189,7 @@ class QURLClient {
|
|
|
2931
3189
|
requireNoUnknownFields(input, CREATE_ACCESS_CODE_FIELD_KEYS, "createAccessCode");
|
|
2932
3190
|
const normalizedRecord = normalizePatchFields(input, CREATE_ACCESS_CODE_FIELD_KEYS);
|
|
2933
3191
|
const normalized = normalizedRecord;
|
|
2934
|
-
|
|
3192
|
+
requireResourceCrid(normalizedRecord.resource_id, "createAccessCode", "resource_id");
|
|
2935
3193
|
return this.request("POST", "/v1/access-codes", normalized, options);
|
|
2936
3194
|
}
|
|
2937
3195
|
/**
|
|
@@ -2958,7 +3216,7 @@ class QURLClient {
|
|
|
2958
3216
|
}
|
|
2959
3217
|
/** Revoke an access code. */
|
|
2960
3218
|
async revokeAccessCode(id) {
|
|
2961
|
-
|
|
3219
|
+
validatePathId(id, "revokeAccessCode");
|
|
2962
3220
|
await this.requestNoContent(`/v1/access-codes/${encodeURIComponent(id)}`);
|
|
2963
3221
|
}
|
|
2964
3222
|
// --- Internal HTTP plumbing ---
|
|
@@ -3446,7 +3704,7 @@ exports.QURLClient = QURLClient;
|
|
|
3446
3704
|
/**
|
|
3447
3705
|
* A LayerV-protected resource, bound to the client that produced it.
|
|
3448
3706
|
*
|
|
3449
|
-
* Obtained from {@link QURLClient.protectUrl} or {@link QURLClient.
|
|
3707
|
+
* Obtained from {@link QURLClient.protectUrl} or {@link QURLClient.resourceByCrid} —
|
|
3450
3708
|
* not constructed directly. Mint short-lived access links for the resource
|
|
3451
3709
|
* with {@link createPortal} (qurl-go: `qurl.Resource`).
|
|
3452
3710
|
*/
|
|
@@ -3455,19 +3713,19 @@ class ProtectedResource {
|
|
|
3455
3713
|
// CONTRIBUTING.md's dual-build rule bars) so the binding stays on the
|
|
3456
3714
|
// instance and out of Object.keys/JSON.stringify output.
|
|
3457
3715
|
#client;
|
|
3458
|
-
/** The
|
|
3459
|
-
|
|
3716
|
+
/** The public resource CRID. */
|
|
3717
|
+
crid;
|
|
3460
3718
|
/** The private URL protected by this resource, when known. */
|
|
3461
3719
|
targetUrl;
|
|
3462
3720
|
/**
|
|
3463
3721
|
* Full server-populated {@link Resource} metadata when the handle came
|
|
3464
|
-
* from an API response; `undefined` for {@link QURLClient.
|
|
3722
|
+
* from an API response; `undefined` for {@link QURLClient.resourceByCrid}
|
|
3465
3723
|
* handles and for the one-call {@link QURLClient.createPortalForUrl} path.
|
|
3466
3724
|
*/
|
|
3467
3725
|
details;
|
|
3468
3726
|
constructor(client, id, targetUrl, details) {
|
|
3469
3727
|
this.#client = client;
|
|
3470
|
-
this.
|
|
3728
|
+
this.crid = id;
|
|
3471
3729
|
this.targetUrl = targetUrl;
|
|
3472
3730
|
this.details = details;
|
|
3473
3731
|
}
|
|
@@ -3499,8 +3757,8 @@ const CONNECTOR_RESOURCE_CONSTRUCTOR_TOKEN = Symbol("validated ConnectorResource
|
|
|
3499
3757
|
*/
|
|
3500
3758
|
class ConnectorResource {
|
|
3501
3759
|
#client;
|
|
3502
|
-
|
|
3503
|
-
/**
|
|
3760
|
+
resourcePublicKey;
|
|
3761
|
+
/** Required public identifier, verified against the returned resource key. */
|
|
3504
3762
|
crid;
|
|
3505
3763
|
connectorRoutingId;
|
|
3506
3764
|
knockResourceId;
|
|
@@ -3512,7 +3770,7 @@ class ConnectorResource {
|
|
|
3512
3770
|
throw clientValidationError("ConnectorResource cannot be constructed directly; load it through QURLClient");
|
|
3513
3771
|
}
|
|
3514
3772
|
this.#client = client;
|
|
3515
|
-
this.
|
|
3773
|
+
this.resourcePublicKey = details.resource_id;
|
|
3516
3774
|
this.crid = details.crid;
|
|
3517
3775
|
this.connectorRoutingId = details.connector_routing_id;
|
|
3518
3776
|
this.knockResourceId = details.knock_resource_id;
|
|
@@ -3521,7 +3779,7 @@ class ConnectorResource {
|
|
|
3521
3779
|
}
|
|
3522
3780
|
/** Mint a short-lived portal for this Connector resource. */
|
|
3523
3781
|
async createPortal(opts = {}, requestOptions) {
|
|
3524
|
-
return this.#client.createPortal(this.
|
|
3782
|
+
return this.#client.createPortal(this.crid, opts, requestOptions);
|
|
3525
3783
|
}
|
|
3526
3784
|
}
|
|
3527
3785
|
exports.ConnectorResource = ConnectorResource;
|