@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.
package/README.md CHANGED
@@ -68,11 +68,15 @@ same URL again returns the existing resource. `validFor` accepts a duration
68
68
  string (`'5m'`, `'24h'`) or a number of milliseconds (whole seconds, at least
69
69
  one minute); prefer short portal lifetimes.
70
70
 
71
- If qURL Connector already protects the service, use the connector id instead
72
- of calling `protectUrl`:
71
+ If qURL Connector protects the service, address its management-plane resource
72
+ by immutable slug instead of calling `protectUrl`:
73
73
 
74
74
  ```typescript
75
- const resource = await client.connectorResource('prod-dashboard');
75
+ const { resource, foundExisting } = await client.ensureConnectorResource(
76
+ 'prod-dashboard',
77
+ { idempotencyKey: 'connector-bootstrap-prod-dashboard' },
78
+ );
79
+ console.log(foundExisting ? 'Using existing connector resource' : 'Created connector resource');
76
80
  const portal = await resource.createPortal({
77
81
  validFor: '5m',
78
82
  targetPath: '/api/detect/eib_example',
@@ -84,6 +88,40 @@ checks the non-empty 2048-byte boundary; the API remains authoritative for the
84
88
  path grammar and the tunnel-only resource gate. `createPortalForUrl` rejects
85
89
  this option because it creates a URL resource.
86
90
 
91
+ `resource.resourceId`, `resource.connectorRoutingId`, and
92
+ `resource.knockResourceId` are three distinct server-issued values for public
93
+ identity, reverse routing, and NHP admission. Consume each verbatim; never
94
+ derive or substitute one for another. Use `getConnectorResource(resourceId)`
95
+ or `getConnectorResourceBySlug(slug)` for read-only lookup and
96
+ `deleteConnectorResource(resourceId)` to revoke it. This replaces the old
97
+ alias-based `connectorResource(connectorId)` method.
98
+ `ConnectorResource` instances cannot be constructed directly; the client
99
+ returns them only after validating the complete response contract.
100
+
101
+ `ensureConnectorResource` and `deleteConnectorResource` throw
102
+ `ConnectorResourceOutcomeUnknownError` when a dispatched mutation may have
103
+ committed but its response cannot prove the result. Reconcile by immutable slug
104
+ or resource ID before deciding whether to retry. The wrapper deliberately uses
105
+ `status: 0`; the original typed error is available as `cause`, including its
106
+ observed HTTP status.
107
+
108
+ Connector lifecycle calls make one HTTP attempt. The SDK does not pace or
109
+ replay them; the caller controls any retry after it reconciles state.
110
+
111
+ `ensureConnectorResource` does not generate an idempotency key because the slug
112
+ operation is already idempotent. If you supply an `idempotencyKey`, the SDK
113
+ forwards it, and you must reuse it on any deliberate retry. A 409
114
+ `bootstrap_key_consumed` response is outcome-unknown because resource binding
115
+ can finish before bootstrap-key consumption fails. The original bootstrap key
116
+ is terminal: do not retry it. Obtain a new bootstrap key and use normal
117
+ owner-authenticated lookup by immutable slug to reconcile the resource first.
118
+
119
+ The API does not apply idempotency replay to DELETE, so after an outcome-unknown
120
+ `deleteConnectorResource` call, reconcile by resource ID before issuing a
121
+ deliberate retry. A valid exact-201 resource missing only `meta.found_existing`
122
+ is known to have selected that row but still fails as an unwrapped
123
+ `unexpected_response` because required ensure metadata is absent.
124
+
87
125
  If you persist the resource id, future calls do not need to recreate the
88
126
  handle (no API call is made until you mint):
89
127
 
@@ -116,6 +154,9 @@ const portal = await resource.createPortal({
116
154
  });
117
155
  ```
118
156
 
157
+ `createPortal` sends `{}` when no options are set because the service requires
158
+ a JSON object at the wire level.
159
+
119
160
  The Node-only `@layervai/qurl/node` entry opens received qv2 links with native
120
161
  NHP UDP. It has no relay or HTTP-resolve fallback. Connector assignment and
121
162
  registration are separate producer operations and are not part of the portal
@@ -336,7 +377,9 @@ console.log(`Access granted to ${access.target_url} for ${access.access_grant?.e
336
377
  | `protectUrl(targetUrl, opts?)` | Protect a private URL → portal-minting `ProtectedResource` handle |
337
378
  | `resource.createPortal(opts?)` / `createPortal(resourceOrId, opts?)` | Mint a short-lived portal link; existing resources can set `targetPath` |
338
379
  | `createPortalForUrl(targetUrl, opts?)` | Protect + mint a URL resource; rejects `targetPath` |
339
- | `connectorResource(connectorId)` | Handle for a service qURL Connector already protects |
380
+ | `ensureConnectorResource(slug, requestOptions?)` | Find or create an active Connector resource by immutable slug |
381
+ | `getConnectorResource(resourceId)` / `getConnectorResourceBySlug(slug)` | Load a validated Connector resource by immutable identity |
382
+ | `deleteConnectorResource(resourceId)` | Revoke a Connector resource by immutable public resource ID |
340
383
  | `resourceById(id)` | Handle from a stored resource id (no API call) |
341
384
  | `enterPortal(linkOrToken)` | Open a qURL link programmatically → `ResourceHandle` |
342
385
 
@@ -449,7 +492,7 @@ try {
449
492
  Client-detected failures use `status: 0` with a discriminating `code`:
450
493
  `"client_validation"` for bad input caught before a request, and — on the
451
494
  portal surface — `"resource_not_found"` / `"ambiguous_resource"` when
452
- `connectorResource` cannot resolve a connector id to exactly one resource,
495
+ `getConnectorResourceBySlug` cannot resolve a slug to exactly one resource,
453
496
  and `"unexpected_response"` when a response is missing required fields (e.g.
454
497
  `enterPortal` failing closed on a grant with no resource URL).
455
498
 
@@ -92,23 +92,19 @@ export declare class QURLClient {
92
92
  * need the full resource details.
93
93
  */
94
94
  resourceById(id: string): ProtectedResource;
95
+ /** Find or create the active qURL Connector resource for an immutable slug. */
96
+ ensureConnectorResource(slug: string, requestOptions?: RequestOptions): Promise<EnsureConnectorResourceResult>;
97
+ /** Fetch a qURL Connector resource by its immutable public resource ID. */
98
+ getConnectorResource(resourceId: string): Promise<ConnectorResource>;
99
+ /** Fetch the single active qURL Connector resource for an immutable slug. */
100
+ getConnectorResourceBySlug(slug: string): Promise<ConnectorResource>;
95
101
  /**
96
- * Return the resource qURL Connector created for `connectorId`.
102
+ * Revoke a qURL Connector resource by immutable public resource ID.
97
103
  *
98
- * Use when qURL Connector already protects the service — do not call
99
- * {@link protectUrl} again for the same service. The connector id is the
100
- * resource slug LayerV stores for that connector; the LayerV API performs
101
- * the slug lookup, and the SDK confirms the returned alias matches
102
- * `connectorId` before binding the handle (qurl-go:
103
- * `Client.ConnectorResource`).
104
- *
105
- * Throws {@link NotFoundError} (`status: 0`, `code: "resource_not_found"`)
106
- * when no resource exists for the connector id, {@link QURLError}
107
- * (`code: "ambiguous_resource"`) when the lookup returns more than one
108
- * resource, and {@link ValidationError} (`code: "unexpected_response"`)
109
- * when the returned resource's alias is missing or does not match.
104
+ * The API does not apply Idempotency-Key replay to DELETE. After an
105
+ * outcome-unknown failure, reconcile by ID before issuing a deliberate retry.
110
106
  */
111
- connectorResource(connectorId: string): Promise<ProtectedResource>;
107
+ deleteConnectorResource(resourceId: string): Promise<void>;
112
108
  /**
113
109
  * Mint a short-lived qURL link (a portal) for a protected resource.
114
110
  *
@@ -116,7 +112,7 @@ export declare class QURLClient {
116
112
  * reach one private resource. Recipients open `portal.link` directly and
117
113
  * need no LayerV credentials. Prefer short lifetimes such as
118
114
  * `{ validFor: "5m" }`. Accepts a {@link ProtectedResource} handle or a
119
- * resource id (`r_` prefix). REST-shaped equivalents: {@link mintLink} /
115
+ * public resource ID string. REST-shaped equivalents: {@link mintLink} /
120
116
  * {@link createQurlForResource}.
121
117
  *
122
118
  * Duration options take a string (`"5m"`, `"24h"`; server-validated) or a
@@ -509,14 +505,13 @@ export declare class QURLClient {
509
505
  /**
510
506
  * A LayerV-protected resource, bound to the client that produced it.
511
507
  *
512
- * Obtained from {@link QURLClient.protectUrl},
513
- * {@link QURLClient.connectorResource}, or {@link QURLClient.resourceById} —
508
+ * Obtained from {@link QURLClient.protectUrl} or {@link QURLClient.resourceById} —
514
509
  * not constructed directly. Mint short-lived access links for the resource
515
510
  * with {@link createPortal} (qurl-go: `qurl.Resource`).
516
511
  */
517
512
  export declare class ProtectedResource {
518
513
  #private;
519
- /** The LayerV resource id (`r_` prefix). */
514
+ /** The LayerV public resource ID. */
520
515
  readonly id: string;
521
516
  /** The private URL protected by this resource, when known. */
522
517
  readonly targetUrl?: string;
@@ -540,3 +535,31 @@ export declare class ProtectedResource {
540
535
  */
541
536
  createPortal(opts?: CreatePortalOptions, requestOptions?: RequestOptions): Promise<Portal>;
542
537
  }
538
+ /** Result of idempotently ensuring a qURL Connector resource. */
539
+ export interface EnsureConnectorResourceResult {
540
+ resource: ConnectorResource;
541
+ foundExisting: boolean;
542
+ }
543
+ declare const CONNECTOR_RESOURCE_CONSTRUCTOR_TOKEN: unique symbol;
544
+ /**
545
+ * A validated qURL Connector resource bound to the client that loaded it.
546
+ *
547
+ * Resource identity, reverse-routing identity, and NHP admission identity are
548
+ * distinct server-issued values. Callers must consume each field verbatim and
549
+ * must never derive one from another.
550
+ */
551
+ export declare class ConnectorResource {
552
+ #private;
553
+ readonly resourceId: string;
554
+ /** Producer-supplied CRID carried verbatim; verify it against a trusted key before trust. */
555
+ readonly crid?: string;
556
+ readonly connectorRoutingId: string;
557
+ readonly knockResourceId: string;
558
+ readonly slug: string;
559
+ readonly alias?: string;
560
+ /** @internal Instances are returned by QURLClient after wire validation. */
561
+ constructor(client: QURLClient, details: Resource, validationToken: typeof CONNECTOR_RESOURCE_CONSTRUCTOR_TOKEN);
562
+ /** Mint a short-lived portal for this Connector resource. */
563
+ createPortal(opts?: CreatePortalOptions, requestOptions?: RequestOptions): Promise<Portal>;
564
+ }
565
+ export {};
@@ -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");
@@ -323,14 +323,14 @@ function validateRequestOptions(options) {
323
323
  }
324
324
  }
325
325
  }
326
- function idempotencyKeyForRequest(method, options) {
326
+ function idempotencyKeyForRequest(method, options, generateWhenMissing = true) {
327
327
  // Validate before method gating so future read-only methods that accept
328
328
  // RequestOptions fail loudly on malformed options instead of discarding them.
329
329
  validateRequestOptions(options);
330
330
  if (!IDEMPOTENCY_KEY_METHODS.has(method)) {
331
331
  return undefined;
332
332
  }
333
- return options?.idempotencyKey ?? generateUuidV7();
333
+ return options?.idempotencyKey ?? (generateWhenMissing ? generateUuidV7() : undefined);
334
334
  }
335
335
  function generateUuidV7() {
336
336
  const bytes = new Uint8Array(16);
@@ -724,6 +724,14 @@ const MAX_TARGET_PATH = 2048;
724
724
  const MAX_TAGS = 10;
725
725
  const MAX_TAG_LENGTH = 50;
726
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]$/;
727
735
  // CreateQurlRequest.target_url pattern is loose (just a URI) but
728
736
  // UpdateQurlRequest.tags pattern is specific — enforce it here.
729
737
  const TAG_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9 _-]*$/;
@@ -845,6 +853,163 @@ function requireNonEmptyId(id, method, field = "id") {
845
853
  throw clientValidationError(`${method}: ${field} must not include leading or trailing whitespace`);
846
854
  }
847
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
+ }
848
1013
  function requireValidTags(tags) {
849
1014
  // null tolerance for untyped-JS callers — treated as "don't touch."
850
1015
  // Duplicates pass through to the server (authoritative on dedupe policy).
@@ -1427,10 +1592,9 @@ function parsePortal(data, method) {
1427
1592
  };
1428
1593
  }
1429
1594
  /**
1430
- * Validate portal options and build the create-portal wire body, or
1431
- * `undefined` when every option is omitted so no request body is sent and
1432
- * the server applies its default lifetime (matching mintLink /
1433
- * 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.
1434
1598
  */
1435
1599
  function buildCreatePortalBody(opts, method) {
1436
1600
  requireObjectInput(opts, method);
@@ -1468,7 +1632,7 @@ function buildCreatePortalBody(opts, method) {
1468
1632
  body.session_duration = wireSessionDuration;
1469
1633
  if (targetPath !== undefined)
1470
1634
  body.target_path = targetPath;
1471
- return Object.keys(body).length > 0 ? body : undefined;
1635
+ return body;
1472
1636
  }
1473
1637
  /** qURL API client. */
1474
1638
  class QURLClient {
@@ -1791,49 +1955,110 @@ class QURLClient {
1791
1955
  requireNonEmptyId(id, "resourceById");
1792
1956
  return new ProtectedResource(this, id);
1793
1957
  }
1794
- /**
1795
- * Return the resource qURL Connector created for `connectorId`.
1796
- *
1797
- * Use when qURL Connector already protects the service — do not call
1798
- * {@link protectUrl} again for the same service. The connector id is the
1799
- * resource slug LayerV stores for that connector; the LayerV API performs
1800
- * the slug lookup, and the SDK confirms the returned alias matches
1801
- * `connectorId` before binding the handle (qurl-go:
1802
- * `Client.ConnectorResource`).
1803
- *
1804
- * Throws {@link NotFoundError} (`status: 0`, `code: "resource_not_found"`)
1805
- * when no resource exists for the connector id, {@link QURLError}
1806
- * (`code: "ambiguous_resource"`) when the lookup returns more than one
1807
- * resource, and {@link ValidationError} (`code: "unexpected_response"`)
1808
- * when the returned resource's alias is missing or does not match.
1809
- */
1810
- async connectorResource(connectorId) {
1811
- requireNonEmptyId(connectorId, "connectorResource", "connector id");
1812
- const { resources } = await this.listResources({ slug: connectorId });
1813
- 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) {
1814
2031
  throw new errors_js_1.NotFoundError({
1815
2032
  status: 0,
1816
2033
  code: errors_js_1.ERROR_CODE_RESOURCE_NOT_FOUND,
1817
2034
  title: "Resource Not Found",
1818
- detail: `connectorResource: no resource found for connector "${connectorId}"`,
2035
+ detail: "getConnectorResourceBySlug: no active resource exists for the requested slug",
1819
2036
  });
1820
2037
  }
1821
- if (resources.length > 1) {
2038
+ if (data.length > 1) {
1822
2039
  throw new errors_js_1.QURLError({
1823
2040
  status: 0,
1824
2041
  code: errors_js_1.ERROR_CODE_AMBIGUOUS_RESOURCE,
1825
2042
  title: "Ambiguous Resource",
1826
- detail: `connectorResource: connector "${connectorId}" returned ${resources.length} resources`,
2043
+ detail: `getConnectorResourceBySlug: expected one active resource, got ${data.length}`,
1827
2044
  });
1828
2045
  }
1829
- const resource = resources[0];
1830
- if (resource.alias !== connectorId) {
1831
- 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)}`);
1832
2058
  }
1833
- if (typeof resource.resource_id !== "string" || resource.resource_id.trim() === "") {
1834
- throw unexpectedResponseError("connectorResource: response is missing resource_id");
2059
+ catch (error) {
2060
+ classifyConnectorMutationFailure("delete", error);
1835
2061
  }
1836
- return new ProtectedResource(this, resource.resource_id, resource.target_url, resource);
1837
2062
  }
1838
2063
  /**
1839
2064
  * Mint a short-lived qURL link (a portal) for a protected resource.
@@ -1842,7 +2067,7 @@ class QURLClient {
1842
2067
  * reach one private resource. Recipients open `portal.link` directly and
1843
2068
  * need no LayerV credentials. Prefer short lifetimes such as
1844
2069
  * `{ validFor: "5m" }`. Accepts a {@link ProtectedResource} handle or a
1845
- * resource id (`r_` prefix). REST-shaped equivalents: {@link mintLink} /
2070
+ * public resource ID string. REST-shaped equivalents: {@link mintLink} /
1846
2071
  * {@link createQurlForResource}.
1847
2072
  *
1848
2073
  * Duration options take a string (`"5m"`, `"24h"`; server-validated) or a
@@ -2775,8 +3000,8 @@ class QURLClient {
2775
3000
  */
2776
3001
  async rawRequest(method, path, body, rawOptions = {}) {
2777
3002
  const url = `${this.baseUrl}${path}`;
2778
- const { passthroughStatuses = NO_PASSTHROUGH_STATUSES, requestOptions, allowEmptySuccessBody = false, } = rawOptions;
2779
- 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);
2780
3005
  const headers = {
2781
3006
  Authorization: `Bearer ${this.apiKey}`,
2782
3007
  Accept: "application/json",
@@ -2795,13 +3020,15 @@ class QURLClient {
2795
3020
  // retry rule and avoids SDK pacing on an application request path. The
2796
3021
  // caller receives retryAfter when supplied and must reconcile resource
2797
3022
  // state before it chooses to issue another delete.
2798
- const idempotencyKeyBackedRetry = MUTATING_RETRY_METHODS.has(method);
2799
- const retryFetchFailure = method === "GET" || idempotencyKeyBackedRetry;
2800
- const retryable = method === "GET"
2801
- ? RETRYABLE_STATUS
2802
- : idempotencyKeyBackedRetry
2803
- ? RETRYABLE_STATUS_MUTATING
2804
- : 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;
2805
3032
  const serializedBody = body !== undefined ? JSON.stringify(body) : undefined;
2806
3033
  let lastError;
2807
3034
  for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
@@ -3219,8 +3446,7 @@ exports.QURLClient = QURLClient;
3219
3446
  /**
3220
3447
  * A LayerV-protected resource, bound to the client that produced it.
3221
3448
  *
3222
- * Obtained from {@link QURLClient.protectUrl},
3223
- * {@link QURLClient.connectorResource}, or {@link QURLClient.resourceById} —
3449
+ * Obtained from {@link QURLClient.protectUrl} or {@link QURLClient.resourceById} —
3224
3450
  * not constructed directly. Mint short-lived access links for the resource
3225
3451
  * with {@link createPortal} (qurl-go: `qurl.Resource`).
3226
3452
  */
@@ -3229,7 +3455,7 @@ class ProtectedResource {
3229
3455
  // CONTRIBUTING.md's dual-build rule bars) so the binding stays on the
3230
3456
  // instance and out of Object.keys/JSON.stringify output.
3231
3457
  #client;
3232
- /** The LayerV resource id (`r_` prefix). */
3458
+ /** The LayerV public resource ID. */
3233
3459
  id;
3234
3460
  /** The private URL protected by this resource, when known. */
3235
3461
  targetUrl;
@@ -3263,3 +3489,39 @@ class ProtectedResource {
3263
3489
  }
3264
3490
  }
3265
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;