@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.
- package/README.md +153 -16
- package/dist/cjs/client.d.ts +164 -4
- package/dist/cjs/client.js +483 -6
- package/dist/cjs/errors.d.ts +4 -0
- package/dist/cjs/errors.js +5 -1
- package/dist/cjs/index.d.ts +3 -3
- package/dist/cjs/index.js +4 -1
- package/dist/cjs/types.d.ts +83 -0
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/client.d.ts +164 -4
- package/dist/esm/client.d.ts.map +1 -1
- package/dist/esm/client.js +482 -6
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/errors.d.ts +4 -0
- package/dist/esm/errors.d.ts.map +1 -1
- package/dist/esm/errors.js +4 -0
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/index.d.ts +3 -3
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +2 -2
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/types.d.ts +83 -0
- package/dist/esm/types.d.ts.map +1 -1
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +1 -1
package/dist/esm/client.js
CHANGED
|
@@ -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(
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|