@layervai/qurl 0.2.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;
@@ -124,6 +124,28 @@ const CREATE_QURL_FOR_RESOURCE_FIELD_KEYS = [
124
124
  "session_duration",
125
125
  "label",
126
126
  "access_policy",
127
+ "target_path",
128
+ ];
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",
127
149
  ];
128
150
  assertExhaustive(true);
129
151
  const UPDATE_RESOURCE_QURL_FIELD_KEYS = [
@@ -347,6 +369,7 @@ const MAX_MAX_SESSIONS = 1000;
347
369
  const MAX_API_KEY_NAME = 100;
348
370
  const MAX_ALIAS = 64;
349
371
  const MAX_SLUG = 64;
372
+ const MAX_TARGET_PATH = 2048;
350
373
  const MAX_TAGS = 10;
351
374
  const MAX_TAG_LENGTH = 50;
352
375
  const MAX_AUTO_PAGINATION_PAGES = 10_000;
@@ -374,11 +397,11 @@ function requireNonEmptyIfPresent(value, field) {
374
397
  throw clientValidationError(`${field}: must not be an empty string`);
375
398
  }
376
399
  }
377
- function requireMaxSessionsInRange(value) {
400
+ function requireMaxSessionsInRange(value, field = "max_sessions") {
378
401
  if (value === undefined)
379
402
  return;
380
403
  if (!Number.isInteger(value) || value < 0 || value > MAX_MAX_SESSIONS) {
381
- 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})`);
382
405
  }
383
406
  }
384
407
  function requireBooleanIfPresent(value, field) {
@@ -660,6 +683,13 @@ function validateQurlTokenOptions(input) {
660
683
  requireNonEmptyIfPresent(input.label, "label");
661
684
  requireNonEmptyIfPresent(input.expires_in, "expires_in");
662
685
  requireNonEmptyIfPresent(input.session_duration, "session_duration");
686
+ // Mirror the service-documented size cap, but keep host-safety grammar
687
+ // (single leading slash, no scheme/host/backslash/etc.) and the tunnel-only
688
+ // gate server-authoritative (`invalid_target_path`); a client-side regex
689
+ // would create a drift surface. Keep max-before-empty consistent with label;
690
+ // empty strings pass the length check and are rejected by the next guard.
691
+ requireMaxLength(input.target_path, "target_path", MAX_TARGET_PATH);
692
+ requireNonEmptyIfPresent(input.target_path, "target_path");
663
693
  requireBooleanIfPresent(input.one_time_use, "one_time_use");
664
694
  requireMaxSessionsInRange(input.max_sessions);
665
695
  requireValidAccessPolicy(input.access_policy);
@@ -878,6 +908,195 @@ function batchItemResultValidationReason(entry) {
878
908
  }
879
909
  return "missing/invalid 'success' discriminant (expected boolean)";
880
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
+ }
881
1100
  /** qURL API client. */
882
1101
  export class QURLClient {
883
1102
  baseUrl;
@@ -1135,8 +1354,214 @@ export class QURLClient {
1135
1354
  }
1136
1355
  } while (cursor);
1137
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
+ }
1138
1558
  // --- Public API ---
1139
- /** 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
+ */
1140
1565
  async create(input, options) {
1141
1566
  validateCreateInput(input);
1142
1567
  return this.request("POST", "/v1/qurls", input, options);
@@ -1363,8 +1788,8 @@ export class QURLClient {
1363
1788
  }
1364
1789
  if (!id.startsWith(RESOURCE_ID_PREFIX)) {
1365
1790
  // Wrong-prefix branch: the input is long enough to plausibly be
1366
- // an ID but has the wrong prefix (e.g. `q_3a7f2c8e91b`,
1367
- // `at_xyz…`). Echo only the 2-char prefix — never the raw ID
1791
+ // an ID but has the wrong prefix (e.g. `q_3a7f2c8e91b`).
1792
+ // Echo only the 2-char prefix — never the raw ID
1368
1793
  // — so observability pipelines don't end up with caller-supplied
1369
1794
  // identifiers in error logs.
1370
1795
  const observedPrefix = id.slice(0, RESOURCE_ID_PREFIX.length);
@@ -1433,6 +1858,8 @@ export class QURLClient {
1433
1858
  /**
1434
1859
  * Mint a new access link for a qURL.
1435
1860
  *
1861
+ * Portal-flow equivalent: {@link createPortal}.
1862
+ *
1436
1863
  * Accepts either a resource ID (`r_` prefix) or a qURL display ID (`q_`
1437
1864
  * prefix); the API resolves `q_` IDs to the parent resource automatically.
1438
1865
  *
@@ -1489,6 +1916,9 @@ export class QURLClient {
1489
1916
  /**
1490
1917
  * Resolve a qURL access token (headless).
1491
1918
  *
1919
+ * Portal-flow equivalent: {@link enterPortal} (also accepts full qURL
1920
+ * links and fails closed when no resource URL is returned).
1921
+ *
1492
1922
  * Triggers an NHP knock to grant network access for the caller's IP.
1493
1923
  * Requires `qurl:resolve` scope on the API key.
1494
1924
  *
@@ -1543,7 +1973,12 @@ export class QURLClient {
1543
1973
  validateListAllInput(input, RESOURCE_LIST_PARAM_KEYS, "listAllResources");
1544
1974
  return this.paginateAll("listAllResources", input, (pageInput) => this.listResources(pageInput), (page) => page.resources);
1545
1975
  }
1546
- /** 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
+ */
1547
1982
  async createResource(input, options) {
1548
1983
  requireObjectInput(input, "createResource");
1549
1984
  requireNoUnknownFields(input, CREATE_RESOURCE_FIELD_KEYS, "createResource");
@@ -1578,7 +2013,11 @@ export class QURLClient {
1578
2013
  requireNonEmptyId(id, "deleteResource");
1579
2014
  await this.rawRequest("DELETE", `/v1/resources/${encodeURIComponent(id)}`);
1580
2015
  }
1581
- /** 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
+ */
1582
2021
  async createQurlForResource(id, input, options) {
1583
2022
  requireNonEmptyId(id, "createQurlForResource");
1584
2023
  let normalized = input;
@@ -2198,4 +2637,50 @@ export class QURLClient {
2198
2637
  return new NetworkError(cause?.message ?? String(err), { cause });
2199
2638
  }
2200
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
+ }
2201
2686
  //# sourceMappingURL=client.js.map