@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 +48 -5
- package/dist/cjs/client.d.ts +41 -18
- package/dist/cjs/client.js +312 -50
- package/dist/cjs/errors.d.ts +24 -3
- package/dist/cjs/errors.js +34 -4
- package/dist/cjs/index.d.ts +3 -2
- package/dist/cjs/index.js +5 -1
- package/dist/cjs/types.d.ts +8 -0
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/client.d.ts +41 -18
- package/dist/esm/client.d.ts.map +1 -1
- package/dist/esm/client.js +311 -50
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/errors.d.ts +24 -3
- package/dist/esm/errors.d.ts.map +1 -1
- package/dist/esm/errors.js +32 -3
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/index.d.ts +3 -2
- 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 +8 -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/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
|
|
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.
|
|
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
|
-
| `
|
|
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
|
-
`
|
|
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
|
|
package/dist/cjs/client.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
102
|
+
* Revoke a qURL Connector resource by immutable public resource ID.
|
|
97
103
|
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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 {};
|
package/dist/cjs/client.js
CHANGED
|
@@ -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
|
|
1431
|
-
*
|
|
1432
|
-
*
|
|
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
|
|
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
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
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:
|
|
2035
|
+
detail: "getConnectorResourceBySlug: no active resource exists for the requested slug",
|
|
1819
2036
|
});
|
|
1820
2037
|
}
|
|
1821
|
-
if (
|
|
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: `
|
|
2043
|
+
detail: `getConnectorResourceBySlug: expected one active resource, got ${data.length}`,
|
|
1827
2044
|
});
|
|
1828
2045
|
}
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
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
|
-
|
|
1834
|
-
|
|
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
|
|
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 =
|
|
2801
|
-
?
|
|
2802
|
-
:
|
|
2803
|
-
?
|
|
2804
|
-
:
|
|
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
|
|
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;
|