@layervai/qurl 0.3.0 → 0.3.1

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_CLIENT_VALIDATION, ERROR_CODE_UNEXPECTED_RESPONSE, ERROR_CODE_UNKNOWN, NetworkError, QURLError, RuntimeError, TimeoutError, ValidationError, } from "./errors.js";
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";
2
2
  import { VERSION } from "./version.js";
3
3
  const DEFAULT_BASE_URL = "https://api.layerv.ai";
4
4
  const DEFAULT_MAX_RETRIES = 3;
@@ -127,6 +127,27 @@ const CREATE_QURL_FOR_RESOURCE_FIELD_KEYS = [
127
127
  "target_path",
128
128
  ];
129
129
  assertExhaustive(true);
130
+ /**
131
+ * Options accepted by the portal-verb `protectUrl` / `createPortal`
132
+ * surface. These are camelCase caller-facing options (the portal verbs are
133
+ * the idiomatic layer); the build helpers map them onto the same wire
134
+ * fields the REST-shaped methods send.
135
+ */
136
+ const PROTECT_URL_OPTION_KEYS = [
137
+ "description",
138
+ "tags",
139
+ "customDomain",
140
+ "alias",
141
+ ];
142
+ assertExhaustive(true);
143
+ const CREATE_PORTAL_OPTION_KEYS = [
144
+ "validFor",
145
+ "label",
146
+ "oneTimeUse",
147
+ "maxSessions",
148
+ "sessionDuration",
149
+ ];
150
+ assertExhaustive(true);
130
151
  const UPDATE_RESOURCE_QURL_FIELD_KEYS = [
131
152
  "extend_by",
132
153
  "expires_at",
@@ -376,11 +397,11 @@ function requireNonEmptyIfPresent(value, field) {
376
397
  throw clientValidationError(`${field}: must not be an empty string`);
377
398
  }
378
399
  }
379
- function requireMaxSessionsInRange(value) {
400
+ function requireMaxSessionsInRange(value, field = "max_sessions") {
380
401
  if (value === undefined)
381
402
  return;
382
403
  if (!Number.isInteger(value) || value < 0 || value > MAX_MAX_SESSIONS) {
383
- throw clientValidationError(`max_sessions: must be an integer between 0 and ${MAX_MAX_SESSIONS} (got ${value})`);
404
+ throw clientValidationError(`${field}: must be an integer between 0 and ${MAX_MAX_SESSIONS} (got ${value})`);
384
405
  }
385
406
  }
386
407
  function requireBooleanIfPresent(value, field) {
@@ -887,6 +908,195 @@ function batchItemResultValidationReason(entry) {
887
908
  }
888
909
  return "missing/invalid 'success' discriminant (expected boolean)";
889
910
  }
911
+ // ---- Portal-verb helpers (mirrors qurl-go) -------------------------------
912
+ // Client-side guardrails for the portal flow (protectUrl → createPortal →
913
+ // enterPortal), kept in lockstep with qurl-go's option validation and
914
+ // qurl-python's port of it.
915
+ /**
916
+ * Client-side floor for portal lifetimes, mirroring qurl-go's `ValidFor`.
917
+ * The LayerV API remains the source of truth for account limits; there is
918
+ * intentionally no SDK-side maximum because an account may allow
919
+ * longer-lived portals.
920
+ */
921
+ const MIN_PORTAL_VALID_FOR_SECONDS = 60;
922
+ /**
923
+ * Client-side floor for session durations, mirroring qurl-go's
924
+ * `WithSessionDuration` (at least one second; zero is rejected rather than
925
+ * treated as the server default).
926
+ */
927
+ const MIN_SESSION_DURATION_SECONDS = 1;
928
+ // Shape of qurl-go's offline signed-fragment links: three or more
929
+ // dot-separated base64url parts (<version>.<claims>.<secret>.<sig>).
930
+ // Detected only to give those links a precise error in extractAccessToken.
931
+ const SIGNED_FRAGMENT_RE = /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+){2,}$/;
932
+ // Access-token grammar accepted by enterPortal (base64url charset).
933
+ const ACCESS_TOKEN_RE = /^[A-Za-z0-9_-]+$/;
934
+ /**
935
+ * Serialize a duration option to the API's h/m/s duration grammar.
936
+ *
937
+ * Strings pass through unvalidated — the server is authoritative, same as
938
+ * the REST-shaped `expires_in` fields. Numbers are milliseconds and get
939
+ * qurl-go's client-side guardrails: whole seconds only and at least
940
+ * `minimumSeconds` (checked in that order so e.g. 30.5s reports "at least
941
+ * 60s" in every SDK). Serialized with hours as the largest unit so all API
942
+ * duration fields use the same grammar across SDKs.
943
+ */
944
+ function serializeApiDuration(value, field, minimumSeconds) {
945
+ if (value === undefined)
946
+ return undefined;
947
+ if (typeof value === "string") {
948
+ if (value.trim() === "") {
949
+ throw clientValidationError(`${field}: must be a non-empty duration string`);
950
+ }
951
+ return value;
952
+ }
953
+ if (typeof value === "number") {
954
+ // NaN fails no comparison, so an explicit finite check keeps the
955
+ // error actionable instead of a misleading "whole seconds" report.
956
+ if (!Number.isFinite(value)) {
957
+ throw clientValidationError(`${field}: must be a finite number of milliseconds`);
958
+ }
959
+ if (value < minimumSeconds * 1000) {
960
+ throw clientValidationError(`${field}: must be at least ${minimumSeconds}s`);
961
+ }
962
+ if (value % 1000 !== 0) {
963
+ throw clientValidationError(`${field}: must be whole seconds`);
964
+ }
965
+ const seconds = value / 1000;
966
+ if (seconds % 3600 === 0)
967
+ return `${seconds / 3600}h`;
968
+ if (seconds % 60 === 0)
969
+ return `${seconds / 60}m`;
970
+ return `${seconds}s`;
971
+ }
972
+ throw clientValidationError(`${field}: must be a duration string (e.g. "5m") or a number of milliseconds`);
973
+ }
974
+ /**
975
+ * Portal-verb target URL validation (qurl-go `ProtectURL` parity). On top of
976
+ * the shared scheme/length check, the URL must carry a host and must parse,
977
+ * and embedded credentials (userinfo) are rejected. The added checks never
978
+ * echo the URL — a userinfo URL contains credentials that must stay out of
979
+ * logs and error messages. The REST-shaped compatibility layer keeps its
980
+ * original prefix-only check so its accepted inputs don't change.
981
+ */
982
+ function requirePortalTargetUrl(targetUrl) {
983
+ requireValidTargetUrl(targetUrl);
984
+ const url = targetUrl; // requireValidTargetUrl only returns for http(s) strings
985
+ // WHATWG URL normalizes away an empty authority ("https:///x" parses
986
+ // with host "x"), so the host-presence check runs on the raw string,
987
+ // mirroring qurl-go's url.Parse which rejects authority-less URLs.
988
+ const afterScheme = url.slice(url.indexOf("://") + 3);
989
+ if (afterScheme === "" || "/?#".includes(afterScheme[0])) {
990
+ throw clientValidationError("target_url: must include a host");
991
+ }
992
+ let parsed;
993
+ try {
994
+ parsed = new URL(url);
995
+ }
996
+ catch {
997
+ throw clientValidationError("target_url: malformed URL");
998
+ }
999
+ if (parsed.username !== "" || parsed.password !== "") {
1000
+ throw clientValidationError("target_url: must not include embedded credentials (userinfo)");
1001
+ }
1002
+ }
1003
+ /**
1004
+ * Extract the access token from a qURL link, or pass a bare token through.
1005
+ *
1006
+ * Platform-issued links carry the access token in the URL fragment
1007
+ * (`https://qurl.link/#at_...`). Error messages deliberately never echo the
1008
+ * input — qURL links are credentials and must stay out of logs.
1009
+ */
1010
+ function extractAccessToken(qurlLink) {
1011
+ if (typeof qurlLink !== "string" || qurlLink.trim() === "") {
1012
+ throw clientValidationError("qurlLink: must be a non-empty string");
1013
+ }
1014
+ const hashIndex = qurlLink.indexOf("#");
1015
+ const token = hashIndex >= 0 ? qurlLink.slice(hashIndex + 1) : qurlLink;
1016
+ if (SIGNED_FRAGMENT_RE.test(token)) {
1017
+ throw clientValidationError("qurlLink: this is a signed qURL link (offline fragment format), which cannot be " +
1018
+ "opened through the API resolver — enterPortal only accepts platform links " +
1019
+ "(https://qurl.link/#at_...) or bare access tokens");
1020
+ }
1021
+ if (token === "" || !ACCESS_TOKEN_RE.test(token)) {
1022
+ throw clientValidationError("qurlLink: no access token found — pass a platform qURL link " +
1023
+ "(https://qurl.link/#at_...) or a bare access token");
1024
+ }
1025
+ return token;
1026
+ }
1027
+ /**
1028
+ * Parse an RFC 3339 timestamp from the API into a `Date`, or `undefined`
1029
+ * when absent or unparseable. The portal surface exposes `Date` (matching
1030
+ * qurl-go's `time.Time` and qurl-python's `datetime`); the REST-shaped
1031
+ * layer keeps raw wire strings.
1032
+ */
1033
+ function parseApiDate(value) {
1034
+ if (typeof value !== "string" || value === "")
1035
+ return undefined;
1036
+ const parsed = new Date(value);
1037
+ return Number.isNaN(parsed.getTime()) ? undefined : parsed;
1038
+ }
1039
+ /**
1040
+ * Parse a {@link Portal} from a create-portal API response. `resource_id`
1041
+ * and `qurl_link` are required, mirroring qurl-go's fail-closed response
1042
+ * check; everything else is optional because the two mint endpoints differ
1043
+ * in which ancillary fields they return.
1044
+ */
1045
+ function parsePortal(data, method) {
1046
+ if (typeof data?.resource_id !== "string" || data.resource_id.trim() === "") {
1047
+ throw unexpectedResponseError(`${method}: response is missing resource_id`);
1048
+ }
1049
+ if (typeof data.qurl_link !== "string" || data.qurl_link.trim() === "") {
1050
+ throw unexpectedResponseError(`${method}: response is missing qurl_link`);
1051
+ }
1052
+ return {
1053
+ resourceId: data.resource_id,
1054
+ link: data.qurl_link,
1055
+ site: data.qurl_site,
1056
+ expiresAt: parseApiDate(data.expires_at),
1057
+ // Same empty-string → absent normalization the REST-shaped layer's
1058
+ // consumers apply to qurl_id.
1059
+ qurlId: data.qurl_id || undefined,
1060
+ label: data.label,
1061
+ };
1062
+ }
1063
+ /**
1064
+ * Validate portal options and build the create-portal wire body, or
1065
+ * `undefined` when every option is omitted so no request body is sent and
1066
+ * the server applies its default lifetime (matching mintLink /
1067
+ * createQurlForResource).
1068
+ */
1069
+ function buildCreatePortalBody(opts, method) {
1070
+ requireObjectInput(opts, method);
1071
+ requireNoUnknownFields(opts, CREATE_PORTAL_OPTION_KEYS, method);
1072
+ // null → omitted, via the SDK-wide normalizePatchFields convention.
1073
+ const { validFor, label, oneTimeUse, maxSessions, sessionDuration } = normalizePatchFields(opts, CREATE_PORTAL_OPTION_KEYS);
1074
+ if (label !== undefined) {
1075
+ requireMaxLength(label, "label", MAX_LABEL);
1076
+ // qurl-go's WithLabel rejects blank labels; the REST-shaped mintLink
1077
+ // keeps its permissive whitespace behavior, so the check lives here.
1078
+ if (label.trim() === "") {
1079
+ throw clientValidationError("label: must not be empty");
1080
+ }
1081
+ }
1082
+ requireBooleanIfPresent(oneTimeUse, "oneTimeUse");
1083
+ requireMaxSessionsInRange(maxSessions, "maxSessions");
1084
+ const body = {};
1085
+ const expiresIn = serializeApiDuration(validFor, "validFor", MIN_PORTAL_VALID_FOR_SECONDS);
1086
+ if (expiresIn !== undefined)
1087
+ body.expires_in = expiresIn;
1088
+ if (label !== undefined)
1089
+ body.label = label;
1090
+ if (oneTimeUse !== undefined)
1091
+ body.one_time_use = oneTimeUse;
1092
+ // Explicit 0 means unlimited and must survive body construction.
1093
+ if (maxSessions !== undefined)
1094
+ body.max_sessions = maxSessions;
1095
+ const wireSessionDuration = serializeApiDuration(sessionDuration, "sessionDuration", MIN_SESSION_DURATION_SECONDS);
1096
+ if (wireSessionDuration !== undefined)
1097
+ body.session_duration = wireSessionDuration;
1098
+ return Object.keys(body).length > 0 ? body : undefined;
1099
+ }
890
1100
  /** qURL API client. */
891
1101
  export class QURLClient {
892
1102
  baseUrl;
@@ -1144,8 +1354,214 @@ export class QURLClient {
1144
1354
  }
1145
1355
  } while (cursor);
1146
1356
  }
1357
+ // --- Portal API (mirrors qurl-go: ProtectURL → CreatePortal → EnterPortal) ---
1358
+ /**
1359
+ * Protect a private URL — create or reuse its LayerV resource.
1360
+ *
1361
+ * The first step of the portal flow (qurl-go: `Client.ProtectURL`).
1362
+ * Protect the URL once, then mint short-lived links for it with
1363
+ * {@link ProtectedResource.createPortal}:
1364
+ *
1365
+ * ```ts
1366
+ * const resource = await client.protectUrl("https://internal.example.com/dashboard");
1367
+ * const portal = await resource.createPortal({ validFor: "5m" });
1368
+ * share(portal.link);
1369
+ * ```
1370
+ *
1371
+ * Protecting the same target URL again returns the existing resource.
1372
+ * Malformed URLs and URLs with embedded credentials (userinfo) are
1373
+ * rejected before any request, mirroring qurl-go. REST-shaped equivalent:
1374
+ * {@link createResource}.
1375
+ */
1376
+ async protectUrl(targetUrl, opts = {}, requestOptions) {
1377
+ requirePortalTargetUrl(targetUrl);
1378
+ requireObjectInput(opts, "protectUrl");
1379
+ requireNoUnknownFields(opts, PROTECT_URL_OPTION_KEYS, "protectUrl");
1380
+ // camelCase options → wire fields; null is treated as omitted, via the
1381
+ // SDK-wide normalizePatchFields convention.
1382
+ const normalized = normalizePatchFields(opts, PROTECT_URL_OPTION_KEYS);
1383
+ const body = { target_url: targetUrl };
1384
+ if (normalized.description !== undefined)
1385
+ body.description = normalized.description;
1386
+ if (normalized.tags !== undefined)
1387
+ body.tags = normalized.tags;
1388
+ if (normalized.customDomain !== undefined)
1389
+ body.custom_domain = normalized.customDomain;
1390
+ if (normalized.alias !== undefined)
1391
+ body.alias = normalized.alias;
1392
+ validateResourceWriteFields(body);
1393
+ const resource = await this.request("POST", "/v1/resources", body, requestOptions);
1394
+ if (typeof resource?.resource_id !== "string" || resource.resource_id.trim() === "") {
1395
+ throw unexpectedResponseError("protectUrl: response is missing resource_id");
1396
+ }
1397
+ return new ProtectedResource(this, resource.resource_id,
1398
+ // Some resource types redact target_url in responses; fall back to
1399
+ // the caller-supplied URL so the handle stays useful.
1400
+ resource.target_url || targetUrl, resource);
1401
+ }
1402
+ /**
1403
+ * Return a portal-minting handle for a stored resource id, without an API
1404
+ * call.
1405
+ *
1406
+ * Use when you persisted a LayerV resource id and want to mint more
1407
+ * portals for it (qurl-go: `Client.ResourceByID`):
1408
+ *
1409
+ * ```ts
1410
+ * const resource = client.resourceById("r_demo1234567");
1411
+ * const portal = await resource.createPortal({ validFor: "1h" });
1412
+ * ```
1413
+ *
1414
+ * The handle carries no server metadata; use {@link getResource} when you
1415
+ * need the full resource details.
1416
+ */
1417
+ resourceById(id) {
1418
+ requireNonEmptyId(id, "resourceById");
1419
+ return new ProtectedResource(this, id);
1420
+ }
1421
+ /**
1422
+ * Return the resource qURL Connector created for `connectorId`.
1423
+ *
1424
+ * Use when qURL Connector already protects the service — do not call
1425
+ * {@link protectUrl} again for the same service. The connector id is the
1426
+ * resource slug LayerV stores for that connector; the LayerV API performs
1427
+ * the slug lookup, and the SDK confirms the returned alias matches
1428
+ * `connectorId` before binding the handle (qurl-go:
1429
+ * `Client.ConnectorResource`).
1430
+ *
1431
+ * Throws {@link NotFoundError} (`status: 0`, `code: "resource_not_found"`)
1432
+ * when no resource exists for the connector id, {@link QURLError}
1433
+ * (`code: "ambiguous_resource"`) when the lookup returns more than one
1434
+ * resource, and {@link ValidationError} (`code: "unexpected_response"`)
1435
+ * when the returned resource's alias is missing or does not match.
1436
+ */
1437
+ async connectorResource(connectorId) {
1438
+ requireNonEmptyId(connectorId, "connectorResource", "connector id");
1439
+ const { resources } = await this.listResources({ slug: connectorId });
1440
+ if (resources.length === 0) {
1441
+ throw new NotFoundError({
1442
+ status: 0,
1443
+ code: ERROR_CODE_RESOURCE_NOT_FOUND,
1444
+ title: "Resource Not Found",
1445
+ detail: `connectorResource: no resource found for connector "${connectorId}"`,
1446
+ });
1447
+ }
1448
+ if (resources.length > 1) {
1449
+ throw new QURLError({
1450
+ status: 0,
1451
+ code: ERROR_CODE_AMBIGUOUS_RESOURCE,
1452
+ title: "Ambiguous Resource",
1453
+ detail: `connectorResource: connector "${connectorId}" returned ${resources.length} resources`,
1454
+ });
1455
+ }
1456
+ const resource = resources[0];
1457
+ if (resource.alias !== connectorId) {
1458
+ throw unexpectedResponseError(`connectorResource: connector "${connectorId}" returned a resource with a missing or different alias`);
1459
+ }
1460
+ if (typeof resource.resource_id !== "string" || resource.resource_id.trim() === "") {
1461
+ throw unexpectedResponseError("connectorResource: response is missing resource_id");
1462
+ }
1463
+ return new ProtectedResource(this, resource.resource_id, resource.target_url, resource);
1464
+ }
1465
+ /**
1466
+ * Mint a short-lived qURL link (a portal) for a protected resource.
1467
+ *
1468
+ * A portal is cryptographic, just-in-time permission for one actor to
1469
+ * reach one private resource. Recipients open `portal.link` directly and
1470
+ * need no LayerV credentials. Prefer short lifetimes such as
1471
+ * `{ validFor: "5m" }`. Accepts a {@link ProtectedResource} handle or a
1472
+ * resource id (`r_` prefix). REST-shaped equivalents: {@link mintLink} /
1473
+ * {@link createQurlForResource}.
1474
+ *
1475
+ * Duration options take a string (`"5m"`, `"24h"`; server-validated) or a
1476
+ * number of milliseconds with qurl-go's client-side guardrails: whole
1477
+ * seconds only, at least one minute for `validFor` and one second for
1478
+ * `sessionDuration`. `maxSessions: 0` is sent explicitly and means
1479
+ * unlimited; blank labels are rejected.
1480
+ */
1481
+ async createPortal(resource, opts = {}, requestOptions) {
1482
+ let resourceId;
1483
+ if (typeof resource === "string") {
1484
+ resourceId = resource;
1485
+ }
1486
+ else if (resource instanceof ProtectedResource) {
1487
+ if (!ProtectedResource.isBoundTo(resource, this)) {
1488
+ throw clientValidationError("createPortal: resource is bound to a different client");
1489
+ }
1490
+ resourceId = resource.id;
1491
+ }
1492
+ else {
1493
+ throw clientValidationError(`createPortal: resource must be a ProtectedResource handle or a resource id string (got ${describeShape(resource)})`);
1494
+ }
1495
+ requireNonEmptyId(resourceId, "createPortal", "resource id");
1496
+ const body = buildCreatePortalBody(opts, "createPortal");
1497
+ const data = await this.request("POST", `/v1/resources/${encodeURIComponent(resourceId)}/qurls`, body, requestOptions);
1498
+ return parsePortal(data, "createPortal");
1499
+ }
1500
+ /**
1501
+ * Protect `targetUrl` and mint a portal in one API call.
1502
+ *
1503
+ * Convenience for one-off scripts (qurl-go: `Client.CreatePortalForURL`).
1504
+ * The returned resource handle is reusable for minting more portals but
1505
+ * carries only the resource id and the caller-supplied target URL; use
1506
+ * {@link protectUrl} when you need the full server-populated resource
1507
+ * metadata. Portal options match {@link createPortal}; `targetUrl` gets
1508
+ * the same malformed-URL and embedded-credentials rejection as
1509
+ * {@link protectUrl}. REST-shaped equivalent: {@link create}.
1510
+ */
1511
+ async createPortalForUrl(targetUrl, opts = {}, requestOptions) {
1512
+ requirePortalTargetUrl(targetUrl);
1513
+ const body = {
1514
+ target_url: targetUrl,
1515
+ ...buildCreatePortalBody(opts, "createPortalForUrl"),
1516
+ };
1517
+ const data = await this.request("POST", "/v1/qurls", body, requestOptions);
1518
+ const portal = parsePortal(data, "createPortalForUrl");
1519
+ return {
1520
+ portal,
1521
+ resource: new ProtectedResource(this, portal.resourceId, targetUrl),
1522
+ };
1523
+ }
1524
+ /**
1525
+ * Open a qURL link programmatically and return the reachable resource.
1526
+ *
1527
+ * For services and agents that open received qURL links in code — human
1528
+ * recipients just open the link. Accepts a full platform link
1529
+ * (`https://qurl.link/#at_...`) or a bare access token. Triggers an NHP
1530
+ * knock that grants network access for the caller's IP and returns a
1531
+ * {@link ResourceHandle} with the reachable resource URL and access
1532
+ * lifetime.
1533
+ *
1534
+ * Unlike qurl-go's `EnterPortal` (which verifies links locally and needs
1535
+ * no LayerV credentials), this SDK opens links through the LayerV API:
1536
+ * the client needs an API key with the `qurl:resolve` scope. REST-shaped
1537
+ * equivalent: {@link resolve}.
1538
+ *
1539
+ * Throws {@link ValidationError} (`code: "client_validation"`) when no
1540
+ * access token can be extracted from `qurlLink`, and
1541
+ * (`code: "unexpected_response"`) when access is granted but the API
1542
+ * reports no resource URL — it fails closed rather than returning an
1543
+ * empty handle. Error messages never echo the link; qURL links are
1544
+ * credentials.
1545
+ */
1546
+ async enterPortal(qurlLink, requestOptions) {
1547
+ const token = extractAccessToken(qurlLink);
1548
+ const resolved = await this.resolve(token, requestOptions);
1549
+ if (typeof resolved?.target_url !== "string" || resolved.target_url === "") {
1550
+ throw unexpectedResponseError("enterPortal: access was granted but the API returned no resource URL");
1551
+ }
1552
+ return {
1553
+ resourceUrl: resolved.target_url,
1554
+ openSeconds: resolved.access_grant?.expires_in ?? 0,
1555
+ resourceId: resolved.resource_id,
1556
+ };
1557
+ }
1147
1558
  // --- Public API ---
1148
- /** Create a new qURL. */
1559
+ /**
1560
+ * Create a new qURL.
1561
+ *
1562
+ * Portal-flow equivalent: {@link createPortalForUrl} (same endpoint,
1563
+ * returns a {@link Portal} plus a reusable resource handle).
1564
+ */
1149
1565
  async create(input, options) {
1150
1566
  validateCreateInput(input);
1151
1567
  return this.request("POST", "/v1/qurls", input, options);
@@ -1442,6 +1858,8 @@ export class QURLClient {
1442
1858
  /**
1443
1859
  * Mint a new access link for a qURL.
1444
1860
  *
1861
+ * Portal-flow equivalent: {@link createPortal}.
1862
+ *
1445
1863
  * Accepts either a resource ID (`r_` prefix) or a qURL display ID (`q_`
1446
1864
  * prefix); the API resolves `q_` IDs to the parent resource automatically.
1447
1865
  *
@@ -1498,6 +1916,9 @@ export class QURLClient {
1498
1916
  /**
1499
1917
  * Resolve a qURL access token (headless).
1500
1918
  *
1919
+ * Portal-flow equivalent: {@link enterPortal} (also accepts full qURL
1920
+ * links and fails closed when no resource URL is returned).
1921
+ *
1501
1922
  * Triggers an NHP knock to grant network access for the caller's IP.
1502
1923
  * Requires `qurl:resolve` scope on the API key.
1503
1924
  *
@@ -1552,7 +1973,12 @@ export class QURLClient {
1552
1973
  validateListAllInput(input, RESOURCE_LIST_PARAM_KEYS, "listAllResources");
1553
1974
  return this.paginateAll("listAllResources", input, (pageInput) => this.listResources(pageInput), (page) => page.resources);
1554
1975
  }
1555
- /** Create a resource directly. */
1976
+ /**
1977
+ * Create a resource directly.
1978
+ *
1979
+ * Portal-flow equivalent for URL resources: {@link protectUrl} (returns
1980
+ * a portal-minting handle).
1981
+ */
1556
1982
  async createResource(input, options) {
1557
1983
  requireObjectInput(input, "createResource");
1558
1984
  requireNoUnknownFields(input, CREATE_RESOURCE_FIELD_KEYS, "createResource");
@@ -1587,7 +2013,11 @@ export class QURLClient {
1587
2013
  requireNonEmptyId(id, "deleteResource");
1588
2014
  await this.rawRequest("DELETE", `/v1/resources/${encodeURIComponent(id)}`);
1589
2015
  }
1590
- /** Mint a qURL against an existing resource. */
2016
+ /**
2017
+ * Mint a qURL against an existing resource.
2018
+ *
2019
+ * Portal-flow equivalent: {@link createPortal} (same endpoint).
2020
+ */
1591
2021
  async createQurlForResource(id, input, options) {
1592
2022
  requireNonEmptyId(id, "createQurlForResource");
1593
2023
  let normalized = input;
@@ -2207,4 +2637,50 @@ export class QURLClient {
2207
2637
  return new NetworkError(cause?.message ?? String(err), { cause });
2208
2638
  }
2209
2639
  }
2640
+ /**
2641
+ * A LayerV-protected resource, bound to the client that produced it.
2642
+ *
2643
+ * Obtained from {@link QURLClient.protectUrl},
2644
+ * {@link QURLClient.connectorResource}, or {@link QURLClient.resourceById} —
2645
+ * not constructed directly. Mint short-lived access links for the resource
2646
+ * with {@link createPortal} (qurl-go: `qurl.Resource`).
2647
+ */
2648
+ export class ProtectedResource {
2649
+ // A true private field (not a module-scope WeakMap registry, which
2650
+ // CONTRIBUTING.md's dual-build rule bars) so the binding stays on the
2651
+ // instance and out of Object.keys/JSON.stringify output.
2652
+ #client;
2653
+ /** The LayerV resource id (`r_` prefix). */
2654
+ id;
2655
+ /** The private URL protected by this resource, when known. */
2656
+ targetUrl;
2657
+ /**
2658
+ * Full server-populated {@link Resource} metadata when the handle came
2659
+ * from an API response; `undefined` for {@link QURLClient.resourceById}
2660
+ * handles and for the one-call {@link QURLClient.createPortalForUrl} path.
2661
+ */
2662
+ details;
2663
+ constructor(client, id, targetUrl, details) {
2664
+ this.#client = client;
2665
+ this.id = id;
2666
+ this.targetUrl = targetUrl;
2667
+ this.details = details;
2668
+ }
2669
+ /**
2670
+ * Whether `resource` was produced by `client`. A static because the
2671
+ * binding lives in a private field that only this class can read; used
2672
+ * by {@link QURLClient.createPortal} to refuse handles bound to a
2673
+ * different client.
2674
+ */
2675
+ static isBoundTo(resource, client) {
2676
+ return resource.#client === client;
2677
+ }
2678
+ /**
2679
+ * Mint a short-lived qURL link (a portal) for this resource. See
2680
+ * {@link QURLClient.createPortal} for the option reference.
2681
+ */
2682
+ async createPortal(opts = {}, requestOptions) {
2683
+ return this.#client.createPortal(this, opts, requestOptions);
2684
+ }
2685
+ }
2210
2686
  //# sourceMappingURL=client.js.map