@layervai/qurl 0.4.0 → 0.6.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 +174 -61
- package/dist/cjs/client.d.ts +46 -20
- package/dist/cjs/client.js +338 -54
- package/dist/cjs/errors.d.ts +24 -3
- package/dist/cjs/errors.js +34 -4
- package/dist/cjs/index.d.ts +3 -2
- package/dist/cjs/index.js +5 -1
- package/dist/cjs/node/deployment.d.ts +1 -1
- package/dist/cjs/node/deployment.js +2 -2
- package/dist/cjs/node/native-udp.d.ts +2 -4
- package/dist/cjs/node/native-udp.js +8 -8
- package/dist/cjs/node/portal-opener.d.ts +54 -23
- package/dist/cjs/node/portal-opener.js +643 -340
- package/dist/cjs/node.d.ts +2 -2
- package/dist/cjs/node.js +8 -1
- package/dist/cjs/types.d.ts +18 -2
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/client.d.ts +46 -20
- package/dist/esm/client.d.ts.map +1 -1
- package/dist/esm/client.js +337 -54
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/errors.d.ts +24 -3
- package/dist/esm/errors.d.ts.map +1 -1
- package/dist/esm/errors.js +32 -3
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/index.d.ts +3 -2
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +2 -2
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/node/deployment.d.ts +1 -1
- package/dist/esm/node/deployment.d.ts.map +1 -1
- package/dist/esm/node/deployment.js +2 -2
- package/dist/esm/node/deployment.js.map +1 -1
- package/dist/esm/node/native-udp.d.ts +2 -4
- package/dist/esm/node/native-udp.d.ts.map +1 -1
- package/dist/esm/node/native-udp.js +5 -5
- package/dist/esm/node/native-udp.js.map +1 -1
- package/dist/esm/node/portal-opener.d.ts +54 -23
- package/dist/esm/node/portal-opener.d.ts.map +1 -1
- package/dist/esm/node/portal-opener.js +636 -340
- package/dist/esm/node/portal-opener.js.map +1 -1
- package/dist/esm/node.d.ts +2 -2
- package/dist/esm/node.d.ts.map +1 -1
- package/dist/esm/node.js +1 -1
- package/dist/esm/node.js.map +1 -1
- package/dist/esm/types.d.ts +18 -2
- 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 +4 -4
package/dist/esm/client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createError, ERROR_CODE_AMBIGUOUS_RESOURCE, ERROR_CODE_CLIENT_VALIDATION, ERROR_CODE_RESOURCE_NOT_FOUND, ERROR_CODE_UNEXPECTED_RESPONSE, ERROR_CODE_UNKNOWN, NetworkError, NotFoundError, QURLError, RuntimeError, TimeoutError, ValidationError, } from "./errors.js";
|
|
1
|
+
import { ConnectorResourceOutcomeUnknownError, createError, ERROR_CODE_AMBIGUOUS_RESOURCE, ERROR_CODE_CLIENT_VALIDATION, ERROR_CODE_RESOURCE_NOT_FOUND, ERROR_CODE_CONNECTOR_RESOURCE_REVOKED, ERROR_CODE_UNEXPECTED_RESPONSE, ERROR_CODE_UNKNOWN, NetworkError, NotFoundError, QURLError, RuntimeError, TimeoutError, ValidationError, } from "./errors.js";
|
|
2
2
|
import { stripTrailingSlashes } from "./internal.js";
|
|
3
3
|
import { VERSION } from "./version.js";
|
|
4
4
|
const DEFAULT_BASE_URL = "https://api.layerv.ai";
|
|
@@ -193,6 +193,7 @@ const CREATE_PORTAL_OPTION_KEYS = [
|
|
|
193
193
|
"oneTimeUse",
|
|
194
194
|
"maxSessions",
|
|
195
195
|
"sessionDuration",
|
|
196
|
+
"targetPath",
|
|
196
197
|
];
|
|
197
198
|
assertExhaustive(true);
|
|
198
199
|
const UPDATE_RESOURCE_QURL_FIELD_KEYS = [
|
|
@@ -319,14 +320,14 @@ function validateRequestOptions(options) {
|
|
|
319
320
|
}
|
|
320
321
|
}
|
|
321
322
|
}
|
|
322
|
-
function idempotencyKeyForRequest(method, options) {
|
|
323
|
+
function idempotencyKeyForRequest(method, options, generateWhenMissing = true) {
|
|
323
324
|
// Validate before method gating so future read-only methods that accept
|
|
324
325
|
// RequestOptions fail loudly on malformed options instead of discarding them.
|
|
325
326
|
validateRequestOptions(options);
|
|
326
327
|
if (!IDEMPOTENCY_KEY_METHODS.has(method)) {
|
|
327
328
|
return undefined;
|
|
328
329
|
}
|
|
329
|
-
return options?.idempotencyKey ?? generateUuidV7();
|
|
330
|
+
return options?.idempotencyKey ?? (generateWhenMissing ? generateUuidV7() : undefined);
|
|
330
331
|
}
|
|
331
332
|
function generateUuidV7() {
|
|
332
333
|
const bytes = new Uint8Array(16);
|
|
@@ -720,6 +721,14 @@ const MAX_TARGET_PATH = 2048;
|
|
|
720
721
|
const MAX_TAGS = 10;
|
|
721
722
|
const MAX_TAG_LENGTH = 50;
|
|
722
723
|
const MAX_AUTO_PAGINATION_PAGES = 10_000;
|
|
724
|
+
// Keep these identity contracts aligned with qurl-go and the public API.
|
|
725
|
+
const CONNECTOR_SLUG_PATTERN = /^[a-z][a-z0-9-]{1,62}[a-z0-9]$/;
|
|
726
|
+
// Current service schemas intentionally share this grammar, but keep the
|
|
727
|
+
// mutable alias contract separate from the immutable lookup identity.
|
|
728
|
+
const CONNECTOR_ALIAS_PATTERN = /^[a-z][a-z0-9-]{1,62}[a-z0-9]$/;
|
|
729
|
+
const CONNECTOR_RESOURCE_ID_PATTERN = /^[A-Za-z0-9_-]{122}$/;
|
|
730
|
+
const CONNECTOR_RESOURCE_ID_DER_PREFIX = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE";
|
|
731
|
+
const CONNECTOR_ROUTING_ID_PATTERN = /^c-[a-z2-7]{51}[aq]$/;
|
|
723
732
|
// CreateQurlRequest.target_url pattern is loose (just a URI) but
|
|
724
733
|
// UpdateQurlRequest.tags pattern is specific — enforce it here.
|
|
725
734
|
const TAG_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9 _-]*$/;
|
|
@@ -738,6 +747,17 @@ function requireMaxLength(value, field, max) {
|
|
|
738
747
|
throw clientValidationError(`${field}: must be ${max} characters or fewer (got ${value.length})`);
|
|
739
748
|
}
|
|
740
749
|
}
|
|
750
|
+
function requireMaxUtf8Bytes(value, field, max) {
|
|
751
|
+
if (value === undefined)
|
|
752
|
+
return;
|
|
753
|
+
if (typeof value !== "string") {
|
|
754
|
+
throw clientValidationError(`${field}: must be a string (got ${value === null ? "null" : typeof value})`);
|
|
755
|
+
}
|
|
756
|
+
const bytes = TEXT_ENCODER.encode(value).byteLength;
|
|
757
|
+
if (bytes > max) {
|
|
758
|
+
throw clientValidationError(`${field}: must be ${max} UTF-8 bytes or fewer (got ${bytes})`);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
741
761
|
function requireNonEmptyIfPresent(value, field) {
|
|
742
762
|
// Reject `""` so uninitialized form state doesn't reach the wire.
|
|
743
763
|
if (value === "") {
|
|
@@ -830,6 +850,163 @@ function requireNonEmptyId(id, method, field = "id") {
|
|
|
830
850
|
throw clientValidationError(`${method}: ${field} must not include leading or trailing whitespace`);
|
|
831
851
|
}
|
|
832
852
|
}
|
|
853
|
+
function requireConnectorSlug(slug, method) {
|
|
854
|
+
if (typeof slug !== "string" || !CONNECTOR_SLUG_PATTERN.test(slug)) {
|
|
855
|
+
throw clientValidationError(`${method}: slug must be 3-64 lowercase alphanumeric or hyphen characters, start with a letter, and end alphanumeric`);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
function classifyConnectorMutationFailure(operation, error) {
|
|
859
|
+
if (!(error instanceof QURLError)) {
|
|
860
|
+
throw new ConnectorResourceOutcomeUnknownError(new RuntimeError("Unexpected failure after Connector mutation dispatch", { cause: error }));
|
|
861
|
+
}
|
|
862
|
+
// Most authoritative 4xx responses prove that the mutation was rejected.
|
|
863
|
+
// bootstrap_key_consumed is the exception: the service returns it from the
|
|
864
|
+
// consume phase after the resource may have been bound or found.
|
|
865
|
+
// Callers perform argument/runtime preflight outside their mutation try
|
|
866
|
+
// blocks; every other surfaced failure therefore follows dispatch or a
|
|
867
|
+
// nominal success whose resource contract could not be consumed, so callers
|
|
868
|
+
// must reconcile before retrying. Ensure's exact 201 + valid resource but
|
|
869
|
+
// missing found_existing is handled after this classifier: the row proves
|
|
870
|
+
// the selected resource, while only its required metadata is unavailable.
|
|
871
|
+
if (operation === "ensure" && error.status === 409 && error.code === "bootstrap_key_consumed") {
|
|
872
|
+
throw new ConnectorResourceOutcomeUnknownError(error);
|
|
873
|
+
}
|
|
874
|
+
if (error.status >= 400 && error.status < 500) {
|
|
875
|
+
throw error;
|
|
876
|
+
}
|
|
877
|
+
throw new ConnectorResourceOutcomeUnknownError(error);
|
|
878
|
+
}
|
|
879
|
+
function decodeCanonicalBase64Url(value) {
|
|
880
|
+
if (!CONNECTOR_RESOURCE_ID_PATTERN.test(value) ||
|
|
881
|
+
!value.startsWith(CONNECTOR_RESOURCE_ID_DER_PREFIX)) {
|
|
882
|
+
return undefined;
|
|
883
|
+
}
|
|
884
|
+
try {
|
|
885
|
+
const padded = value.replace(/-/g, "+").replace(/_/g, "/") + "==";
|
|
886
|
+
const binary = atob(padded);
|
|
887
|
+
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
888
|
+
const canonical = btoa(String.fromCharCode(...bytes))
|
|
889
|
+
.replace(/\+/g, "-")
|
|
890
|
+
.replace(/\//g, "_")
|
|
891
|
+
.replace(/=+$/, "");
|
|
892
|
+
return canonical === value ? bytes : undefined;
|
|
893
|
+
}
|
|
894
|
+
catch {
|
|
895
|
+
return undefined;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
async function isValidConnectorResourceId(value) {
|
|
899
|
+
const der = decodeCanonicalBase64Url(value);
|
|
900
|
+
if (!der)
|
|
901
|
+
return false;
|
|
902
|
+
if (!globalThis.crypto?.subtle) {
|
|
903
|
+
throw new RuntimeError("qURL Connector resource validation requires the Web Crypto SubtleCrypto API");
|
|
904
|
+
}
|
|
905
|
+
return importsAsConnectorPublicKey(der);
|
|
906
|
+
}
|
|
907
|
+
function requireConnectorSubtleCrypto(method) {
|
|
908
|
+
if (!globalThis.crypto?.subtle) {
|
|
909
|
+
throw new RuntimeError(`${method}: requires the Web Crypto SubtleCrypto API`);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
async function requireConnectorResourceId(resourceId, method) {
|
|
913
|
+
const der = decodeCanonicalBase64Url(resourceId);
|
|
914
|
+
if (!der) {
|
|
915
|
+
throw clientValidationError(`${method}: resource id must be a canonical unpadded base64url P-256 DER SPKI public key`);
|
|
916
|
+
}
|
|
917
|
+
requireConnectorSubtleCrypto(method);
|
|
918
|
+
if (!(await importsAsConnectorPublicKey(der))) {
|
|
919
|
+
throw clientValidationError(`${method}: resource id must be a canonical unpadded base64url P-256 DER SPKI public key`);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
async function importsAsConnectorPublicKey(der) {
|
|
923
|
+
try {
|
|
924
|
+
const keyData = new ArrayBuffer(der.byteLength);
|
|
925
|
+
new Uint8Array(keyData).set(der);
|
|
926
|
+
await globalThis.crypto.subtle.importKey("spki", keyData, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
|
|
927
|
+
return true;
|
|
928
|
+
}
|
|
929
|
+
catch {
|
|
930
|
+
return false;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
function containsControlCharacter(value) {
|
|
934
|
+
return Array.from(value).some((character) => {
|
|
935
|
+
const codePoint = character.codePointAt(0);
|
|
936
|
+
return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
async function parseConnectorResource(client, value, method, expectation = {}) {
|
|
940
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
941
|
+
throw unexpectedResponseError(`${method}: response is missing connector resource data`);
|
|
942
|
+
}
|
|
943
|
+
const resource = value;
|
|
944
|
+
if (typeof resource.resource_id !== "string") {
|
|
945
|
+
throw unexpectedResponseError(`${method}: response has missing or invalid resource_id`);
|
|
946
|
+
}
|
|
947
|
+
if (expectation.resourceId !== undefined && resource.resource_id !== expectation.resourceId) {
|
|
948
|
+
throw unexpectedResponseError(`${method}: response resource_id does not match the request`);
|
|
949
|
+
}
|
|
950
|
+
// A by-ID request already validated its requested resource key and matched
|
|
951
|
+
// this response byte-for-byte, so do not repeat the async key import.
|
|
952
|
+
if (expectation.resourceId === undefined &&
|
|
953
|
+
!(await isValidConnectorResourceId(resource.resource_id))) {
|
|
954
|
+
throw unexpectedResponseError(`${method}: response has missing or invalid resource_id`);
|
|
955
|
+
}
|
|
956
|
+
if (typeof resource.connector_routing_id !== "string" ||
|
|
957
|
+
!CONNECTOR_ROUTING_ID_PATTERN.test(resource.connector_routing_id)) {
|
|
958
|
+
throw unexpectedResponseError(`${method}: response has missing or invalid connector_routing_id`);
|
|
959
|
+
}
|
|
960
|
+
if (typeof resource.knock_resource_id !== "string" ||
|
|
961
|
+
resource.knock_resource_id.trim() === "" ||
|
|
962
|
+
resource.knock_resource_id.trim() !== resource.knock_resource_id ||
|
|
963
|
+
containsControlCharacter(resource.knock_resource_id)) {
|
|
964
|
+
throw unexpectedResponseError(`${method}: response has missing or invalid knock_resource_id`);
|
|
965
|
+
}
|
|
966
|
+
if (resource.resource_id === resource.knock_resource_id ||
|
|
967
|
+
resource.connector_routing_id === resource.knock_resource_id) {
|
|
968
|
+
throw unexpectedResponseError(`${method}: response cross-wires resource identity, routing, and admission values`);
|
|
969
|
+
}
|
|
970
|
+
if (resource.type !== "tunnel") {
|
|
971
|
+
throw unexpectedResponseError(`${method}: response resource type is not tunnel`);
|
|
972
|
+
}
|
|
973
|
+
if (typeof resource.slug !== "string" || !CONNECTOR_SLUG_PATTERN.test(resource.slug)) {
|
|
974
|
+
throw unexpectedResponseError(`${method}: response has missing or invalid slug`);
|
|
975
|
+
}
|
|
976
|
+
if (expectation.slug !== undefined && resource.slug !== expectation.slug) {
|
|
977
|
+
throw unexpectedResponseError(`${method}: response slug does not match the request`);
|
|
978
|
+
}
|
|
979
|
+
if (resource.alias !== undefined &&
|
|
980
|
+
resource.alias !== null &&
|
|
981
|
+
(typeof resource.alias !== "string" || !CONNECTOR_ALIAS_PATTERN.test(resource.alias))) {
|
|
982
|
+
// The public API intentionally gives mutable aliases and immutable slugs the
|
|
983
|
+
// same canonical wire grammar (domain aliasPattern/slugPattern). Keeping
|
|
984
|
+
// response validation aligned avoids accepting a row the service itself
|
|
985
|
+
// could not create or update.
|
|
986
|
+
throw unexpectedResponseError(`${method}: response has invalid alias`);
|
|
987
|
+
}
|
|
988
|
+
if (resource.crid !== undefined && typeof resource.crid !== "string") {
|
|
989
|
+
throw unexpectedResponseError(`${method}: response has invalid crid`);
|
|
990
|
+
}
|
|
991
|
+
// Match qurl-go: ConnectorResource carries an optional producer CRID
|
|
992
|
+
// verbatim. Consumers that possess a trusted delivered key can verify the
|
|
993
|
+
// binding separately; this management-plane row is not itself a trust root.
|
|
994
|
+
if (resource.status === "revoked") {
|
|
995
|
+
if (expectation.revokedIsLifecycleError !== true) {
|
|
996
|
+
throw unexpectedResponseError(`${method}: active-only operation returned a revoked connector resource`);
|
|
997
|
+
}
|
|
998
|
+
throw new QURLError({
|
|
999
|
+
status: 0,
|
|
1000
|
+
code: ERROR_CODE_CONNECTOR_RESOURCE_REVOKED,
|
|
1001
|
+
title: "Connector Resource Revoked",
|
|
1002
|
+
detail: `${method}: qURL Connector resource is revoked`,
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
if (resource.status !== "active") {
|
|
1006
|
+
throw unexpectedResponseError(`${method}: response has invalid resource status`);
|
|
1007
|
+
}
|
|
1008
|
+
return new ConnectorResource(client, resource, CONNECTOR_RESOURCE_CONSTRUCTOR_TOKEN);
|
|
1009
|
+
}
|
|
833
1010
|
function requireValidTags(tags) {
|
|
834
1011
|
// null tolerance for untyped-JS callers — treated as "don't touch."
|
|
835
1012
|
// Duplicates pass through to the server (authoritative on dedupe policy).
|
|
@@ -1035,7 +1212,7 @@ function validateQurlTokenOptions(input) {
|
|
|
1035
1212
|
// gate server-authoritative (`invalid_target_path`); a client-side regex
|
|
1036
1213
|
// would create a drift surface. Keep max-before-empty consistent with label;
|
|
1037
1214
|
// empty strings pass the length check and are rejected by the next guard.
|
|
1038
|
-
|
|
1215
|
+
requireMaxUtf8Bytes(input.target_path, "target_path", MAX_TARGET_PATH);
|
|
1039
1216
|
requireNonEmptyIfPresent(input.target_path, "target_path");
|
|
1040
1217
|
requireBooleanIfPresent(input.one_time_use, "one_time_use");
|
|
1041
1218
|
requireMaxSessionsInRange(input.max_sessions);
|
|
@@ -1412,16 +1589,15 @@ function parsePortal(data, method) {
|
|
|
1412
1589
|
};
|
|
1413
1590
|
}
|
|
1414
1591
|
/**
|
|
1415
|
-
* Validate portal options and build the create-portal wire body
|
|
1416
|
-
*
|
|
1417
|
-
*
|
|
1418
|
-
* createQurlForResource).
|
|
1592
|
+
* Validate portal options and build the create-portal wire body. The service
|
|
1593
|
+
* requires a JSON object even when every option is omitted; `{}` selects the
|
|
1594
|
+
* owner's plan defaults and matches qurl-go's zero-value request struct.
|
|
1419
1595
|
*/
|
|
1420
1596
|
function buildCreatePortalBody(opts, method) {
|
|
1421
1597
|
requireObjectInput(opts, method);
|
|
1422
1598
|
requireNoUnknownFields(opts, CREATE_PORTAL_OPTION_KEYS, method);
|
|
1423
1599
|
// null → omitted, via the SDK-wide normalizePatchFields convention.
|
|
1424
|
-
const { validFor, label, oneTimeUse, maxSessions, sessionDuration } = normalizePatchFields(opts, CREATE_PORTAL_OPTION_KEYS);
|
|
1600
|
+
const { validFor, label, oneTimeUse, maxSessions, sessionDuration, targetPath } = normalizePatchFields(opts, CREATE_PORTAL_OPTION_KEYS);
|
|
1425
1601
|
if (label !== undefined) {
|
|
1426
1602
|
requireMaxLength(label, "label", MAX_LABEL);
|
|
1427
1603
|
// qurl-go's WithLabel rejects blank labels; the REST-shaped mintLink
|
|
@@ -1432,6 +1608,11 @@ function buildCreatePortalBody(opts, method) {
|
|
|
1432
1608
|
}
|
|
1433
1609
|
requireBooleanIfPresent(oneTimeUse, "oneTimeUse");
|
|
1434
1610
|
requireMaxSessionsInRange(maxSessions, "maxSessions");
|
|
1611
|
+
requireMaxUtf8Bytes(targetPath, "targetPath", MAX_TARGET_PATH);
|
|
1612
|
+
requireNonEmptyIfPresent(targetPath, "targetPath");
|
|
1613
|
+
if (targetPath !== undefined && method === "createPortalForUrl") {
|
|
1614
|
+
throw clientValidationError("targetPath: requires an existing resource");
|
|
1615
|
+
}
|
|
1435
1616
|
const body = {};
|
|
1436
1617
|
const expiresIn = serializeApiDuration(validFor, "validFor", MIN_PORTAL_VALID_FOR_SECONDS);
|
|
1437
1618
|
if (expiresIn !== undefined)
|
|
@@ -1446,7 +1627,9 @@ function buildCreatePortalBody(opts, method) {
|
|
|
1446
1627
|
const wireSessionDuration = serializeApiDuration(sessionDuration, "sessionDuration", MIN_SESSION_DURATION_SECONDS);
|
|
1447
1628
|
if (wireSessionDuration !== undefined)
|
|
1448
1629
|
body.session_duration = wireSessionDuration;
|
|
1449
|
-
|
|
1630
|
+
if (targetPath !== undefined)
|
|
1631
|
+
body.target_path = targetPath;
|
|
1632
|
+
return body;
|
|
1450
1633
|
}
|
|
1451
1634
|
/** qURL API client. */
|
|
1452
1635
|
export class QURLClient {
|
|
@@ -1769,49 +1952,110 @@ export class QURLClient {
|
|
|
1769
1952
|
requireNonEmptyId(id, "resourceById");
|
|
1770
1953
|
return new ProtectedResource(this, id);
|
|
1771
1954
|
}
|
|
1772
|
-
/**
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1955
|
+
/** Find or create the active qURL Connector resource for an immutable slug. */
|
|
1956
|
+
async ensureConnectorResource(slug, requestOptions) {
|
|
1957
|
+
requireConnectorSlug(slug, "ensureConnectorResource");
|
|
1958
|
+
// Response validation imports the producer's P-256 resource key. Preflight
|
|
1959
|
+
// before POST so a missing runtime capability cannot orphan a committed row.
|
|
1960
|
+
requireConnectorSubtleCrypto("ensureConnectorResource");
|
|
1961
|
+
// Keep invalid options in the known pre-dispatch error arm. rawRequest
|
|
1962
|
+
// validates again when building the request, which protects future callers
|
|
1963
|
+
// that do not have this mutation-specific outcome contract.
|
|
1964
|
+
validateRequestOptions(requestOptions);
|
|
1965
|
+
let response;
|
|
1966
|
+
let resource;
|
|
1967
|
+
try {
|
|
1968
|
+
response = await this.rawRequest("POST", "/v1/resources", {
|
|
1969
|
+
type: "tunnel",
|
|
1970
|
+
slug,
|
|
1971
|
+
find_or_create: true,
|
|
1972
|
+
}, { requestOptions, retry: false, generateIdempotencyKey: false });
|
|
1973
|
+
// The find-or-create endpoint returns 201 for both newly-created
|
|
1974
|
+
// and found-existing rows; meta.found_existing distinguishes the arms.
|
|
1975
|
+
if (response.__http_status !== 201) {
|
|
1976
|
+
throw unexpectedResponseError(`ensureConnectorResource: expected HTTP 201, got ${response.__http_status ?? "unknown"}`);
|
|
1977
|
+
}
|
|
1978
|
+
resource = await parseConnectorResource(this, response.data, "ensureConnectorResource", {
|
|
1979
|
+
slug,
|
|
1980
|
+
});
|
|
1981
|
+
}
|
|
1982
|
+
catch (error) {
|
|
1983
|
+
classifyConnectorMutationFailure("ensure", error);
|
|
1984
|
+
}
|
|
1985
|
+
if (response.meta !== undefined &&
|
|
1986
|
+
response.meta !== null &&
|
|
1987
|
+
(typeof response.meta !== "object" || Array.isArray(response.meta))) {
|
|
1988
|
+
classifyConnectorMutationFailure("ensure", unexpectedResponseError("ensureConnectorResource: response has invalid meta"));
|
|
1989
|
+
}
|
|
1990
|
+
const foundExisting = response.meta
|
|
1991
|
+
?.found_existing;
|
|
1992
|
+
if (foundExisting === undefined || foundExisting === null) {
|
|
1993
|
+
throw unexpectedResponseError("ensureConnectorResource: response is missing meta.found_existing; the validated resource exists, so reconcile by slug before retrying");
|
|
1994
|
+
}
|
|
1995
|
+
if (typeof foundExisting !== "boolean") {
|
|
1996
|
+
classifyConnectorMutationFailure("ensure", unexpectedResponseError("ensureConnectorResource: response has invalid meta.found_existing"));
|
|
1997
|
+
}
|
|
1998
|
+
return { resource, foundExisting };
|
|
1999
|
+
}
|
|
2000
|
+
/** Fetch a qURL Connector resource by its immutable public resource ID. */
|
|
2001
|
+
async getConnectorResource(resourceId) {
|
|
2002
|
+
await requireConnectorResourceId(resourceId, "getConnectorResource");
|
|
2003
|
+
const { data, __http_status } = await this.rawRequest("GET", `/v1/resources/${encodeURIComponent(resourceId)}`, undefined, { retry: false });
|
|
2004
|
+
if (__http_status !== 200) {
|
|
2005
|
+
throw unexpectedResponseError(`getConnectorResource: expected HTTP 200, got ${__http_status ?? "unknown"}`);
|
|
2006
|
+
}
|
|
2007
|
+
return parseConnectorResource(this, data?.resource, "getConnectorResource", {
|
|
2008
|
+
resourceId,
|
|
2009
|
+
revokedIsLifecycleError: true,
|
|
2010
|
+
});
|
|
2011
|
+
}
|
|
2012
|
+
/** Fetch the single active qURL Connector resource for an immutable slug. */
|
|
2013
|
+
async getConnectorResourceBySlug(slug) {
|
|
2014
|
+
requireConnectorSlug(slug, "getConnectorResourceBySlug");
|
|
2015
|
+
// Parsing validates the returned P-256 resource key; avoid dispatching a
|
|
2016
|
+
// read that this runtime cannot safely consume.
|
|
2017
|
+
requireConnectorSubtleCrypto("getConnectorResourceBySlug");
|
|
2018
|
+
// The slug point lookup is intrinsically active-only. It also
|
|
2019
|
+
// rejects combining `slug` with `status`, so this query must stay slug-only.
|
|
2020
|
+
const { data, __http_status } = await this.rawRequest("GET", `/v1/resources?slug=${encodeURIComponent(slug)}`, undefined, { retry: false });
|
|
2021
|
+
if (__http_status !== 200) {
|
|
2022
|
+
throw unexpectedResponseError(`getConnectorResourceBySlug: expected HTTP 200, got ${__http_status ?? "unknown"}`);
|
|
2023
|
+
}
|
|
2024
|
+
if (!Array.isArray(data)) {
|
|
2025
|
+
throw unexpectedResponseError("getConnectorResourceBySlug: response has missing or invalid data");
|
|
2026
|
+
}
|
|
2027
|
+
if (data.length === 0) {
|
|
1792
2028
|
throw new NotFoundError({
|
|
1793
2029
|
status: 0,
|
|
1794
2030
|
code: ERROR_CODE_RESOURCE_NOT_FOUND,
|
|
1795
2031
|
title: "Resource Not Found",
|
|
1796
|
-
detail:
|
|
2032
|
+
detail: "getConnectorResourceBySlug: no active resource exists for the requested slug",
|
|
1797
2033
|
});
|
|
1798
2034
|
}
|
|
1799
|
-
if (
|
|
2035
|
+
if (data.length > 1) {
|
|
1800
2036
|
throw new QURLError({
|
|
1801
2037
|
status: 0,
|
|
1802
2038
|
code: ERROR_CODE_AMBIGUOUS_RESOURCE,
|
|
1803
2039
|
title: "Ambiguous Resource",
|
|
1804
|
-
detail: `
|
|
2040
|
+
detail: `getConnectorResourceBySlug: expected one active resource, got ${data.length}`,
|
|
1805
2041
|
});
|
|
1806
2042
|
}
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
2043
|
+
return parseConnectorResource(this, data[0], "getConnectorResourceBySlug", { slug });
|
|
2044
|
+
}
|
|
2045
|
+
/**
|
|
2046
|
+
* Revoke a qURL Connector resource by immutable public resource ID.
|
|
2047
|
+
*
|
|
2048
|
+
* The API does not apply Idempotency-Key replay to DELETE. After an
|
|
2049
|
+
* outcome-unknown failure, reconcile by ID before issuing a deliberate retry.
|
|
2050
|
+
*/
|
|
2051
|
+
async deleteConnectorResource(resourceId) {
|
|
2052
|
+
await requireConnectorResourceId(resourceId, "deleteConnectorResource");
|
|
2053
|
+
try {
|
|
2054
|
+
await this.requestNoContent(`/v1/resources/${encodeURIComponent(resourceId)}`);
|
|
1810
2055
|
}
|
|
1811
|
-
|
|
1812
|
-
|
|
2056
|
+
catch (error) {
|
|
2057
|
+
classifyConnectorMutationFailure("delete", error);
|
|
1813
2058
|
}
|
|
1814
|
-
return new ProtectedResource(this, resource.resource_id, resource.target_url, resource);
|
|
1815
2059
|
}
|
|
1816
2060
|
/**
|
|
1817
2061
|
* Mint a short-lived qURL link (a portal) for a protected resource.
|
|
@@ -1820,14 +2064,15 @@ export class QURLClient {
|
|
|
1820
2064
|
* reach one private resource. Recipients open `portal.link` directly and
|
|
1821
2065
|
* need no LayerV credentials. Prefer short lifetimes such as
|
|
1822
2066
|
* `{ validFor: "5m" }`. Accepts a {@link ProtectedResource} handle or a
|
|
1823
|
-
* resource
|
|
2067
|
+
* public resource ID string. REST-shaped equivalents: {@link mintLink} /
|
|
1824
2068
|
* {@link createQurlForResource}.
|
|
1825
2069
|
*
|
|
1826
2070
|
* Duration options take a string (`"5m"`, `"24h"`; server-validated) or a
|
|
1827
2071
|
* number of milliseconds with qurl-go's client-side guardrails: whole
|
|
1828
2072
|
* seconds only, at least one minute for `validFor` and one second for
|
|
1829
2073
|
* `sessionDuration`. `maxSessions: 0` is sent explicitly and means
|
|
1830
|
-
* unlimited; blank labels are rejected.
|
|
2074
|
+
* unlimited; blank labels are rejected. `targetPath` is valid only for an
|
|
2075
|
+
* existing resource. The API owns its full grammar and tunnel-only gate.
|
|
1831
2076
|
*/
|
|
1832
2077
|
async createPortal(resource, opts = {}, requestOptions) {
|
|
1833
2078
|
let resourceId;
|
|
@@ -1857,7 +2102,9 @@ export class QURLClient {
|
|
|
1857
2102
|
* {@link protectUrl} when you need the full server-populated resource
|
|
1858
2103
|
* metadata. Portal options match {@link createPortal}; `targetUrl` gets
|
|
1859
2104
|
* the same malformed-URL and embedded-credentials rejection as
|
|
1860
|
-
* {@link protectUrl}.
|
|
2105
|
+
* {@link protectUrl}. `targetPath` is rejected because this call creates a
|
|
2106
|
+
* URL resource instead of addressing an existing resource. REST-shaped
|
|
2107
|
+
* equivalent: {@link create}.
|
|
1861
2108
|
*/
|
|
1862
2109
|
async createPortalForUrl(targetUrl, opts = {}, requestOptions) {
|
|
1863
2110
|
requirePortalTargetUrl(targetUrl);
|
|
@@ -2750,8 +2997,8 @@ export class QURLClient {
|
|
|
2750
2997
|
*/
|
|
2751
2998
|
async rawRequest(method, path, body, rawOptions = {}) {
|
|
2752
2999
|
const url = `${this.baseUrl}${path}`;
|
|
2753
|
-
const { passthroughStatuses = NO_PASSTHROUGH_STATUSES, requestOptions, allowEmptySuccessBody = false, } = rawOptions;
|
|
2754
|
-
const idempotencyKey = idempotencyKeyForRequest(method, requestOptions);
|
|
3000
|
+
const { passthroughStatuses = NO_PASSTHROUGH_STATUSES, requestOptions, allowEmptySuccessBody = false, retry = true, generateIdempotencyKey = true, } = rawOptions;
|
|
3001
|
+
const idempotencyKey = idempotencyKeyForRequest(method, requestOptions, generateIdempotencyKey);
|
|
2755
3002
|
const headers = {
|
|
2756
3003
|
Authorization: `Bearer ${this.apiKey}`,
|
|
2757
3004
|
Accept: "application/json",
|
|
@@ -2770,13 +3017,15 @@ export class QURLClient {
|
|
|
2770
3017
|
// retry rule and avoids SDK pacing on an application request path. The
|
|
2771
3018
|
// caller receives retryAfter when supplied and must reconcile resource
|
|
2772
3019
|
// state before it chooses to issue another delete.
|
|
2773
|
-
const idempotencyKeyBackedRetry = MUTATING_RETRY_METHODS.has(method);
|
|
2774
|
-
const retryFetchFailure = method === "GET" || idempotencyKeyBackedRetry;
|
|
2775
|
-
const retryable =
|
|
2776
|
-
?
|
|
2777
|
-
:
|
|
2778
|
-
?
|
|
2779
|
-
:
|
|
3020
|
+
const idempotencyKeyBackedRetry = retry && MUTATING_RETRY_METHODS.has(method);
|
|
3021
|
+
const retryFetchFailure = retry && (method === "GET" || idempotencyKeyBackedRetry);
|
|
3022
|
+
const retryable = !retry
|
|
3023
|
+
? NO_RETRYABLE_STATUSES
|
|
3024
|
+
: method === "GET"
|
|
3025
|
+
? RETRYABLE_STATUS
|
|
3026
|
+
: idempotencyKeyBackedRetry
|
|
3027
|
+
? RETRYABLE_STATUS_MUTATING
|
|
3028
|
+
: NO_RETRYABLE_STATUSES;
|
|
2780
3029
|
const serializedBody = body !== undefined ? JSON.stringify(body) : undefined;
|
|
2781
3030
|
let lastError;
|
|
2782
3031
|
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
@@ -3193,8 +3442,7 @@ export class QURLClient {
|
|
|
3193
3442
|
/**
|
|
3194
3443
|
* A LayerV-protected resource, bound to the client that produced it.
|
|
3195
3444
|
*
|
|
3196
|
-
* Obtained from {@link QURLClient.protectUrl}
|
|
3197
|
-
* {@link QURLClient.connectorResource}, or {@link QURLClient.resourceById} —
|
|
3445
|
+
* Obtained from {@link QURLClient.protectUrl} or {@link QURLClient.resourceById} —
|
|
3198
3446
|
* not constructed directly. Mint short-lived access links for the resource
|
|
3199
3447
|
* with {@link createPortal} (qurl-go: `qurl.Resource`).
|
|
3200
3448
|
*/
|
|
@@ -3203,7 +3451,7 @@ export class ProtectedResource {
|
|
|
3203
3451
|
// CONTRIBUTING.md's dual-build rule bars) so the binding stays on the
|
|
3204
3452
|
// instance and out of Object.keys/JSON.stringify output.
|
|
3205
3453
|
#client;
|
|
3206
|
-
/** The LayerV resource
|
|
3454
|
+
/** The LayerV public resource ID. */
|
|
3207
3455
|
id;
|
|
3208
3456
|
/** The private URL protected by this resource, when known. */
|
|
3209
3457
|
targetUrl;
|
|
@@ -3236,4 +3484,39 @@ export class ProtectedResource {
|
|
|
3236
3484
|
return this.#client.createPortal(this, opts, requestOptions);
|
|
3237
3485
|
}
|
|
3238
3486
|
}
|
|
3487
|
+
const CONNECTOR_RESOURCE_CONSTRUCTOR_TOKEN = Symbol("validated ConnectorResource");
|
|
3488
|
+
/**
|
|
3489
|
+
* A validated qURL Connector resource bound to the client that loaded it.
|
|
3490
|
+
*
|
|
3491
|
+
* Resource identity, reverse-routing identity, and NHP admission identity are
|
|
3492
|
+
* distinct server-issued values. Callers must consume each field verbatim and
|
|
3493
|
+
* must never derive one from another.
|
|
3494
|
+
*/
|
|
3495
|
+
export class ConnectorResource {
|
|
3496
|
+
#client;
|
|
3497
|
+
resourceId;
|
|
3498
|
+
/** Producer-supplied CRID carried verbatim; verify it against a trusted key before trust. */
|
|
3499
|
+
crid;
|
|
3500
|
+
connectorRoutingId;
|
|
3501
|
+
knockResourceId;
|
|
3502
|
+
slug;
|
|
3503
|
+
alias;
|
|
3504
|
+
/** @internal Instances are returned by QURLClient after wire validation. */
|
|
3505
|
+
constructor(client, details, validationToken) {
|
|
3506
|
+
if (validationToken !== CONNECTOR_RESOURCE_CONSTRUCTOR_TOKEN) {
|
|
3507
|
+
throw clientValidationError("ConnectorResource cannot be constructed directly; load it through QURLClient");
|
|
3508
|
+
}
|
|
3509
|
+
this.#client = client;
|
|
3510
|
+
this.resourceId = details.resource_id;
|
|
3511
|
+
this.crid = details.crid;
|
|
3512
|
+
this.connectorRoutingId = details.connector_routing_id;
|
|
3513
|
+
this.knockResourceId = details.knock_resource_id;
|
|
3514
|
+
this.slug = details.slug;
|
|
3515
|
+
this.alias = details.alias ?? undefined;
|
|
3516
|
+
}
|
|
3517
|
+
/** Mint a short-lived portal for this Connector resource. */
|
|
3518
|
+
async createPortal(opts = {}, requestOptions) {
|
|
3519
|
+
return this.#client.createPortal(this.resourceId, opts, requestOptions);
|
|
3520
|
+
}
|
|
3521
|
+
}
|
|
3239
3522
|
//# sourceMappingURL=client.js.map
|