@layervai/qurl 0.5.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.
@@ -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";
@@ -320,14 +320,14 @@ function validateRequestOptions(options) {
320
320
  }
321
321
  }
322
322
  }
323
- function idempotencyKeyForRequest(method, options) {
323
+ function idempotencyKeyForRequest(method, options, generateWhenMissing = true) {
324
324
  // Validate before method gating so future read-only methods that accept
325
325
  // RequestOptions fail loudly on malformed options instead of discarding them.
326
326
  validateRequestOptions(options);
327
327
  if (!IDEMPOTENCY_KEY_METHODS.has(method)) {
328
328
  return undefined;
329
329
  }
330
- return options?.idempotencyKey ?? generateUuidV7();
330
+ return options?.idempotencyKey ?? (generateWhenMissing ? generateUuidV7() : undefined);
331
331
  }
332
332
  function generateUuidV7() {
333
333
  const bytes = new Uint8Array(16);
@@ -721,6 +721,14 @@ const MAX_TARGET_PATH = 2048;
721
721
  const MAX_TAGS = 10;
722
722
  const MAX_TAG_LENGTH = 50;
723
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]$/;
724
732
  // CreateQurlRequest.target_url pattern is loose (just a URI) but
725
733
  // UpdateQurlRequest.tags pattern is specific — enforce it here.
726
734
  const TAG_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9 _-]*$/;
@@ -842,6 +850,163 @@ function requireNonEmptyId(id, method, field = "id") {
842
850
  throw clientValidationError(`${method}: ${field} must not include leading or trailing whitespace`);
843
851
  }
844
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
+ }
845
1010
  function requireValidTags(tags) {
846
1011
  // null tolerance for untyped-JS callers — treated as "don't touch."
847
1012
  // Duplicates pass through to the server (authoritative on dedupe policy).
@@ -1424,10 +1589,9 @@ function parsePortal(data, method) {
1424
1589
  };
1425
1590
  }
1426
1591
  /**
1427
- * Validate portal options and build the create-portal wire body, or
1428
- * `undefined` when every option is omitted so no request body is sent and
1429
- * the server applies its default lifetime (matching mintLink /
1430
- * 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.
1431
1595
  */
1432
1596
  function buildCreatePortalBody(opts, method) {
1433
1597
  requireObjectInput(opts, method);
@@ -1465,7 +1629,7 @@ function buildCreatePortalBody(opts, method) {
1465
1629
  body.session_duration = wireSessionDuration;
1466
1630
  if (targetPath !== undefined)
1467
1631
  body.target_path = targetPath;
1468
- return Object.keys(body).length > 0 ? body : undefined;
1632
+ return body;
1469
1633
  }
1470
1634
  /** qURL API client. */
1471
1635
  export class QURLClient {
@@ -1788,49 +1952,110 @@ export class QURLClient {
1788
1952
  requireNonEmptyId(id, "resourceById");
1789
1953
  return new ProtectedResource(this, id);
1790
1954
  }
1791
- /**
1792
- * Return the resource qURL Connector created for `connectorId`.
1793
- *
1794
- * Use when qURL Connector already protects the service — do not call
1795
- * {@link protectUrl} again for the same service. The connector id is the
1796
- * resource slug LayerV stores for that connector; the LayerV API performs
1797
- * the slug lookup, and the SDK confirms the returned alias matches
1798
- * `connectorId` before binding the handle (qurl-go:
1799
- * `Client.ConnectorResource`).
1800
- *
1801
- * Throws {@link NotFoundError} (`status: 0`, `code: "resource_not_found"`)
1802
- * when no resource exists for the connector id, {@link QURLError}
1803
- * (`code: "ambiguous_resource"`) when the lookup returns more than one
1804
- * resource, and {@link ValidationError} (`code: "unexpected_response"`)
1805
- * when the returned resource's alias is missing or does not match.
1806
- */
1807
- async connectorResource(connectorId) {
1808
- requireNonEmptyId(connectorId, "connectorResource", "connector id");
1809
- const { resources } = await this.listResources({ slug: connectorId });
1810
- 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) {
1811
2028
  throw new NotFoundError({
1812
2029
  status: 0,
1813
2030
  code: ERROR_CODE_RESOURCE_NOT_FOUND,
1814
2031
  title: "Resource Not Found",
1815
- detail: `connectorResource: no resource found for connector "${connectorId}"`,
2032
+ detail: "getConnectorResourceBySlug: no active resource exists for the requested slug",
1816
2033
  });
1817
2034
  }
1818
- if (resources.length > 1) {
2035
+ if (data.length > 1) {
1819
2036
  throw new QURLError({
1820
2037
  status: 0,
1821
2038
  code: ERROR_CODE_AMBIGUOUS_RESOURCE,
1822
2039
  title: "Ambiguous Resource",
1823
- detail: `connectorResource: connector "${connectorId}" returned ${resources.length} resources`,
2040
+ detail: `getConnectorResourceBySlug: expected one active resource, got ${data.length}`,
1824
2041
  });
1825
2042
  }
1826
- const resource = resources[0];
1827
- if (resource.alias !== connectorId) {
1828
- 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)}`);
1829
2055
  }
1830
- if (typeof resource.resource_id !== "string" || resource.resource_id.trim() === "") {
1831
- throw unexpectedResponseError("connectorResource: response is missing resource_id");
2056
+ catch (error) {
2057
+ classifyConnectorMutationFailure("delete", error);
1832
2058
  }
1833
- return new ProtectedResource(this, resource.resource_id, resource.target_url, resource);
1834
2059
  }
1835
2060
  /**
1836
2061
  * Mint a short-lived qURL link (a portal) for a protected resource.
@@ -1839,7 +2064,7 @@ export class QURLClient {
1839
2064
  * reach one private resource. Recipients open `portal.link` directly and
1840
2065
  * need no LayerV credentials. Prefer short lifetimes such as
1841
2066
  * `{ validFor: "5m" }`. Accepts a {@link ProtectedResource} handle or a
1842
- * resource id (`r_` prefix). REST-shaped equivalents: {@link mintLink} /
2067
+ * public resource ID string. REST-shaped equivalents: {@link mintLink} /
1843
2068
  * {@link createQurlForResource}.
1844
2069
  *
1845
2070
  * Duration options take a string (`"5m"`, `"24h"`; server-validated) or a
@@ -2772,8 +2997,8 @@ export class QURLClient {
2772
2997
  */
2773
2998
  async rawRequest(method, path, body, rawOptions = {}) {
2774
2999
  const url = `${this.baseUrl}${path}`;
2775
- const { passthroughStatuses = NO_PASSTHROUGH_STATUSES, requestOptions, allowEmptySuccessBody = false, } = rawOptions;
2776
- 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);
2777
3002
  const headers = {
2778
3003
  Authorization: `Bearer ${this.apiKey}`,
2779
3004
  Accept: "application/json",
@@ -2792,13 +3017,15 @@ export class QURLClient {
2792
3017
  // retry rule and avoids SDK pacing on an application request path. The
2793
3018
  // caller receives retryAfter when supplied and must reconcile resource
2794
3019
  // state before it chooses to issue another delete.
2795
- const idempotencyKeyBackedRetry = MUTATING_RETRY_METHODS.has(method);
2796
- const retryFetchFailure = method === "GET" || idempotencyKeyBackedRetry;
2797
- const retryable = method === "GET"
2798
- ? RETRYABLE_STATUS
2799
- : idempotencyKeyBackedRetry
2800
- ? RETRYABLE_STATUS_MUTATING
2801
- : 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;
2802
3029
  const serializedBody = body !== undefined ? JSON.stringify(body) : undefined;
2803
3030
  let lastError;
2804
3031
  for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
@@ -3215,8 +3442,7 @@ export class QURLClient {
3215
3442
  /**
3216
3443
  * A LayerV-protected resource, bound to the client that produced it.
3217
3444
  *
3218
- * Obtained from {@link QURLClient.protectUrl},
3219
- * {@link QURLClient.connectorResource}, or {@link QURLClient.resourceById} —
3445
+ * Obtained from {@link QURLClient.protectUrl} or {@link QURLClient.resourceById} —
3220
3446
  * not constructed directly. Mint short-lived access links for the resource
3221
3447
  * with {@link createPortal} (qurl-go: `qurl.Resource`).
3222
3448
  */
@@ -3225,7 +3451,7 @@ export class ProtectedResource {
3225
3451
  // CONTRIBUTING.md's dual-build rule bars) so the binding stays on the
3226
3452
  // instance and out of Object.keys/JSON.stringify output.
3227
3453
  #client;
3228
- /** The LayerV resource id (`r_` prefix). */
3454
+ /** The LayerV public resource ID. */
3229
3455
  id;
3230
3456
  /** The private URL protected by this resource, when known. */
3231
3457
  targetUrl;
@@ -3258,4 +3484,39 @@ export class ProtectedResource {
3258
3484
  return this.#client.createPortal(this, opts, requestOptions);
3259
3485
  }
3260
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
+ }
3261
3522
  //# sourceMappingURL=client.js.map