@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.
Files changed (51) hide show
  1. package/README.md +174 -61
  2. package/dist/cjs/client.d.ts +46 -20
  3. package/dist/cjs/client.js +338 -54
  4. package/dist/cjs/errors.d.ts +24 -3
  5. package/dist/cjs/errors.js +34 -4
  6. package/dist/cjs/index.d.ts +3 -2
  7. package/dist/cjs/index.js +5 -1
  8. package/dist/cjs/node/deployment.d.ts +1 -1
  9. package/dist/cjs/node/deployment.js +2 -2
  10. package/dist/cjs/node/native-udp.d.ts +2 -4
  11. package/dist/cjs/node/native-udp.js +8 -8
  12. package/dist/cjs/node/portal-opener.d.ts +54 -23
  13. package/dist/cjs/node/portal-opener.js +643 -340
  14. package/dist/cjs/node.d.ts +2 -2
  15. package/dist/cjs/node.js +8 -1
  16. package/dist/cjs/types.d.ts +18 -2
  17. package/dist/cjs/version.d.ts +1 -1
  18. package/dist/cjs/version.js +1 -1
  19. package/dist/esm/client.d.ts +46 -20
  20. package/dist/esm/client.d.ts.map +1 -1
  21. package/dist/esm/client.js +337 -54
  22. package/dist/esm/client.js.map +1 -1
  23. package/dist/esm/errors.d.ts +24 -3
  24. package/dist/esm/errors.d.ts.map +1 -1
  25. package/dist/esm/errors.js +32 -3
  26. package/dist/esm/errors.js.map +1 -1
  27. package/dist/esm/index.d.ts +3 -2
  28. package/dist/esm/index.d.ts.map +1 -1
  29. package/dist/esm/index.js +2 -2
  30. package/dist/esm/index.js.map +1 -1
  31. package/dist/esm/node/deployment.d.ts +1 -1
  32. package/dist/esm/node/deployment.d.ts.map +1 -1
  33. package/dist/esm/node/deployment.js +2 -2
  34. package/dist/esm/node/deployment.js.map +1 -1
  35. package/dist/esm/node/native-udp.d.ts +2 -4
  36. package/dist/esm/node/native-udp.d.ts.map +1 -1
  37. package/dist/esm/node/native-udp.js +5 -5
  38. package/dist/esm/node/native-udp.js.map +1 -1
  39. package/dist/esm/node/portal-opener.d.ts +54 -23
  40. package/dist/esm/node/portal-opener.d.ts.map +1 -1
  41. package/dist/esm/node/portal-opener.js +636 -340
  42. package/dist/esm/node/portal-opener.js.map +1 -1
  43. package/dist/esm/node.d.ts +2 -2
  44. package/dist/esm/node.d.ts.map +1 -1
  45. package/dist/esm/node.js +1 -1
  46. package/dist/esm/node.js.map +1 -1
  47. package/dist/esm/types.d.ts +18 -2
  48. package/dist/esm/types.d.ts.map +1 -1
  49. package/dist/esm/version.d.ts +1 -1
  50. package/dist/esm/version.js +1 -1
  51. package/package.json +4 -4
@@ -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
- requireMaxLength(input.target_path, "target_path", MAX_TARGET_PATH);
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, or
1416
- * `undefined` when every option is omitted so no request body is sent and
1417
- * the server applies its default lifetime (matching mintLink /
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
- return Object.keys(body).length > 0 ? body : undefined;
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
- * Return the resource qURL Connector created for `connectorId`.
1774
- *
1775
- * Use when qURL Connector already protects the service — do not call
1776
- * {@link protectUrl} again for the same service. The connector id is the
1777
- * resource slug LayerV stores for that connector; the LayerV API performs
1778
- * the slug lookup, and the SDK confirms the returned alias matches
1779
- * `connectorId` before binding the handle (qurl-go:
1780
- * `Client.ConnectorResource`).
1781
- *
1782
- * Throws {@link NotFoundError} (`status: 0`, `code: "resource_not_found"`)
1783
- * when no resource exists for the connector id, {@link QURLError}
1784
- * (`code: "ambiguous_resource"`) when the lookup returns more than one
1785
- * resource, and {@link ValidationError} (`code: "unexpected_response"`)
1786
- * when the returned resource's alias is missing or does not match.
1787
- */
1788
- async connectorResource(connectorId) {
1789
- requireNonEmptyId(connectorId, "connectorResource", "connector id");
1790
- const { resources } = await this.listResources({ slug: connectorId });
1791
- if (resources.length === 0) {
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: `connectorResource: no resource found for connector "${connectorId}"`,
2032
+ detail: "getConnectorResourceBySlug: no active resource exists for the requested slug",
1797
2033
  });
1798
2034
  }
1799
- if (resources.length > 1) {
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: `connectorResource: connector "${connectorId}" returned ${resources.length} resources`,
2040
+ detail: `getConnectorResourceBySlug: expected one active resource, got ${data.length}`,
1805
2041
  });
1806
2042
  }
1807
- const resource = resources[0];
1808
- if (resource.alias !== connectorId) {
1809
- throw unexpectedResponseError(`connectorResource: connector "${connectorId}" returned a resource with a missing or different alias`);
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
- if (typeof resource.resource_id !== "string" || resource.resource_id.trim() === "") {
1812
- throw unexpectedResponseError("connectorResource: response is missing resource_id");
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 id (`r_` prefix). REST-shaped equivalents: {@link mintLink} /
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}. REST-shaped equivalent: {@link create}.
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 = method === "GET"
2776
- ? RETRYABLE_STATUS
2777
- : idempotencyKeyBackedRetry
2778
- ? RETRYABLE_STATUS_MUTATING
2779
- : NO_RETRYABLE_STATUSES;
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 id (`r_` prefix). */
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