@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,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ProtectedResource = exports.QURLClient = exports.SIGNED_FRAGMENT_RE = void 0;
3
+ exports.ConnectorResource = exports.ProtectedResource = exports.QURLClient = exports.SIGNED_FRAGMENT_RE = void 0;
4
4
  const errors_js_1 = require("./errors.js");
5
5
  const internal_js_1 = require("./internal.js");
6
6
  const version_js_1 = require("./version.js");
@@ -196,6 +196,7 @@ const CREATE_PORTAL_OPTION_KEYS = [
196
196
  "oneTimeUse",
197
197
  "maxSessions",
198
198
  "sessionDuration",
199
+ "targetPath",
199
200
  ];
200
201
  assertExhaustive(true);
201
202
  const UPDATE_RESOURCE_QURL_FIELD_KEYS = [
@@ -322,14 +323,14 @@ function validateRequestOptions(options) {
322
323
  }
323
324
  }
324
325
  }
325
- function idempotencyKeyForRequest(method, options) {
326
+ function idempotencyKeyForRequest(method, options, generateWhenMissing = true) {
326
327
  // Validate before method gating so future read-only methods that accept
327
328
  // RequestOptions fail loudly on malformed options instead of discarding them.
328
329
  validateRequestOptions(options);
329
330
  if (!IDEMPOTENCY_KEY_METHODS.has(method)) {
330
331
  return undefined;
331
332
  }
332
- return options?.idempotencyKey ?? generateUuidV7();
333
+ return options?.idempotencyKey ?? (generateWhenMissing ? generateUuidV7() : undefined);
333
334
  }
334
335
  function generateUuidV7() {
335
336
  const bytes = new Uint8Array(16);
@@ -723,6 +724,14 @@ const MAX_TARGET_PATH = 2048;
723
724
  const MAX_TAGS = 10;
724
725
  const MAX_TAG_LENGTH = 50;
725
726
  const MAX_AUTO_PAGINATION_PAGES = 10_000;
727
+ // Keep these identity contracts aligned with qurl-go and the public API.
728
+ const CONNECTOR_SLUG_PATTERN = /^[a-z][a-z0-9-]{1,62}[a-z0-9]$/;
729
+ // Current service schemas intentionally share this grammar, but keep the
730
+ // mutable alias contract separate from the immutable lookup identity.
731
+ const CONNECTOR_ALIAS_PATTERN = /^[a-z][a-z0-9-]{1,62}[a-z0-9]$/;
732
+ const CONNECTOR_RESOURCE_ID_PATTERN = /^[A-Za-z0-9_-]{122}$/;
733
+ const CONNECTOR_RESOURCE_ID_DER_PREFIX = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE";
734
+ const CONNECTOR_ROUTING_ID_PATTERN = /^c-[a-z2-7]{51}[aq]$/;
726
735
  // CreateQurlRequest.target_url pattern is loose (just a URI) but
727
736
  // UpdateQurlRequest.tags pattern is specific — enforce it here.
728
737
  const TAG_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9 _-]*$/;
@@ -741,6 +750,17 @@ function requireMaxLength(value, field, max) {
741
750
  throw clientValidationError(`${field}: must be ${max} characters or fewer (got ${value.length})`);
742
751
  }
743
752
  }
753
+ function requireMaxUtf8Bytes(value, field, max) {
754
+ if (value === undefined)
755
+ return;
756
+ if (typeof value !== "string") {
757
+ throw clientValidationError(`${field}: must be a string (got ${value === null ? "null" : typeof value})`);
758
+ }
759
+ const bytes = TEXT_ENCODER.encode(value).byteLength;
760
+ if (bytes > max) {
761
+ throw clientValidationError(`${field}: must be ${max} UTF-8 bytes or fewer (got ${bytes})`);
762
+ }
763
+ }
744
764
  function requireNonEmptyIfPresent(value, field) {
745
765
  // Reject `""` so uninitialized form state doesn't reach the wire.
746
766
  if (value === "") {
@@ -833,6 +853,163 @@ function requireNonEmptyId(id, method, field = "id") {
833
853
  throw clientValidationError(`${method}: ${field} must not include leading or trailing whitespace`);
834
854
  }
835
855
  }
856
+ function requireConnectorSlug(slug, method) {
857
+ if (typeof slug !== "string" || !CONNECTOR_SLUG_PATTERN.test(slug)) {
858
+ throw clientValidationError(`${method}: slug must be 3-64 lowercase alphanumeric or hyphen characters, start with a letter, and end alphanumeric`);
859
+ }
860
+ }
861
+ function classifyConnectorMutationFailure(operation, error) {
862
+ if (!(error instanceof errors_js_1.QURLError)) {
863
+ throw new errors_js_1.ConnectorResourceOutcomeUnknownError(new errors_js_1.RuntimeError("Unexpected failure after Connector mutation dispatch", { cause: error }));
864
+ }
865
+ // Most authoritative 4xx responses prove that the mutation was rejected.
866
+ // bootstrap_key_consumed is the exception: the service returns it from the
867
+ // consume phase after the resource may have been bound or found.
868
+ // Callers perform argument/runtime preflight outside their mutation try
869
+ // blocks; every other surfaced failure therefore follows dispatch or a
870
+ // nominal success whose resource contract could not be consumed, so callers
871
+ // must reconcile before retrying. Ensure's exact 201 + valid resource but
872
+ // missing found_existing is handled after this classifier: the row proves
873
+ // the selected resource, while only its required metadata is unavailable.
874
+ if (operation === "ensure" && error.status === 409 && error.code === "bootstrap_key_consumed") {
875
+ throw new errors_js_1.ConnectorResourceOutcomeUnknownError(error);
876
+ }
877
+ if (error.status >= 400 && error.status < 500) {
878
+ throw error;
879
+ }
880
+ throw new errors_js_1.ConnectorResourceOutcomeUnknownError(error);
881
+ }
882
+ function decodeCanonicalBase64Url(value) {
883
+ if (!CONNECTOR_RESOURCE_ID_PATTERN.test(value) ||
884
+ !value.startsWith(CONNECTOR_RESOURCE_ID_DER_PREFIX)) {
885
+ return undefined;
886
+ }
887
+ try {
888
+ const padded = value.replace(/-/g, "+").replace(/_/g, "/") + "==";
889
+ const binary = atob(padded);
890
+ const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
891
+ const canonical = btoa(String.fromCharCode(...bytes))
892
+ .replace(/\+/g, "-")
893
+ .replace(/\//g, "_")
894
+ .replace(/=+$/, "");
895
+ return canonical === value ? bytes : undefined;
896
+ }
897
+ catch {
898
+ return undefined;
899
+ }
900
+ }
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
+ function requireConnectorSubtleCrypto(method) {
911
+ if (!globalThis.crypto?.subtle) {
912
+ throw new errors_js_1.RuntimeError(`${method}: requires the Web Crypto SubtleCrypto API`);
913
+ }
914
+ }
915
+ async function requireConnectorResourceId(resourceId, method) {
916
+ const der = decodeCanonicalBase64Url(resourceId);
917
+ if (!der) {
918
+ throw clientValidationError(`${method}: resource id must be a canonical unpadded base64url P-256 DER SPKI public key`);
919
+ }
920
+ requireConnectorSubtleCrypto(method);
921
+ if (!(await importsAsConnectorPublicKey(der))) {
922
+ throw clientValidationError(`${method}: resource id must be a canonical unpadded base64url P-256 DER SPKI public key`);
923
+ }
924
+ }
925
+ async function importsAsConnectorPublicKey(der) {
926
+ try {
927
+ const keyData = new ArrayBuffer(der.byteLength);
928
+ new Uint8Array(keyData).set(der);
929
+ await globalThis.crypto.subtle.importKey("spki", keyData, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
930
+ return true;
931
+ }
932
+ catch {
933
+ return false;
934
+ }
935
+ }
936
+ function containsControlCharacter(value) {
937
+ return Array.from(value).some((character) => {
938
+ const codePoint = character.codePointAt(0);
939
+ return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);
940
+ });
941
+ }
942
+ async function parseConnectorResource(client, value, method, expectation = {}) {
943
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
944
+ throw unexpectedResponseError(`${method}: response is missing connector resource data`);
945
+ }
946
+ const resource = value;
947
+ if (typeof resource.resource_id !== "string") {
948
+ throw unexpectedResponseError(`${method}: response has missing or invalid resource_id`);
949
+ }
950
+ if (expectation.resourceId !== undefined && resource.resource_id !== expectation.resourceId) {
951
+ throw unexpectedResponseError(`${method}: response resource_id does not match the request`);
952
+ }
953
+ // A by-ID request already validated its requested resource key and matched
954
+ // this response byte-for-byte, so do not repeat the async key import.
955
+ if (expectation.resourceId === undefined &&
956
+ !(await isValidConnectorResourceId(resource.resource_id))) {
957
+ throw unexpectedResponseError(`${method}: response has missing or invalid resource_id`);
958
+ }
959
+ if (typeof resource.connector_routing_id !== "string" ||
960
+ !CONNECTOR_ROUTING_ID_PATTERN.test(resource.connector_routing_id)) {
961
+ throw unexpectedResponseError(`${method}: response has missing or invalid connector_routing_id`);
962
+ }
963
+ if (typeof resource.knock_resource_id !== "string" ||
964
+ resource.knock_resource_id.trim() === "" ||
965
+ resource.knock_resource_id.trim() !== resource.knock_resource_id ||
966
+ containsControlCharacter(resource.knock_resource_id)) {
967
+ throw unexpectedResponseError(`${method}: response has missing or invalid knock_resource_id`);
968
+ }
969
+ if (resource.resource_id === resource.knock_resource_id ||
970
+ resource.connector_routing_id === resource.knock_resource_id) {
971
+ throw unexpectedResponseError(`${method}: response cross-wires resource identity, routing, and admission values`);
972
+ }
973
+ if (resource.type !== "tunnel") {
974
+ throw unexpectedResponseError(`${method}: response resource type is not tunnel`);
975
+ }
976
+ if (typeof resource.slug !== "string" || !CONNECTOR_SLUG_PATTERN.test(resource.slug)) {
977
+ throw unexpectedResponseError(`${method}: response has missing or invalid slug`);
978
+ }
979
+ if (expectation.slug !== undefined && resource.slug !== expectation.slug) {
980
+ throw unexpectedResponseError(`${method}: response slug does not match the request`);
981
+ }
982
+ if (resource.alias !== undefined &&
983
+ resource.alias !== null &&
984
+ (typeof resource.alias !== "string" || !CONNECTOR_ALIAS_PATTERN.test(resource.alias))) {
985
+ // The public API intentionally gives mutable aliases and immutable slugs the
986
+ // same canonical wire grammar (domain aliasPattern/slugPattern). Keeping
987
+ // response validation aligned avoids accepting a row the service itself
988
+ // could not create or update.
989
+ throw unexpectedResponseError(`${method}: response has invalid alias`);
990
+ }
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
+ if (resource.status === "revoked") {
998
+ if (expectation.revokedIsLifecycleError !== true) {
999
+ throw unexpectedResponseError(`${method}: active-only operation returned a revoked connector resource`);
1000
+ }
1001
+ throw new errors_js_1.QURLError({
1002
+ status: 0,
1003
+ code: errors_js_1.ERROR_CODE_CONNECTOR_RESOURCE_REVOKED,
1004
+ title: "Connector Resource Revoked",
1005
+ detail: `${method}: qURL Connector resource is revoked`,
1006
+ });
1007
+ }
1008
+ if (resource.status !== "active") {
1009
+ throw unexpectedResponseError(`${method}: response has invalid resource status`);
1010
+ }
1011
+ return new ConnectorResource(client, resource, CONNECTOR_RESOURCE_CONSTRUCTOR_TOKEN);
1012
+ }
836
1013
  function requireValidTags(tags) {
837
1014
  // null tolerance for untyped-JS callers — treated as "don't touch."
838
1015
  // Duplicates pass through to the server (authoritative on dedupe policy).
@@ -1038,7 +1215,7 @@ function validateQurlTokenOptions(input) {
1038
1215
  // gate server-authoritative (`invalid_target_path`); a client-side regex
1039
1216
  // would create a drift surface. Keep max-before-empty consistent with label;
1040
1217
  // empty strings pass the length check and are rejected by the next guard.
1041
- requireMaxLength(input.target_path, "target_path", MAX_TARGET_PATH);
1218
+ requireMaxUtf8Bytes(input.target_path, "target_path", MAX_TARGET_PATH);
1042
1219
  requireNonEmptyIfPresent(input.target_path, "target_path");
1043
1220
  requireBooleanIfPresent(input.one_time_use, "one_time_use");
1044
1221
  requireMaxSessionsInRange(input.max_sessions);
@@ -1415,16 +1592,15 @@ function parsePortal(data, method) {
1415
1592
  };
1416
1593
  }
1417
1594
  /**
1418
- * Validate portal options and build the create-portal wire body, or
1419
- * `undefined` when every option is omitted so no request body is sent and
1420
- * the server applies its default lifetime (matching mintLink /
1421
- * createQurlForResource).
1595
+ * Validate portal options and build the create-portal wire body. The service
1596
+ * requires a JSON object even when every option is omitted; `{}` selects the
1597
+ * owner's plan defaults and matches qurl-go's zero-value request struct.
1422
1598
  */
1423
1599
  function buildCreatePortalBody(opts, method) {
1424
1600
  requireObjectInput(opts, method);
1425
1601
  requireNoUnknownFields(opts, CREATE_PORTAL_OPTION_KEYS, method);
1426
1602
  // null → omitted, via the SDK-wide normalizePatchFields convention.
1427
- const { validFor, label, oneTimeUse, maxSessions, sessionDuration } = normalizePatchFields(opts, CREATE_PORTAL_OPTION_KEYS);
1603
+ const { validFor, label, oneTimeUse, maxSessions, sessionDuration, targetPath } = normalizePatchFields(opts, CREATE_PORTAL_OPTION_KEYS);
1428
1604
  if (label !== undefined) {
1429
1605
  requireMaxLength(label, "label", MAX_LABEL);
1430
1606
  // qurl-go's WithLabel rejects blank labels; the REST-shaped mintLink
@@ -1435,6 +1611,11 @@ function buildCreatePortalBody(opts, method) {
1435
1611
  }
1436
1612
  requireBooleanIfPresent(oneTimeUse, "oneTimeUse");
1437
1613
  requireMaxSessionsInRange(maxSessions, "maxSessions");
1614
+ requireMaxUtf8Bytes(targetPath, "targetPath", MAX_TARGET_PATH);
1615
+ requireNonEmptyIfPresent(targetPath, "targetPath");
1616
+ if (targetPath !== undefined && method === "createPortalForUrl") {
1617
+ throw clientValidationError("targetPath: requires an existing resource");
1618
+ }
1438
1619
  const body = {};
1439
1620
  const expiresIn = serializeApiDuration(validFor, "validFor", MIN_PORTAL_VALID_FOR_SECONDS);
1440
1621
  if (expiresIn !== undefined)
@@ -1449,7 +1630,9 @@ function buildCreatePortalBody(opts, method) {
1449
1630
  const wireSessionDuration = serializeApiDuration(sessionDuration, "sessionDuration", MIN_SESSION_DURATION_SECONDS);
1450
1631
  if (wireSessionDuration !== undefined)
1451
1632
  body.session_duration = wireSessionDuration;
1452
- return Object.keys(body).length > 0 ? body : undefined;
1633
+ if (targetPath !== undefined)
1634
+ body.target_path = targetPath;
1635
+ return body;
1453
1636
  }
1454
1637
  /** qURL API client. */
1455
1638
  class QURLClient {
@@ -1772,49 +1955,110 @@ class QURLClient {
1772
1955
  requireNonEmptyId(id, "resourceById");
1773
1956
  return new ProtectedResource(this, id);
1774
1957
  }
1775
- /**
1776
- * Return the resource qURL Connector created for `connectorId`.
1777
- *
1778
- * Use when qURL Connector already protects the service — do not call
1779
- * {@link protectUrl} again for the same service. The connector id is the
1780
- * resource slug LayerV stores for that connector; the LayerV API performs
1781
- * the slug lookup, and the SDK confirms the returned alias matches
1782
- * `connectorId` before binding the handle (qurl-go:
1783
- * `Client.ConnectorResource`).
1784
- *
1785
- * Throws {@link NotFoundError} (`status: 0`, `code: "resource_not_found"`)
1786
- * when no resource exists for the connector id, {@link QURLError}
1787
- * (`code: "ambiguous_resource"`) when the lookup returns more than one
1788
- * resource, and {@link ValidationError} (`code: "unexpected_response"`)
1789
- * when the returned resource's alias is missing or does not match.
1790
- */
1791
- async connectorResource(connectorId) {
1792
- requireNonEmptyId(connectorId, "connectorResource", "connector id");
1793
- const { resources } = await this.listResources({ slug: connectorId });
1794
- if (resources.length === 0) {
1958
+ /** Find or create the active qURL Connector resource for an immutable slug. */
1959
+ async ensureConnectorResource(slug, requestOptions) {
1960
+ requireConnectorSlug(slug, "ensureConnectorResource");
1961
+ // Response validation imports the producer's P-256 resource key. Preflight
1962
+ // before POST so a missing runtime capability cannot orphan a committed row.
1963
+ requireConnectorSubtleCrypto("ensureConnectorResource");
1964
+ // Keep invalid options in the known pre-dispatch error arm. rawRequest
1965
+ // validates again when building the request, which protects future callers
1966
+ // that do not have this mutation-specific outcome contract.
1967
+ validateRequestOptions(requestOptions);
1968
+ let response;
1969
+ let resource;
1970
+ try {
1971
+ response = await this.rawRequest("POST", "/v1/resources", {
1972
+ type: "tunnel",
1973
+ slug,
1974
+ find_or_create: true,
1975
+ }, { requestOptions, retry: false, generateIdempotencyKey: false });
1976
+ // The find-or-create endpoint returns 201 for both newly-created
1977
+ // and found-existing rows; meta.found_existing distinguishes the arms.
1978
+ if (response.__http_status !== 201) {
1979
+ throw unexpectedResponseError(`ensureConnectorResource: expected HTTP 201, got ${response.__http_status ?? "unknown"}`);
1980
+ }
1981
+ resource = await parseConnectorResource(this, response.data, "ensureConnectorResource", {
1982
+ slug,
1983
+ });
1984
+ }
1985
+ catch (error) {
1986
+ classifyConnectorMutationFailure("ensure", error);
1987
+ }
1988
+ if (response.meta !== undefined &&
1989
+ response.meta !== null &&
1990
+ (typeof response.meta !== "object" || Array.isArray(response.meta))) {
1991
+ classifyConnectorMutationFailure("ensure", unexpectedResponseError("ensureConnectorResource: response has invalid meta"));
1992
+ }
1993
+ const foundExisting = response.meta
1994
+ ?.found_existing;
1995
+ if (foundExisting === undefined || foundExisting === null) {
1996
+ throw unexpectedResponseError("ensureConnectorResource: response is missing meta.found_existing; the validated resource exists, so reconcile by slug before retrying");
1997
+ }
1998
+ if (typeof foundExisting !== "boolean") {
1999
+ classifyConnectorMutationFailure("ensure", unexpectedResponseError("ensureConnectorResource: response has invalid meta.found_existing"));
2000
+ }
2001
+ return { resource, foundExisting };
2002
+ }
2003
+ /** Fetch a qURL Connector resource by its immutable public resource ID. */
2004
+ async getConnectorResource(resourceId) {
2005
+ await requireConnectorResourceId(resourceId, "getConnectorResource");
2006
+ const { data, __http_status } = await this.rawRequest("GET", `/v1/resources/${encodeURIComponent(resourceId)}`, undefined, { retry: false });
2007
+ if (__http_status !== 200) {
2008
+ throw unexpectedResponseError(`getConnectorResource: expected HTTP 200, got ${__http_status ?? "unknown"}`);
2009
+ }
2010
+ return parseConnectorResource(this, data?.resource, "getConnectorResource", {
2011
+ resourceId,
2012
+ revokedIsLifecycleError: true,
2013
+ });
2014
+ }
2015
+ /** Fetch the single active qURL Connector resource for an immutable slug. */
2016
+ async getConnectorResourceBySlug(slug) {
2017
+ requireConnectorSlug(slug, "getConnectorResourceBySlug");
2018
+ // Parsing validates the returned P-256 resource key; avoid dispatching a
2019
+ // read that this runtime cannot safely consume.
2020
+ requireConnectorSubtleCrypto("getConnectorResourceBySlug");
2021
+ // The slug point lookup is intrinsically active-only. It also
2022
+ // rejects combining `slug` with `status`, so this query must stay slug-only.
2023
+ const { data, __http_status } = await this.rawRequest("GET", `/v1/resources?slug=${encodeURIComponent(slug)}`, undefined, { retry: false });
2024
+ if (__http_status !== 200) {
2025
+ throw unexpectedResponseError(`getConnectorResourceBySlug: expected HTTP 200, got ${__http_status ?? "unknown"}`);
2026
+ }
2027
+ if (!Array.isArray(data)) {
2028
+ throw unexpectedResponseError("getConnectorResourceBySlug: response has missing or invalid data");
2029
+ }
2030
+ if (data.length === 0) {
1795
2031
  throw new errors_js_1.NotFoundError({
1796
2032
  status: 0,
1797
2033
  code: errors_js_1.ERROR_CODE_RESOURCE_NOT_FOUND,
1798
2034
  title: "Resource Not Found",
1799
- detail: `connectorResource: no resource found for connector "${connectorId}"`,
2035
+ detail: "getConnectorResourceBySlug: no active resource exists for the requested slug",
1800
2036
  });
1801
2037
  }
1802
- if (resources.length > 1) {
2038
+ if (data.length > 1) {
1803
2039
  throw new errors_js_1.QURLError({
1804
2040
  status: 0,
1805
2041
  code: errors_js_1.ERROR_CODE_AMBIGUOUS_RESOURCE,
1806
2042
  title: "Ambiguous Resource",
1807
- detail: `connectorResource: connector "${connectorId}" returned ${resources.length} resources`,
2043
+ detail: `getConnectorResourceBySlug: expected one active resource, got ${data.length}`,
1808
2044
  });
1809
2045
  }
1810
- const resource = resources[0];
1811
- if (resource.alias !== connectorId) {
1812
- throw unexpectedResponseError(`connectorResource: connector "${connectorId}" returned a resource with a missing or different alias`);
2046
+ return parseConnectorResource(this, data[0], "getConnectorResourceBySlug", { slug });
2047
+ }
2048
+ /**
2049
+ * Revoke a qURL Connector resource by immutable public resource ID.
2050
+ *
2051
+ * The API does not apply Idempotency-Key replay to DELETE. After an
2052
+ * outcome-unknown failure, reconcile by ID before issuing a deliberate retry.
2053
+ */
2054
+ async deleteConnectorResource(resourceId) {
2055
+ await requireConnectorResourceId(resourceId, "deleteConnectorResource");
2056
+ try {
2057
+ await this.requestNoContent(`/v1/resources/${encodeURIComponent(resourceId)}`);
1813
2058
  }
1814
- if (typeof resource.resource_id !== "string" || resource.resource_id.trim() === "") {
1815
- throw unexpectedResponseError("connectorResource: response is missing resource_id");
2059
+ catch (error) {
2060
+ classifyConnectorMutationFailure("delete", error);
1816
2061
  }
1817
- return new ProtectedResource(this, resource.resource_id, resource.target_url, resource);
1818
2062
  }
1819
2063
  /**
1820
2064
  * Mint a short-lived qURL link (a portal) for a protected resource.
@@ -1823,14 +2067,15 @@ class QURLClient {
1823
2067
  * reach one private resource. Recipients open `portal.link` directly and
1824
2068
  * need no LayerV credentials. Prefer short lifetimes such as
1825
2069
  * `{ validFor: "5m" }`. Accepts a {@link ProtectedResource} handle or a
1826
- * resource id (`r_` prefix). REST-shaped equivalents: {@link mintLink} /
2070
+ * public resource ID string. REST-shaped equivalents: {@link mintLink} /
1827
2071
  * {@link createQurlForResource}.
1828
2072
  *
1829
2073
  * Duration options take a string (`"5m"`, `"24h"`; server-validated) or a
1830
2074
  * number of milliseconds with qurl-go's client-side guardrails: whole
1831
2075
  * seconds only, at least one minute for `validFor` and one second for
1832
2076
  * `sessionDuration`. `maxSessions: 0` is sent explicitly and means
1833
- * unlimited; blank labels are rejected.
2077
+ * unlimited; blank labels are rejected. `targetPath` is valid only for an
2078
+ * existing resource. The API owns its full grammar and tunnel-only gate.
1834
2079
  */
1835
2080
  async createPortal(resource, opts = {}, requestOptions) {
1836
2081
  let resourceId;
@@ -1860,7 +2105,9 @@ class QURLClient {
1860
2105
  * {@link protectUrl} when you need the full server-populated resource
1861
2106
  * metadata. Portal options match {@link createPortal}; `targetUrl` gets
1862
2107
  * the same malformed-URL and embedded-credentials rejection as
1863
- * {@link protectUrl}. REST-shaped equivalent: {@link create}.
2108
+ * {@link protectUrl}. `targetPath` is rejected because this call creates a
2109
+ * URL resource instead of addressing an existing resource. REST-shaped
2110
+ * equivalent: {@link create}.
1864
2111
  */
1865
2112
  async createPortalForUrl(targetUrl, opts = {}, requestOptions) {
1866
2113
  requirePortalTargetUrl(targetUrl);
@@ -2753,8 +3000,8 @@ class QURLClient {
2753
3000
  */
2754
3001
  async rawRequest(method, path, body, rawOptions = {}) {
2755
3002
  const url = `${this.baseUrl}${path}`;
2756
- const { passthroughStatuses = NO_PASSTHROUGH_STATUSES, requestOptions, allowEmptySuccessBody = false, } = rawOptions;
2757
- const idempotencyKey = idempotencyKeyForRequest(method, requestOptions);
3003
+ const { passthroughStatuses = NO_PASSTHROUGH_STATUSES, requestOptions, allowEmptySuccessBody = false, retry = true, generateIdempotencyKey = true, } = rawOptions;
3004
+ const idempotencyKey = idempotencyKeyForRequest(method, requestOptions, generateIdempotencyKey);
2758
3005
  const headers = {
2759
3006
  Authorization: `Bearer ${this.apiKey}`,
2760
3007
  Accept: "application/json",
@@ -2773,13 +3020,15 @@ class QURLClient {
2773
3020
  // retry rule and avoids SDK pacing on an application request path. The
2774
3021
  // caller receives retryAfter when supplied and must reconcile resource
2775
3022
  // state before it chooses to issue another delete.
2776
- const idempotencyKeyBackedRetry = MUTATING_RETRY_METHODS.has(method);
2777
- const retryFetchFailure = method === "GET" || idempotencyKeyBackedRetry;
2778
- const retryable = method === "GET"
2779
- ? RETRYABLE_STATUS
2780
- : idempotencyKeyBackedRetry
2781
- ? RETRYABLE_STATUS_MUTATING
2782
- : NO_RETRYABLE_STATUSES;
3023
+ const idempotencyKeyBackedRetry = retry && MUTATING_RETRY_METHODS.has(method);
3024
+ const retryFetchFailure = retry && (method === "GET" || idempotencyKeyBackedRetry);
3025
+ const retryable = !retry
3026
+ ? NO_RETRYABLE_STATUSES
3027
+ : method === "GET"
3028
+ ? RETRYABLE_STATUS
3029
+ : idempotencyKeyBackedRetry
3030
+ ? RETRYABLE_STATUS_MUTATING
3031
+ : NO_RETRYABLE_STATUSES;
2783
3032
  const serializedBody = body !== undefined ? JSON.stringify(body) : undefined;
2784
3033
  let lastError;
2785
3034
  for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
@@ -3197,8 +3446,7 @@ exports.QURLClient = QURLClient;
3197
3446
  /**
3198
3447
  * A LayerV-protected resource, bound to the client that produced it.
3199
3448
  *
3200
- * Obtained from {@link QURLClient.protectUrl},
3201
- * {@link QURLClient.connectorResource}, or {@link QURLClient.resourceById} —
3449
+ * Obtained from {@link QURLClient.protectUrl} or {@link QURLClient.resourceById} —
3202
3450
  * not constructed directly. Mint short-lived access links for the resource
3203
3451
  * with {@link createPortal} (qurl-go: `qurl.Resource`).
3204
3452
  */
@@ -3207,7 +3455,7 @@ class ProtectedResource {
3207
3455
  // CONTRIBUTING.md's dual-build rule bars) so the binding stays on the
3208
3456
  // instance and out of Object.keys/JSON.stringify output.
3209
3457
  #client;
3210
- /** The LayerV resource id (`r_` prefix). */
3458
+ /** The LayerV public resource ID. */
3211
3459
  id;
3212
3460
  /** The private URL protected by this resource, when known. */
3213
3461
  targetUrl;
@@ -3241,3 +3489,39 @@ class ProtectedResource {
3241
3489
  }
3242
3490
  }
3243
3491
  exports.ProtectedResource = ProtectedResource;
3492
+ const CONNECTOR_RESOURCE_CONSTRUCTOR_TOKEN = Symbol("validated ConnectorResource");
3493
+ /**
3494
+ * A validated qURL Connector resource bound to the client that loaded it.
3495
+ *
3496
+ * Resource identity, reverse-routing identity, and NHP admission identity are
3497
+ * distinct server-issued values. Callers must consume each field verbatim and
3498
+ * must never derive one from another.
3499
+ */
3500
+ class ConnectorResource {
3501
+ #client;
3502
+ resourceId;
3503
+ /** Producer-supplied CRID carried verbatim; verify it against a trusted key before trust. */
3504
+ crid;
3505
+ connectorRoutingId;
3506
+ knockResourceId;
3507
+ slug;
3508
+ alias;
3509
+ /** @internal Instances are returned by QURLClient after wire validation. */
3510
+ constructor(client, details, validationToken) {
3511
+ if (validationToken !== CONNECTOR_RESOURCE_CONSTRUCTOR_TOKEN) {
3512
+ throw clientValidationError("ConnectorResource cannot be constructed directly; load it through QURLClient");
3513
+ }
3514
+ this.#client = client;
3515
+ this.resourceId = details.resource_id;
3516
+ this.crid = details.crid;
3517
+ this.connectorRoutingId = details.connector_routing_id;
3518
+ this.knockResourceId = details.knock_resource_id;
3519
+ this.slug = details.slug;
3520
+ this.alias = details.alias ?? undefined;
3521
+ }
3522
+ /** Mint a short-lived portal for this Connector resource. */
3523
+ async createPortal(opts = {}, requestOptions) {
3524
+ return this.#client.createPortal(this.resourceId, opts, requestOptions);
3525
+ }
3526
+ }
3527
+ exports.ConnectorResource = ConnectorResource;
@@ -14,9 +14,13 @@ export declare const ERROR_CODE_UNEXPECTED_RESPONSE = "unexpected_response";
14
14
  export declare const ERROR_CODE_NETWORK = "network_error";
15
15
  export declare const ERROR_CODE_TIMEOUT = "timeout";
16
16
  export declare const ERROR_CODE_RUNTIME = "runtime_error";
17
- /** `connectorResource` found no resource for the connector id (client-detected, `status: 0`). */
17
+ /** A dispatched Connector resource mutation could have committed and must be reconciled. */
18
+ export declare const ERROR_CODE_CONNECTOR_RESOURCE_OUTCOME_UNKNOWN = "connector_resource_outcome_unknown";
19
+ /** A by-ID Connector resource lookup found a revoked lifecycle row. */
20
+ export declare const ERROR_CODE_CONNECTOR_RESOURCE_REVOKED = "connector_resource_revoked";
21
+ /** A Connector slug lookup found no active resource (client-detected, `status: 0`). */
18
22
  export declare const ERROR_CODE_RESOURCE_NOT_FOUND = "resource_not_found";
19
- /** `connectorResource` matched more than one resource where exactly one is required (client-detected, `status: 0`). */
23
+ /** A Connector slug lookup matched more than one active resource (client-detected, `status: 0`). */
20
24
  export declare const ERROR_CODE_AMBIGUOUS_RESOURCE = "ambiguous_resource";
21
25
  /** Fallback `.code` when the server returns a non-RFC-7807 response (HTML proxy page, plaintext gateway error, JSON without `error` envelope). */
22
26
  export declare const ERROR_CODE_UNKNOWN = "unknown";
@@ -78,7 +82,10 @@ export declare class NotFoundError extends QURLError {
78
82
  * returning HTML on a passthrough status, or a batch response missing
79
83
  * required fields).
80
84
  *
81
- * `instanceof ValidationError` catches both. To distinguish them, check
85
+ * `instanceof ValidationError` catches both on ordinary client/response
86
+ * paths. A Connector mutation wraps a post-dispatch `unexpected_response`
87
+ * in {@link ConnectorResourceOutcomeUnknownError}; inspect that error's
88
+ * typed `cause` before deciding whether to retry. To distinguish them, check
82
89
  * `.code` rather than using `instanceof` alone.
83
90
  *
84
91
  * **`.status` asymmetry within `code: "unexpected_response"`:**
@@ -107,6 +114,20 @@ export declare class RateLimitError extends QURLError {
107
114
  export declare class ServerError extends QURLError {
108
115
  constructor(data: QURLErrorData);
109
116
  }
117
+ /**
118
+ * A Connector resource ensure/delete was dispatched, but the response cannot
119
+ * prove whether it committed. Reconcile by immutable slug or resource ID
120
+ * before choosing whether to retry. The original typed failure is in `cause`.
121
+ * The wrapper uses `status: 0` so generic HTTP-status retry predicates cannot
122
+ * replay the mutation; inspect `cause.status` for the observed HTTP status.
123
+ * A valid HTTP 201 Connector row missing only `meta.found_existing` is a known
124
+ * committed/selected row with incomplete metadata and is not wrapped here.
125
+ */
126
+ export declare class ConnectorResourceOutcomeUnknownError extends QURLError {
127
+ readonly code: typeof ERROR_CODE_CONNECTOR_RESOURCE_OUTCOME_UNKNOWN;
128
+ readonly cause: QURLError;
129
+ constructor(cause: QURLError);
130
+ }
110
131
  /** Transport-level error — DNS failure, connection refused, etc. */
111
132
  export declare class NetworkError extends QURLError {
112
133
  constructor(message: string, options?: {
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.RuntimeError = exports.TimeoutError = exports.NetworkError = exports.ServerError = exports.RateLimitError = exports.ValidationError = exports.NotFoundError = exports.AuthorizationError = exports.AuthenticationError = exports.QURLError = exports.ERROR_CODE_UNKNOWN = exports.ERROR_CODE_AMBIGUOUS_RESOURCE = exports.ERROR_CODE_RESOURCE_NOT_FOUND = exports.ERROR_CODE_RUNTIME = exports.ERROR_CODE_TIMEOUT = exports.ERROR_CODE_NETWORK = exports.ERROR_CODE_UNEXPECTED_RESPONSE = exports.ERROR_CODE_CLIENT_VALIDATION = void 0;
3
+ exports.RuntimeError = exports.TimeoutError = exports.NetworkError = exports.ConnectorResourceOutcomeUnknownError = exports.ServerError = exports.RateLimitError = exports.ValidationError = exports.NotFoundError = exports.AuthorizationError = exports.AuthenticationError = exports.QURLError = exports.ERROR_CODE_UNKNOWN = exports.ERROR_CODE_AMBIGUOUS_RESOURCE = exports.ERROR_CODE_RESOURCE_NOT_FOUND = exports.ERROR_CODE_CONNECTOR_RESOURCE_REVOKED = exports.ERROR_CODE_CONNECTOR_RESOURCE_OUTCOME_UNKNOWN = exports.ERROR_CODE_RUNTIME = exports.ERROR_CODE_TIMEOUT = exports.ERROR_CODE_NETWORK = exports.ERROR_CODE_UNEXPECTED_RESPONSE = exports.ERROR_CODE_CLIENT_VALIDATION = void 0;
4
4
  exports.createError = createError;
5
5
  /**
6
6
  * Sentinel `.code` values for SDK-internal failure modes (no real HTTP
@@ -17,9 +17,13 @@ exports.ERROR_CODE_UNEXPECTED_RESPONSE = "unexpected_response";
17
17
  exports.ERROR_CODE_NETWORK = "network_error";
18
18
  exports.ERROR_CODE_TIMEOUT = "timeout";
19
19
  exports.ERROR_CODE_RUNTIME = "runtime_error";
20
- /** `connectorResource` found no resource for the connector id (client-detected, `status: 0`). */
20
+ /** A dispatched Connector resource mutation could have committed and must be reconciled. */
21
+ exports.ERROR_CODE_CONNECTOR_RESOURCE_OUTCOME_UNKNOWN = "connector_resource_outcome_unknown";
22
+ /** A by-ID Connector resource lookup found a revoked lifecycle row. */
23
+ exports.ERROR_CODE_CONNECTOR_RESOURCE_REVOKED = "connector_resource_revoked";
24
+ /** A Connector slug lookup found no active resource (client-detected, `status: 0`). */
21
25
  exports.ERROR_CODE_RESOURCE_NOT_FOUND = "resource_not_found";
22
- /** `connectorResource` matched more than one resource where exactly one is required (client-detected, `status: 0`). */
26
+ /** A Connector slug lookup matched more than one active resource (client-detected, `status: 0`). */
23
27
  exports.ERROR_CODE_AMBIGUOUS_RESOURCE = "ambiguous_resource";
24
28
  /** Fallback `.code` when the server returns a non-RFC-7807 response (HTML proxy page, plaintext gateway error, JSON without `error` envelope). */
25
29
  exports.ERROR_CODE_UNKNOWN = "unknown";
@@ -109,7 +113,10 @@ exports.NotFoundError = NotFoundError;
109
113
  * returning HTML on a passthrough status, or a batch response missing
110
114
  * required fields).
111
115
  *
112
- * `instanceof ValidationError` catches both. To distinguish them, check
116
+ * `instanceof ValidationError` catches both on ordinary client/response
117
+ * paths. A Connector mutation wraps a post-dispatch `unexpected_response`
118
+ * in {@link ConnectorResourceOutcomeUnknownError}; inspect that error's
119
+ * typed `cause` before deciding whether to retry. To distinguish them, check
113
120
  * `.code` rather than using `instanceof` alone.
114
121
  *
115
122
  * **`.status` asymmetry within `code: "unexpected_response"`:**
@@ -161,6 +168,29 @@ function attachErrorCause(error, options) {
161
168
  configurable: true,
162
169
  });
163
170
  }
171
+ /**
172
+ * A Connector resource ensure/delete was dispatched, but the response cannot
173
+ * prove whether it committed. Reconcile by immutable slug or resource ID
174
+ * before choosing whether to retry. The original typed failure is in `cause`.
175
+ * The wrapper uses `status: 0` so generic HTTP-status retry predicates cannot
176
+ * replay the mutation; inspect `cause.status` for the observed HTTP status.
177
+ * A valid HTTP 201 Connector row missing only `meta.found_existing` is a known
178
+ * committed/selected row with incomplete metadata and is not wrapped here.
179
+ */
180
+ class ConnectorResourceOutcomeUnknownError extends QURLError {
181
+ constructor(cause) {
182
+ super({
183
+ status: 0,
184
+ code: exports.ERROR_CODE_CONNECTOR_RESOURCE_OUTCOME_UNKNOWN,
185
+ title: "Connector Resource Outcome Unknown",
186
+ detail: `Mutation outcome is unknown; reconcile before retrying. ${cause.detail}`,
187
+ request_id: cause.requestId,
188
+ });
189
+ this.name = "ConnectorResourceOutcomeUnknownError";
190
+ attachErrorCause(this, { cause });
191
+ }
192
+ }
193
+ exports.ConnectorResourceOutcomeUnknownError = ConnectorResourceOutcomeUnknownError;
164
194
  /** Transport-level error — DNS failure, connection refused, etc. */
165
195
  class NetworkError extends QURLError {
166
196
  constructor(message, options) {