@layervai/qurl 0.4.0 → 0.5.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 +126 -56
- package/dist/cjs/client.d.ts +5 -2
- package/dist/cjs/client.js +26 -4
- package/dist/cjs/node/deployment.d.ts +1 -1
- package/dist/cjs/node/deployment.js +2 -2
- package/dist/cjs/node/native-udp.d.ts +2 -4
- package/dist/cjs/node/native-udp.js +8 -8
- package/dist/cjs/node/portal-opener.d.ts +54 -23
- package/dist/cjs/node/portal-opener.js +643 -340
- package/dist/cjs/node.d.ts +2 -2
- package/dist/cjs/node.js +8 -1
- package/dist/cjs/types.d.ts +10 -2
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/client.d.ts +5 -2
- package/dist/esm/client.d.ts.map +1 -1
- package/dist/esm/client.js +26 -4
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/node/deployment.d.ts +1 -1
- package/dist/esm/node/deployment.d.ts.map +1 -1
- package/dist/esm/node/deployment.js +2 -2
- package/dist/esm/node/deployment.js.map +1 -1
- package/dist/esm/node/native-udp.d.ts +2 -4
- package/dist/esm/node/native-udp.d.ts.map +1 -1
- package/dist/esm/node/native-udp.js +5 -5
- package/dist/esm/node/native-udp.js.map +1 -1
- package/dist/esm/node/portal-opener.d.ts +54 -23
- package/dist/esm/node/portal-opener.d.ts.map +1 -1
- package/dist/esm/node/portal-opener.js +636 -340
- package/dist/esm/node/portal-opener.js.map +1 -1
- package/dist/esm/node.d.ts +2 -2
- package/dist/esm/node.d.ts.map +1 -1
- package/dist/esm/node.js +1 -1
- package/dist/esm/node.js.map +1 -1
- package/dist/esm/types.d.ts +10 -2
- 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 +4 -4
package/README.md
CHANGED
|
@@ -39,7 +39,9 @@ endpoint in front of the same service:
|
|
|
39
39
|
npm install @layervai/qurl
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
Requires Node.js
|
|
42
|
+
Requires Node.js 22.12+ and has **no runtime dependencies**. Both
|
|
43
|
+
`import { QURLClient } from '@layervai/qurl'` (ESM) and
|
|
44
|
+
`const { QURLClient } = require('@layervai/qurl')` (CJS) work.
|
|
43
45
|
|
|
44
46
|
## Quickstart
|
|
45
47
|
|
|
@@ -71,9 +73,17 @@ of calling `protectUrl`:
|
|
|
71
73
|
|
|
72
74
|
```typescript
|
|
73
75
|
const resource = await client.connectorResource('prod-dashboard');
|
|
74
|
-
const portal = await resource.createPortal({
|
|
76
|
+
const portal = await resource.createPortal({
|
|
77
|
+
validFor: '5m',
|
|
78
|
+
targetPath: '/api/detect/eib_example',
|
|
79
|
+
});
|
|
75
80
|
```
|
|
76
81
|
|
|
82
|
+
`targetPath` is available only when minting for an existing resource. The SDK
|
|
83
|
+
checks the non-empty 2048-byte boundary; the API remains authoritative for the
|
|
84
|
+
path grammar and the tunnel-only resource gate. `createPortalForUrl` rejects
|
|
85
|
+
this option because it creates a URL resource.
|
|
86
|
+
|
|
77
87
|
If you persist the resource id, future calls do not need to recreate the
|
|
78
88
|
handle (no API call is made until you mint):
|
|
79
89
|
|
|
@@ -102,11 +112,12 @@ const portal = await resource.createPortal({
|
|
|
102
112
|
label: 'Alice from Acme',
|
|
103
113
|
oneTimeUse: true,
|
|
104
114
|
maxSessions: 1,
|
|
115
|
+
targetPath: '/api/detect/eib_example',
|
|
105
116
|
});
|
|
106
117
|
```
|
|
107
118
|
|
|
108
119
|
The Node-only `@layervai/qurl/node` entry opens received qv2 links with native
|
|
109
|
-
NHP
|
|
120
|
+
NHP UDP. It has no relay or HTTP-resolve fallback. Connector assignment and
|
|
110
121
|
registration are separate producer operations and are not part of the portal
|
|
111
122
|
opener.
|
|
112
123
|
|
|
@@ -133,10 +144,15 @@ back, it throws instead of returning an empty handle.
|
|
|
133
144
|
Use the Node subpath when a service receives a qv2 link and must keep one NHP
|
|
134
145
|
session ready for a low-latency private request. Construct and start one opener
|
|
135
146
|
for that link during setup. `start()` sends the native UDP knock and schedules
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
expired.
|
|
147
|
+
background renewal before the admission expires. A transient renewal failure
|
|
148
|
+
retries after 500 ms, then 1 second, then at most every 2 seconds while the old
|
|
149
|
+
admission remains valid. `fetch()` never opens or renews a session, and it never
|
|
150
|
+
sleeps. It fails if the cached admission has expired.
|
|
151
|
+
|
|
152
|
+
Renewal waits at least 5 seconds after a successful open. If a server grants a
|
|
153
|
+
session that expires before that safe renewal point, the grant expires without
|
|
154
|
+
a background attempt and `start()` is the explicit recovery path. Production
|
|
155
|
+
admissions should be longer than this minimum gap.
|
|
140
156
|
|
|
141
157
|
```javascript
|
|
142
158
|
const { createPortalOpener } = require('@layervai/qurl/node');
|
|
@@ -144,7 +160,6 @@ const { createPortalOpener } = require('@layervai/qurl/node');
|
|
|
144
160
|
async function uploadPrivateObject(uploadBody) {
|
|
145
161
|
const opener = createPortalOpener({
|
|
146
162
|
qurl: process.env.PRIVATE_UPLOAD_QURL,
|
|
147
|
-
transport: 'native-only',
|
|
148
163
|
});
|
|
149
164
|
try {
|
|
150
165
|
await opener.start();
|
|
@@ -162,65 +177,120 @@ async function uploadPrivateObject(uploadBody) {
|
|
|
162
177
|
}
|
|
163
178
|
```
|
|
164
179
|
|
|
165
|
-
|
|
180
|
+
For a service whose ACK target is a base path, append only trusted raw path
|
|
181
|
+
segments with `fetchDescendant()`. The opener rejects empty, dot, and delimiter
|
|
182
|
+
segments and escapes each accepted segment before it sends the request:
|
|
183
|
+
|
|
184
|
+
```javascript
|
|
185
|
+
const response = await opener.fetchDescendant(
|
|
186
|
+
['eib_example'],
|
|
187
|
+
(authenticatedTarget) => ({ method: 'POST' }),
|
|
188
|
+
{ redirects: 'error' },
|
|
189
|
+
);
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
The request builder receives a copy of the selected authenticated target. The
|
|
166
193
|
opener ignores mutations to that copy and sends the initial request only to the
|
|
167
|
-
fixed ACK URL
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
194
|
+
fixed ACK URL or the validated descendant. A descendant call accepts raw path
|
|
195
|
+
segments, not a URL or path string. It preserves the ACK target query and
|
|
196
|
+
escapes each segment separately. It adds the private `qurl_vsession` cookie,
|
|
197
|
+
replaces a caller-supplied cookie with that name, and preserves other valid
|
|
198
|
+
cookies, including duplicate `Cookie` entries that Node joins with semicolons.
|
|
199
|
+
It does not accept a caller URL, and it rejects a caller-supplied `Host` header
|
|
200
|
+
so Fetch derives the authority from the pinned target. Both `fetch()` and
|
|
201
|
+
`fetchDescendant()` use only the cached NHP 1.1 admission. Use
|
|
202
|
+
`redirects: 'error'` to keep a descendant request at its initial target. The
|
|
203
|
+
default `follow` mode can move outside that path subtree, but only within the
|
|
204
|
+
authenticated origin. Also use `redirects: 'error'` for a request whose
|
|
205
|
+
signature binds its method, target, timestamp, or nonce. This mode closes a
|
|
206
|
+
redirect response and does not replay the request. Follow mode permits at most
|
|
207
|
+
10 requests, including the initial request, and uses the standard 301/302/303
|
|
208
|
+
method rewrite rules. As in Go, a 3xx response with no `Location` header, or a
|
|
209
|
+
307/308 response whose streaming body cannot be replayed, is returned to the
|
|
210
|
+
caller without a follow-up request. The caller then owns that response body and
|
|
211
|
+
must consume or cancel it. Local admission expiry is checked before the first
|
|
212
|
+
request; the protected service remains authoritative while a permitted redirect
|
|
213
|
+
chain is in progress.
|
|
214
|
+
|
|
215
|
+
When `fetch()` rejects before it sends a direct `RequestInit`, it releases a
|
|
216
|
+
caller-owned streaming body. It cancels a web `ReadableStream`, destroys a Node
|
|
217
|
+
`Readable`, or closes another iterator when that body supports the operation. A
|
|
218
|
+
request builder remains lazy and is not called until a grant is ready.
|
|
219
|
+
|
|
220
|
+
Each open has a whole-operation deadline that covers DNS and UDP.
|
|
221
|
+
`openTimeoutMs` sets this ceiling; the default is 15 seconds and the maximum is
|
|
222
|
+
60 seconds. Native DNS or address attempts can fail before this ceiling, so a
|
|
223
|
+
larger value does not extend their internal timeouts. A renewal also stops at
|
|
224
|
+
the old grant's expiry. Pass an abort signal to `start()` when lifecycle code
|
|
225
|
+
needs a shorter deadline. Concurrent `start()` calls share one attempt. The
|
|
226
|
+
first caller's signal owns that attempt, so its abort fails all waiters but does
|
|
227
|
+
not count as an open failure in health. A cold cancellation restores `new`; a
|
|
228
|
+
recovery cancellation preserves its prior `degraded` health. A later waiter's
|
|
229
|
+
abort stops only that wait.
|
|
187
230
|
Content requests use the caller's `RequestInit.signal`; `fetch()` does not add
|
|
188
|
-
an independent application-request deadline.
|
|
231
|
+
an independent application-request deadline. The signal stays active while the
|
|
232
|
+
returned response body is read. Set the opener's optional `fetch` when the
|
|
233
|
+
protected request must use a custom Fetch implementation. Native NHP opening
|
|
234
|
+
never uses this function. The custom function receives the `qurl_vsession`
|
|
235
|
+
bearer cookie and is inside the credential boundary. It must implement standard
|
|
236
|
+
Fetch signal behavior and honor `redirect: 'manual'`. If it needs a receiver,
|
|
237
|
+
pass it already bound; the SDK invokes it with the standard global Fetch
|
|
238
|
+
receiver. An unfollowed synthetic response can leave `Response.url` empty.
|
|
239
|
+
Otherwise, it must report
|
|
240
|
+
`Response.url` and `Response.redirected` accurately. It must not log or forward
|
|
241
|
+
protected request headers.
|
|
189
242
|
|
|
190
243
|
TypeScript consumers of `@layervai/qurl/node` must provide Node and Fetch API
|
|
191
244
|
declarations, for example current `@types/node`, or a configuration that includes
|
|
192
|
-
the `DOM` library for Fetch types.
|
|
245
|
+
the `DOM` library for Fetch types. The native opener requires Node 22.12 or later.
|
|
246
|
+
This floor ensures that composite request signals are reclaimed on long-lived
|
|
247
|
+
openers.
|
|
193
248
|
|
|
194
249
|
Native opening requires public deployment trust. Set `QURL_DEPLOYMENT` to one
|
|
195
250
|
strict JSON object or to a path that contains that object. The object must have
|
|
196
251
|
trusted P-256 issuer keys and native cell host, UDP port 443, and X25519 public
|
|
197
|
-
key entries. A configured path must be a regular file no larger than 1 MiB.
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
`
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
252
|
+
key entries. A configured path must be a regular file no larger than 1 MiB.
|
|
253
|
+
Construction does no I/O. `start()` resolves and validates the deployment, and
|
|
254
|
+
the first successful open pins that resolved trust for renewal and recovery.
|
|
255
|
+
The opener does not perform discovery. It fails before DNS if the verified link
|
|
256
|
+
names an unknown cell.
|
|
257
|
+
|
|
258
|
+
Use `opener.health()` for the local `new`, `starting`, `ready`, `degraded`, or
|
|
259
|
+
`closed` state. It reports absolute expiry, renewal, and last-success times, a
|
|
260
|
+
secret-free failure class, and the number of consecutive unsuccessful open
|
|
261
|
+
attempts. A renewal window can contain more than one such attempt. The
|
|
262
|
+
`starting` state is only for the first cold open; an explicit recovery from a
|
|
263
|
+
failure reports `degraded` until it succeeds. Date fields describe the most
|
|
264
|
+
recent successful grant, so use `ready` as the authority for current usability.
|
|
265
|
+
A transient renewal failure keeps the state `ready` while the prior admission
|
|
266
|
+
is usable. A changed authenticated target is not retried. It keeps the prior
|
|
267
|
+
admission until expiry. During that time, `start()` remains idempotent because
|
|
268
|
+
the old admission is still usable; `health().lastFailureClass` reports the
|
|
269
|
+
target change. After expiry, an explicit `start()` attempts recovery. If the
|
|
270
|
+
target change is intentional and permanent, create a new opener for the new
|
|
271
|
+
qURL; the old opener stays bound to its first authenticated target. Other
|
|
272
|
+
failures retry through the remaining admission window. This does not put an
|
|
273
|
+
open or sleep on the `fetch()` path.
|
|
274
|
+
|
|
275
|
+
Close cancels and waits for an active NHP open, then wipes the mutable
|
|
276
|
+
private-key, visitor-secret, and session-token buffers. Close also aborts a
|
|
277
|
+
protected request and its returned response body, and prevents another redirect
|
|
278
|
+
leg from starting. JavaScript can create immutable string copies during JSON
|
|
279
|
+
and HTTP processing, so the SDK cannot promise full memory zeroization before garbage
|
|
280
|
+
collection. The opener retains the immutable qURL string until close so it can
|
|
281
|
+
verify each renewal. Never log the qURL, ACK body, request cookies, or request
|
|
282
|
+
headers.
|
|
283
|
+
|
|
284
|
+
A cold `start()` failure leaves health at `degraded`. Catch `PortalBusyError`
|
|
285
|
+
for an authenticated COOKIE busy response and `PortalInvalidReplyError` for a
|
|
286
|
+
malformed authenticated reply. An open deadline throws `PortalOpenTimeoutError`.
|
|
287
|
+
The thrown typed error is the cold-start
|
|
288
|
+
diagnostic; health does not expose its message or secret values.
|
|
219
289
|
|
|
220
290
|
Local qv2 verification checks the signed bytes, trust, and clock-free claim
|
|
221
291
|
ordering. As in the Go SDK, it does not compare `nbf` or `exp` with the local
|
|
222
292
|
clock. The authenticated NHP open is the authoritative live validity check.
|
|
223
|
-
The opener also follows the Go NHP
|
|
293
|
+
The opener also follows the Go NHP COOKIE rule: an authenticated COOKIE is
|
|
224
294
|
a busy result and does not use ACK counter correlation or local clock skew
|
|
225
295
|
checks. Replaying it can only force another busy result; it cannot grant access.
|
|
226
296
|
|
|
@@ -264,8 +334,8 @@ console.log(`Access granted to ${access.target_url} for ${access.access_grant?.e
|
|
|
264
334
|
| Method | Description |
|
|
265
335
|
|--------|-------------|
|
|
266
336
|
| `protectUrl(targetUrl, opts?)` | Protect a private URL → portal-minting `ProtectedResource` handle |
|
|
267
|
-
| `resource.createPortal(opts?)` / `createPortal(resourceOrId, opts?)` | Mint a short-lived portal link
|
|
268
|
-
| `createPortalForUrl(targetUrl, opts?)` | Protect + mint
|
|
337
|
+
| `resource.createPortal(opts?)` / `createPortal(resourceOrId, opts?)` | Mint a short-lived portal link; existing resources can set `targetPath` |
|
|
338
|
+
| `createPortalForUrl(targetUrl, opts?)` | Protect + mint a URL resource; rejects `targetPath` |
|
|
269
339
|
| `connectorResource(connectorId)` | Handle for a service qURL Connector already protects |
|
|
270
340
|
| `resourceById(id)` | Handle from a stored resource id (no API call) |
|
|
271
341
|
| `enterPortal(linkOrToken)` | Open a qURL link programmatically → `ResourceHandle` |
|
package/dist/cjs/client.d.ts
CHANGED
|
@@ -123,7 +123,8 @@ export declare class QURLClient {
|
|
|
123
123
|
* number of milliseconds with qurl-go's client-side guardrails: whole
|
|
124
124
|
* seconds only, at least one minute for `validFor` and one second for
|
|
125
125
|
* `sessionDuration`. `maxSessions: 0` is sent explicitly and means
|
|
126
|
-
* unlimited; blank labels are rejected.
|
|
126
|
+
* unlimited; blank labels are rejected. `targetPath` is valid only for an
|
|
127
|
+
* existing resource. The API owns its full grammar and tunnel-only gate.
|
|
127
128
|
*/
|
|
128
129
|
createPortal(resource: ProtectedResource | string, opts?: CreatePortalOptions, requestOptions?: RequestOptions): Promise<Portal>;
|
|
129
130
|
/**
|
|
@@ -135,7 +136,9 @@ export declare class QURLClient {
|
|
|
135
136
|
* {@link protectUrl} when you need the full server-populated resource
|
|
136
137
|
* metadata. Portal options match {@link createPortal}; `targetUrl` gets
|
|
137
138
|
* the same malformed-URL and embedded-credentials rejection as
|
|
138
|
-
* {@link protectUrl}.
|
|
139
|
+
* {@link protectUrl}. `targetPath` is rejected because this call creates a
|
|
140
|
+
* URL resource instead of addressing an existing resource. REST-shaped
|
|
141
|
+
* equivalent: {@link create}.
|
|
139
142
|
*/
|
|
140
143
|
createPortalForUrl(targetUrl: string, opts?: CreatePortalOptions, requestOptions?: RequestOptions): Promise<{
|
|
141
144
|
portal: Portal;
|
package/dist/cjs/client.js
CHANGED
|
@@ -196,6 +196,7 @@ const CREATE_PORTAL_OPTION_KEYS = [
|
|
|
196
196
|
"oneTimeUse",
|
|
197
197
|
"maxSessions",
|
|
198
198
|
"sessionDuration",
|
|
199
|
+
"targetPath",
|
|
199
200
|
];
|
|
200
201
|
assertExhaustive(true);
|
|
201
202
|
const UPDATE_RESOURCE_QURL_FIELD_KEYS = [
|
|
@@ -741,6 +742,17 @@ function requireMaxLength(value, field, max) {
|
|
|
741
742
|
throw clientValidationError(`${field}: must be ${max} characters or fewer (got ${value.length})`);
|
|
742
743
|
}
|
|
743
744
|
}
|
|
745
|
+
function requireMaxUtf8Bytes(value, field, max) {
|
|
746
|
+
if (value === undefined)
|
|
747
|
+
return;
|
|
748
|
+
if (typeof value !== "string") {
|
|
749
|
+
throw clientValidationError(`${field}: must be a string (got ${value === null ? "null" : typeof value})`);
|
|
750
|
+
}
|
|
751
|
+
const bytes = TEXT_ENCODER.encode(value).byteLength;
|
|
752
|
+
if (bytes > max) {
|
|
753
|
+
throw clientValidationError(`${field}: must be ${max} UTF-8 bytes or fewer (got ${bytes})`);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
744
756
|
function requireNonEmptyIfPresent(value, field) {
|
|
745
757
|
// Reject `""` so uninitialized form state doesn't reach the wire.
|
|
746
758
|
if (value === "") {
|
|
@@ -1038,7 +1050,7 @@ function validateQurlTokenOptions(input) {
|
|
|
1038
1050
|
// gate server-authoritative (`invalid_target_path`); a client-side regex
|
|
1039
1051
|
// would create a drift surface. Keep max-before-empty consistent with label;
|
|
1040
1052
|
// empty strings pass the length check and are rejected by the next guard.
|
|
1041
|
-
|
|
1053
|
+
requireMaxUtf8Bytes(input.target_path, "target_path", MAX_TARGET_PATH);
|
|
1042
1054
|
requireNonEmptyIfPresent(input.target_path, "target_path");
|
|
1043
1055
|
requireBooleanIfPresent(input.one_time_use, "one_time_use");
|
|
1044
1056
|
requireMaxSessionsInRange(input.max_sessions);
|
|
@@ -1424,7 +1436,7 @@ function buildCreatePortalBody(opts, method) {
|
|
|
1424
1436
|
requireObjectInput(opts, method);
|
|
1425
1437
|
requireNoUnknownFields(opts, CREATE_PORTAL_OPTION_KEYS, method);
|
|
1426
1438
|
// null → omitted, via the SDK-wide normalizePatchFields convention.
|
|
1427
|
-
const { validFor, label, oneTimeUse, maxSessions, sessionDuration } = normalizePatchFields(opts, CREATE_PORTAL_OPTION_KEYS);
|
|
1439
|
+
const { validFor, label, oneTimeUse, maxSessions, sessionDuration, targetPath } = normalizePatchFields(opts, CREATE_PORTAL_OPTION_KEYS);
|
|
1428
1440
|
if (label !== undefined) {
|
|
1429
1441
|
requireMaxLength(label, "label", MAX_LABEL);
|
|
1430
1442
|
// qurl-go's WithLabel rejects blank labels; the REST-shaped mintLink
|
|
@@ -1435,6 +1447,11 @@ function buildCreatePortalBody(opts, method) {
|
|
|
1435
1447
|
}
|
|
1436
1448
|
requireBooleanIfPresent(oneTimeUse, "oneTimeUse");
|
|
1437
1449
|
requireMaxSessionsInRange(maxSessions, "maxSessions");
|
|
1450
|
+
requireMaxUtf8Bytes(targetPath, "targetPath", MAX_TARGET_PATH);
|
|
1451
|
+
requireNonEmptyIfPresent(targetPath, "targetPath");
|
|
1452
|
+
if (targetPath !== undefined && method === "createPortalForUrl") {
|
|
1453
|
+
throw clientValidationError("targetPath: requires an existing resource");
|
|
1454
|
+
}
|
|
1438
1455
|
const body = {};
|
|
1439
1456
|
const expiresIn = serializeApiDuration(validFor, "validFor", MIN_PORTAL_VALID_FOR_SECONDS);
|
|
1440
1457
|
if (expiresIn !== undefined)
|
|
@@ -1449,6 +1466,8 @@ function buildCreatePortalBody(opts, method) {
|
|
|
1449
1466
|
const wireSessionDuration = serializeApiDuration(sessionDuration, "sessionDuration", MIN_SESSION_DURATION_SECONDS);
|
|
1450
1467
|
if (wireSessionDuration !== undefined)
|
|
1451
1468
|
body.session_duration = wireSessionDuration;
|
|
1469
|
+
if (targetPath !== undefined)
|
|
1470
|
+
body.target_path = targetPath;
|
|
1452
1471
|
return Object.keys(body).length > 0 ? body : undefined;
|
|
1453
1472
|
}
|
|
1454
1473
|
/** qURL API client. */
|
|
@@ -1830,7 +1849,8 @@ class QURLClient {
|
|
|
1830
1849
|
* number of milliseconds with qurl-go's client-side guardrails: whole
|
|
1831
1850
|
* seconds only, at least one minute for `validFor` and one second for
|
|
1832
1851
|
* `sessionDuration`. `maxSessions: 0` is sent explicitly and means
|
|
1833
|
-
* unlimited; blank labels are rejected.
|
|
1852
|
+
* unlimited; blank labels are rejected. `targetPath` is valid only for an
|
|
1853
|
+
* existing resource. The API owns its full grammar and tunnel-only gate.
|
|
1834
1854
|
*/
|
|
1835
1855
|
async createPortal(resource, opts = {}, requestOptions) {
|
|
1836
1856
|
let resourceId;
|
|
@@ -1860,7 +1880,9 @@ class QURLClient {
|
|
|
1860
1880
|
* {@link protectUrl} when you need the full server-populated resource
|
|
1861
1881
|
* metadata. Portal options match {@link createPortal}; `targetUrl` gets
|
|
1862
1882
|
* the same malformed-URL and embedded-credentials rejection as
|
|
1863
|
-
* {@link protectUrl}.
|
|
1883
|
+
* {@link protectUrl}. `targetPath` is rejected because this call creates a
|
|
1884
|
+
* URL resource instead of addressing an existing resource. REST-shaped
|
|
1885
|
+
* equivalent: {@link create}.
|
|
1864
1886
|
*/
|
|
1865
1887
|
async createPortalForUrl(targetUrl, opts = {}, requestOptions) {
|
|
1866
1888
|
requirePortalTargetUrl(targetUrl);
|
|
@@ -32,7 +32,7 @@ interface DeploymentFileRuntime {
|
|
|
32
32
|
readonly read: (descriptor: number, buffer: Buffer, offset: number, length: number, position: number) => number;
|
|
33
33
|
readonly close: (descriptor: number) => void;
|
|
34
34
|
}
|
|
35
|
-
/** Load and validate deployment trust
|
|
35
|
+
/** Load and validate deployment trust before an opener can do network I/O. */
|
|
36
36
|
export declare function loadPortalDeployment(explicit?: PortalDeployment): ValidatedDeployment;
|
|
37
37
|
declare function readBoundedDeploymentFile(path: string, runtime?: DeploymentFileRuntime): Buffer;
|
|
38
38
|
export declare function fingerprintKey(key: Uint8Array): string;
|
|
@@ -17,7 +17,7 @@ const defaultDeploymentFileRuntime = {
|
|
|
17
17
|
read: node_fs_1.readSync,
|
|
18
18
|
close: node_fs_1.closeSync,
|
|
19
19
|
};
|
|
20
|
-
/** Load and validate deployment trust
|
|
20
|
+
/** Load and validate deployment trust before an opener can do network I/O. */
|
|
21
21
|
function loadPortalDeployment(explicit) {
|
|
22
22
|
if (explicit !== undefined)
|
|
23
23
|
return validateDeployment(explicit);
|
|
@@ -32,7 +32,7 @@ function loadPortalDeployment(explicit) {
|
|
|
32
32
|
return validateDeploymentValue((0, strict_json_js_1.parseStrictJson)(raw, MAX_DEPLOYMENT_BYTES));
|
|
33
33
|
}
|
|
34
34
|
function readBoundedDeploymentFile(path, runtime = defaultDeploymentFileRuntime) {
|
|
35
|
-
// O_NONBLOCK prevents a FIFO or device path from hanging
|
|
35
|
+
// O_NONBLOCK prevents a FIFO or device path from hanging startup before
|
|
36
36
|
// fstat can enforce the regular-file contract. It has no effect on files.
|
|
37
37
|
const descriptor = runtime.open(path, node_fs_1.constants.O_RDONLY | node_fs_1.constants.O_NONBLOCK);
|
|
38
38
|
try {
|
|
@@ -2,13 +2,11 @@ import { lookup } from "node:dns/promises";
|
|
|
2
2
|
import { type Socket } from "node:dgram";
|
|
3
3
|
import { buildNHPMessage, decryptNHPReply, type NHPMessage } from "./nhp-wire.js";
|
|
4
4
|
import type { ValidatedCell } from "./deployment.js";
|
|
5
|
-
export declare const NATIVE_DEFAULT_TIMEOUT_MS = 3000;
|
|
6
|
-
export declare const NATIVE_DEFAULT_MAX_ADDRESSES = 3;
|
|
7
5
|
export interface NativeExchangeOptions {
|
|
8
6
|
readonly signal?: AbortSignal;
|
|
9
|
-
/**
|
|
7
|
+
/** Internal per-address timeout. PortalOpener does not expose this setting. */
|
|
10
8
|
readonly timeoutMs?: number;
|
|
11
|
-
/**
|
|
9
|
+
/** Internal serial-address cap. PortalOpener does not expose this setting. */
|
|
12
10
|
readonly maxAddresses?: number;
|
|
13
11
|
}
|
|
14
12
|
interface NativeKnockRuntime {
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.nativeUdpTesting =
|
|
3
|
+
exports.nativeUdpTesting = void 0;
|
|
4
4
|
exports.nativeKnock = nativeKnock;
|
|
5
5
|
const promises_1 = require("node:dns/promises");
|
|
6
6
|
const node_dgram_1 = require("node:dgram");
|
|
7
7
|
const node_net_1 = require("node:net");
|
|
8
8
|
const nhp_wire_js_1 = require("./nhp-wire.js");
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
// These are internal per-address exchange bounds. PortalOpener applies its own
|
|
10
|
+
// whole-open deadline and does not expose per-address tuning.
|
|
11
|
+
const NATIVE_DEFAULT_TIMEOUT_MS = 3_000;
|
|
12
|
+
const NATIVE_DEFAULT_MAX_ADDRESSES = 3;
|
|
13
13
|
const ipv4Denied = new node_net_1.BlockList();
|
|
14
14
|
// Keep this set in lockstep with qurl-go's nonRoutablePrefixes plus the
|
|
15
15
|
// IsPrivate/IsLoopback/IsLinkLocal/IsMulticast predicates it applies first.
|
|
@@ -107,13 +107,13 @@ async function nativeKnockWithRuntime(cell, devicePrivateKey, body, options, run
|
|
|
107
107
|
body,
|
|
108
108
|
});
|
|
109
109
|
try {
|
|
110
|
-
const addresses = await runtime.resolveAddresses(cell.host, options.maxAddresses ??
|
|
110
|
+
const addresses = await runtime.resolveAddresses(cell.host, options.maxAddresses ?? NATIVE_DEFAULT_MAX_ADDRESSES, options.signal);
|
|
111
111
|
let lastError;
|
|
112
112
|
for (const address of addresses) {
|
|
113
113
|
try {
|
|
114
|
-
const packet = await runtime.exchange(address.address, address.family, cell.port, built.packet, options.timeoutMs ??
|
|
114
|
+
const packet = await runtime.exchange(address.address, address.family, cell.port, built.packet, options.timeoutMs ?? NATIVE_DEFAULT_TIMEOUT_MS, options.signal);
|
|
115
115
|
const reply = runtime.decryptReply(devicePrivateKey, cell.serverPublicKey, packet);
|
|
116
|
-
// NHP
|
|
116
|
+
// NHP COOKIE is a stateless busy response. Go intentionally does
|
|
117
117
|
// not bind its counter to the request; only an ACK must match it.
|
|
118
118
|
if (reply.type === nhp_wire_js_1.NHP_TYPE_COOKIE)
|
|
119
119
|
return reply;
|
|
@@ -3,43 +3,53 @@ import { type PortalDeployment, type ValidatedCell, type ValidatedDeployment } f
|
|
|
3
3
|
import { type NativeExchangeOptions } from "./native-udp.js";
|
|
4
4
|
import { type VerifiedQv2Link } from "./qv2.js";
|
|
5
5
|
export interface CreatePortalOpenerOptions {
|
|
6
|
-
/** Full qv2t1 qURL. The opener verifies
|
|
6
|
+
/** Full qv2t1 qURL. The opener re-verifies it before each native open. */
|
|
7
7
|
readonly qurl: string;
|
|
8
|
-
/**
|
|
9
|
-
readonly transport: "native-only";
|
|
10
|
-
/** Public deployment trust. Omit to load QURL_DEPLOYMENT once at construction. */
|
|
8
|
+
/** Public deployment trust. Omit to load QURL_DEPLOYMENT on the first start. */
|
|
11
9
|
readonly deployment?: PortalDeployment;
|
|
12
|
-
/**
|
|
13
|
-
readonly
|
|
14
|
-
/**
|
|
15
|
-
readonly
|
|
10
|
+
/** Whole-operation deadline for each NHP open. The default is 15 seconds. */
|
|
11
|
+
readonly openTimeoutMs?: number;
|
|
12
|
+
/** Protected-content fetch implementation. Native NHP opening never uses it. */
|
|
13
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
16
14
|
}
|
|
17
15
|
export interface PortalStartOptions {
|
|
18
16
|
readonly signal?: AbortSignal;
|
|
19
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* Build one request for the exact target selected by `fetch` or
|
|
20
|
+
* `fetchDescendant`. The URL is a disposable copy; the opener pins the final
|
|
21
|
+
* request URL and Host to its SDK-owned target after this function returns.
|
|
22
|
+
*/
|
|
20
23
|
export type PortalRequestBuilder = (authenticatedTarget: URL) => RequestInit;
|
|
21
24
|
export interface PortalFetchOptions {
|
|
22
25
|
/** Use `error` for signed requests whose method, target, timestamp, and nonce must not be replayed. */
|
|
23
26
|
readonly redirects?: "follow" | "error";
|
|
24
27
|
}
|
|
25
|
-
export type
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
export
|
|
29
|
-
readonly state:
|
|
30
|
-
|
|
31
|
-
readonly
|
|
32
|
-
readonly
|
|
33
|
-
readonly
|
|
34
|
-
readonly
|
|
35
|
-
|
|
28
|
+
export type PortalOpenerState = "new" | "starting" | "ready" | "degraded" | "closed";
|
|
29
|
+
export type PortalOpenerFailureClass = "" | "open_failed" | "target_changed";
|
|
30
|
+
/** Secret-free, nonblocking lifecycle snapshot. */
|
|
31
|
+
export interface PortalOpenerHealth {
|
|
32
|
+
readonly state: PortalOpenerState;
|
|
33
|
+
readonly ready: boolean;
|
|
34
|
+
readonly expiresAt?: Date;
|
|
35
|
+
readonly renewAt?: Date;
|
|
36
|
+
readonly lastOpenSucceededAt?: Date;
|
|
37
|
+
readonly lastFailureClass: PortalOpenerFailureClass;
|
|
38
|
+
readonly consecutiveFailures: number;
|
|
39
|
+
}
|
|
36
40
|
export interface PortalOpener {
|
|
37
41
|
/** Open now and schedule renewal before the admission expires. */
|
|
38
42
|
start(options?: PortalStartOptions): Promise<void>;
|
|
39
43
|
/** Start a fetch at the exact ACK URL. Same-origin redirects follow only when allowed. */
|
|
40
44
|
fetch(init?: RequestInit | PortalRequestBuilder, options?: PortalFetchOptions): Promise<Response>;
|
|
45
|
+
/**
|
|
46
|
+
* Start a fetch below the ACK URL. Each caller value is one raw path segment;
|
|
47
|
+
* the opener validates and escapes each segment before use. The ACK target's
|
|
48
|
+
* query is preserved.
|
|
49
|
+
*/
|
|
50
|
+
fetchDescendant(pathSegments: readonly string[], init?: RequestInit | PortalRequestBuilder, options?: PortalFetchOptions): Promise<Response>;
|
|
41
51
|
/** Read local session state. This method does no I/O and exposes no capability. */
|
|
42
|
-
health():
|
|
52
|
+
health(): PortalOpenerHealth;
|
|
43
53
|
/** Stop renewal and wipe mutable private-key, visitor-secret, and token buffers. */
|
|
44
54
|
close(): Promise<void>;
|
|
45
55
|
}
|
|
@@ -56,6 +66,27 @@ export declare class PortalDenyError extends Error {
|
|
|
56
66
|
export declare class PortalStateError extends Error {
|
|
57
67
|
constructor(message: string, options?: ErrorOptions);
|
|
58
68
|
}
|
|
69
|
+
export declare class PortalOpenerNotStartedError extends PortalStateError {
|
|
70
|
+
constructor();
|
|
71
|
+
}
|
|
72
|
+
export declare class PortalOpenerNotReadyError extends PortalStateError {
|
|
73
|
+
constructor(options?: ErrorOptions);
|
|
74
|
+
}
|
|
75
|
+
export declare class PortalOpenerClosedError extends PortalStateError {
|
|
76
|
+
constructor(options?: ErrorOptions);
|
|
77
|
+
}
|
|
78
|
+
export declare class PortalOpenTimeoutError extends PortalStateError {
|
|
79
|
+
constructor();
|
|
80
|
+
}
|
|
81
|
+
export declare class PortalTargetChangedError extends PortalStateError {
|
|
82
|
+
constructor();
|
|
83
|
+
}
|
|
84
|
+
export declare class PortalRedirectError extends PortalStateError {
|
|
85
|
+
constructor(message?: string, options?: ErrorOptions);
|
|
86
|
+
}
|
|
87
|
+
export declare class PortalTooManyRedirectsError extends PortalStateError {
|
|
88
|
+
constructor();
|
|
89
|
+
}
|
|
59
90
|
export declare class PortalBusyError extends PortalStateError {
|
|
60
91
|
constructor();
|
|
61
92
|
}
|
|
@@ -66,7 +97,7 @@ interface PortalRuntime {
|
|
|
66
97
|
readonly knock: (cell: ValidatedCell, key: Uint8Array, body: Uint8Array, options: NativeExchangeOptions) => Promise<NHPMessage>;
|
|
67
98
|
readonly fetch: typeof globalThis.fetch;
|
|
68
99
|
readonly nowNanos: () => bigint;
|
|
69
|
-
readonly
|
|
100
|
+
readonly nowEpochMs: () => number;
|
|
70
101
|
readonly setTimer: (callback: () => void, delayMs: number) => NodeJS.Timeout;
|
|
71
102
|
readonly clearTimer: (timer: NodeJS.Timeout) => void;
|
|
72
103
|
readonly setDeadlineTimer: (callback: () => void, delayMs: number) => NodeJS.Timeout;
|
|
@@ -74,10 +105,10 @@ interface PortalRuntime {
|
|
|
74
105
|
}
|
|
75
106
|
export declare function createPortalOpener(options: CreatePortalOpenerOptions): PortalOpener;
|
|
76
107
|
export declare function createPortalOpenerWithRuntime(options: CreatePortalOpenerOptions, runtime: PortalRuntime): PortalOpener;
|
|
77
|
-
declare function
|
|
108
|
+
declare function validatedCellForLink(link: VerifiedQv2Link, deployment: ValidatedDeployment): ValidatedCell;
|
|
78
109
|
declare function validateSessionToken(value: string, decode?: (part: string) => Buffer): void;
|
|
79
110
|
export declare const portalOpenerTesting: {
|
|
80
|
-
|
|
111
|
+
validatedCellForLink: typeof validatedCellForLink;
|
|
81
112
|
validateSessionToken: typeof validateSessionToken;
|
|
82
113
|
};
|
|
83
114
|
export {};
|