@layervai/qurl 0.3.0 → 0.4.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 +340 -23
- package/dist/cjs/client.d.ts +180 -13
- package/dist/cjs/client.js +1126 -96
- package/dist/cjs/errors.d.ts +15 -11
- package/dist/cjs/errors.js +30 -21
- package/dist/cjs/index.d.ts +3 -3
- package/dist/cjs/index.js +4 -1
- package/dist/cjs/internal.d.ts +4 -0
- package/dist/cjs/internal.js +16 -0
- package/dist/cjs/node/deployment.d.ts +44 -0
- package/dist/cjs/node/deployment.js +167 -0
- package/dist/cjs/node/native-udp.d.ts +37 -0
- package/dist/cjs/node/native-udp.js +294 -0
- package/dist/cjs/node/nhp-wire.d.ts +35 -0
- package/dist/cjs/node/nhp-wire.js +261 -0
- package/dist/cjs/node/portal-opener.d.ts +83 -0
- package/dist/cjs/node/portal-opener.js +767 -0
- package/dist/cjs/node/qv2.d.ts +38 -0
- package/dist/cjs/node/qv2.js +292 -0
- package/dist/cjs/node/strict-json.d.ts +12 -0
- package/dist/cjs/node/strict-json.js +174 -0
- package/dist/cjs/node.d.ts +3 -0
- package/dist/cjs/node.js +11 -0
- package/dist/cjs/types.d.ts +90 -18
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/client.d.ts +180 -13
- package/dist/esm/client.d.ts.map +1 -1
- package/dist/esm/client.js +1125 -96
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/errors.d.ts +15 -11
- package/dist/esm/errors.d.ts.map +1 -1
- package/dist/esm/errors.js +29 -20
- 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/internal.d.ts +5 -0
- package/dist/esm/internal.d.ts.map +1 -0
- package/dist/esm/internal.js +14 -0
- package/dist/esm/internal.js.map +1 -0
- package/dist/esm/node/deployment.d.ts +45 -0
- package/dist/esm/node/deployment.d.ts.map +1 -0
- package/dist/esm/node/deployment.js +163 -0
- package/dist/esm/node/deployment.js.map +1 -0
- package/dist/esm/node/native-udp.d.ts +38 -0
- package/dist/esm/node/native-udp.d.ts.map +1 -0
- package/dist/esm/node/native-udp.js +291 -0
- package/dist/esm/node/native-udp.js.map +1 -0
- package/dist/esm/node/nhp-wire.d.ts +36 -0
- package/dist/esm/node/nhp-wire.d.ts.map +1 -0
- package/dist/esm/node/nhp-wire.js +257 -0
- package/dist/esm/node/nhp-wire.js.map +1 -0
- package/dist/esm/node/portal-opener.d.ts +84 -0
- package/dist/esm/node/portal-opener.d.ts.map +1 -0
- package/dist/esm/node/portal-opener.js +757 -0
- package/dist/esm/node/portal-opener.js.map +1 -0
- package/dist/esm/node/qv2.d.ts +39 -0
- package/dist/esm/node/qv2.d.ts.map +1 -0
- package/dist/esm/node/qv2.js +286 -0
- package/dist/esm/node/qv2.js.map +1 -0
- package/dist/esm/node/strict-json.d.ts +13 -0
- package/dist/esm/node/strict-json.d.ts.map +1 -0
- package/dist/esm/node/strict-json.js +171 -0
- package/dist/esm/node/strict-json.js.map +1 -0
- package/dist/esm/node.d.ts +4 -0
- package/dist/esm/node.d.ts.map +1 -0
- package/dist/esm/node.js +2 -0
- package/dist/esm/node.js.map +1 -0
- package/dist/esm/types.d.ts +90 -18
- 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 +25 -6
package/README.md
CHANGED
|
@@ -4,13 +4,34 @@
|
|
|
4
4
|
[](https://github.com/layervai/qurl-typescript/actions/workflows/ci.yml)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
**Use the LayerV [qURL™ Platform](https://docs.layerv.ai) from TypeScript: protect a
|
|
8
|
+
private URL once, then mint short-lived portal links for it.**
|
|
8
9
|
|
|
9
10
|
> **Quantum URL (qURL)** · The internet has a hidden layer. This is how you enter.
|
|
10
11
|
|
|
12
|
+
Portal recipients do not need LayerV credentials, API keys, or SDK state. They
|
|
13
|
+
open the qURL link. Credentials are only for software that protects URLs or
|
|
14
|
+
creates portals.
|
|
15
|
+
|
|
11
16
|
## Why qURL?
|
|
12
17
|
|
|
13
|
-
|
|
18
|
+
Agents and services increasingly need to reach private MCP servers, APIs, and
|
|
19
|
+
internal tools. The issue is visibility: every standing public endpoint becomes
|
|
20
|
+
inventory for scanners, fingerprinting, credential attacks, and AI-assisted
|
|
21
|
+
probing before a legitimate user or agent ever arrives.
|
|
22
|
+
|
|
23
|
+
Opening an inbound port, running a VPN, shipping a bastion, publishing a
|
|
24
|
+
Cloudflare Tunnel or ngrok URL, or passing around a long-lived key all leave
|
|
25
|
+
something durable to find, scan, or steal. qURL flips that model: it is an
|
|
26
|
+
invisibility primitive for authenticated access, and the private resource is
|
|
27
|
+
not public inventory. A portal is **cryptographic, just-in-time permission for
|
|
28
|
+
one actor to reach one private resource** — not another externally visible
|
|
29
|
+
endpoint in front of the same service:
|
|
30
|
+
|
|
31
|
+
- **Time-limited** — portals expire after minutes, hours, or days
|
|
32
|
+
- **IP-scoped** — access is granted only to the requesting IP via NHP
|
|
33
|
+
- **Auditable** — every access is logged with who, when, and from where
|
|
34
|
+
- **Revocable** — kill access instantly if something goes wrong
|
|
14
35
|
|
|
15
36
|
## Installation
|
|
16
37
|
|
|
@@ -18,16 +39,200 @@ AI agents need to access protected resources — APIs, databases, internal tools
|
|
|
18
39
|
npm install @layervai/qurl
|
|
19
40
|
```
|
|
20
41
|
|
|
21
|
-
Requires Node.js
|
|
42
|
+
Requires Node.js 20+ and has **no runtime dependencies**. Both `import { QURLClient } from '@layervai/qurl'` (ESM) and `const { QURLClient } = require('@layervai/qurl')` (CJS) work.
|
|
22
43
|
|
|
23
|
-
##
|
|
44
|
+
## Quickstart
|
|
24
45
|
|
|
25
46
|
```typescript
|
|
26
47
|
import { QURLClient } from '@layervai/qurl';
|
|
27
48
|
|
|
28
49
|
const client = new QURLClient({ apiKey: 'YOUR_API_KEY' });
|
|
29
50
|
|
|
30
|
-
|
|
51
|
+
const resource = await client.protectUrl('https://internal.example.com/dashboard');
|
|
52
|
+
const portal = await resource.createPortal({ validFor: '5m' });
|
|
53
|
+
|
|
54
|
+
console.log(portal.link); // Share this link — recipients need no credentials
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
That is the core flow:
|
|
58
|
+
|
|
59
|
+
| Step | Call | What you provide |
|
|
60
|
+
| --- | --- | --- |
|
|
61
|
+
| Protect a private URL | `client.protectUrl` | The target URL you already know |
|
|
62
|
+
| Mint a short-lived access link | `resource.createPortal` | The returned resource handle |
|
|
63
|
+
|
|
64
|
+
`protectUrl` is idempotent for the same account and target URL: protecting the
|
|
65
|
+
same URL again returns the existing resource. `validFor` accepts a duration
|
|
66
|
+
string (`'5m'`, `'24h'`) or a number of milliseconds (whole seconds, at least
|
|
67
|
+
one minute); prefer short portal lifetimes.
|
|
68
|
+
|
|
69
|
+
If qURL Connector already protects the service, use the connector id instead
|
|
70
|
+
of calling `protectUrl`:
|
|
71
|
+
|
|
72
|
+
```typescript
|
|
73
|
+
const resource = await client.connectorResource('prod-dashboard');
|
|
74
|
+
const portal = await resource.createPortal({ validFor: '5m' });
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
If you persist the resource id, future calls do not need to recreate the
|
|
78
|
+
handle (no API call is made until you mint):
|
|
79
|
+
|
|
80
|
+
```typescript
|
|
81
|
+
const resource = client.resourceById('r_demo1234567');
|
|
82
|
+
const portal = await resource.createPortal({ validFor: '1h' });
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
For one-off scripts, `client.createPortalForUrl` combines the two API calls
|
|
86
|
+
and returns both the portal and a reusable resource handle. The handle carries
|
|
87
|
+
the resource id and target URL; use `protectUrl` when you need the full
|
|
88
|
+
server-populated resource metadata:
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
const { portal, resource } = await client.createPortalForUrl(
|
|
92
|
+
'https://internal.example.com/dashboard',
|
|
93
|
+
{ validFor: '5m' },
|
|
94
|
+
);
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Portal options mirror qurl-go:
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
const portal = await resource.createPortal({
|
|
101
|
+
validFor: 5 * 60 * 1000, // milliseconds work too
|
|
102
|
+
label: 'Alice from Acme',
|
|
103
|
+
oneTimeUse: true,
|
|
104
|
+
maxSessions: 1,
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The Node-only `@layervai/qurl/node` entry opens received qv2 links with native
|
|
109
|
+
NHP 1.1 UDP. It has no relay or HTTP-resolve fallback. Connector assignment and
|
|
110
|
+
registration are separate producer operations and are not part of the portal
|
|
111
|
+
opener.
|
|
112
|
+
|
|
113
|
+
## Opening Portals
|
|
114
|
+
|
|
115
|
+
Most recipients open qURL links directly and do not use this SDK at all. If
|
|
116
|
+
you are building a service or agent that opens received qURL links
|
|
117
|
+
programmatically, `enterPortal` accepts a full link or a bare access token,
|
|
118
|
+
grants network access for the caller's IP, and returns the reachable resource:
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
const handle = await client.enterPortal(link);
|
|
122
|
+
console.log(handle.resourceUrl); // The reachable resource location
|
|
123
|
+
console.log(handle.openSeconds); // How long access stays open
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Unlike qurl-go's offline `EnterPortal`, this SDK opens links through the
|
|
127
|
+
LayerV API: the client needs an API key with the `qurl:resolve` scope.
|
|
128
|
+
`enterPortal` fails closed — if access is granted but no resource URL comes
|
|
129
|
+
back, it throws instead of returning an empty handle.
|
|
130
|
+
|
|
131
|
+
### Proactive native Node opener
|
|
132
|
+
|
|
133
|
+
Use the Node subpath when a service receives a qv2 link and must keep one NHP
|
|
134
|
+
session ready for a low-latency private request. Construct and start one opener
|
|
135
|
+
for that link during setup. `start()` sends the native UDP knock and schedules
|
|
136
|
+
bounded background renewal before the admission expires. Failed renewal tries
|
|
137
|
+
are spread across the remaining valid grant window. `fetch()` never opens or
|
|
138
|
+
renews a session, and it never sleeps. It fails if the cached admission has
|
|
139
|
+
expired.
|
|
140
|
+
|
|
141
|
+
```javascript
|
|
142
|
+
const { createPortalOpener } = require('@layervai/qurl/node');
|
|
143
|
+
|
|
144
|
+
async function uploadPrivateObject(uploadBody) {
|
|
145
|
+
const opener = createPortalOpener({
|
|
146
|
+
qurl: process.env.PRIVATE_UPLOAD_QURL,
|
|
147
|
+
transport: 'native-only',
|
|
148
|
+
});
|
|
149
|
+
try {
|
|
150
|
+
await opener.start();
|
|
151
|
+
return await opener.fetch(
|
|
152
|
+
(authenticatedTarget) => ({
|
|
153
|
+
method: 'POST',
|
|
154
|
+
headers: signUploadForExactTarget(authenticatedTarget),
|
|
155
|
+
body: uploadBody,
|
|
156
|
+
}),
|
|
157
|
+
{ redirects: 'error' },
|
|
158
|
+
);
|
|
159
|
+
} finally {
|
|
160
|
+
await opener.close();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
The request builder receives a copy of the exact authenticated ACK target. The
|
|
166
|
+
opener ignores mutations to that copy and sends the initial request only to the
|
|
167
|
+
fixed ACK URL. It adds the private `qurl_vsession` cookie, replaces a
|
|
168
|
+
caller-supplied cookie with that name, and preserves other valid cookies,
|
|
169
|
+
including duplicate `Cookie` entries that Node joins with semicolons. It does
|
|
170
|
+
not accept a caller URL or path. Use `redirects: 'error'` for a request
|
|
171
|
+
whose signature binds its method, target, timestamp, or nonce. This mode closes
|
|
172
|
+
a redirect response and does not replay the request. The default `follow` mode
|
|
173
|
+
can then move within the authenticated origin. It permits at most 10 requests,
|
|
174
|
+
including the initial request, and uses the standard 301/302/303 method rewrite
|
|
175
|
+
rules. As in Go, a 3xx response with no `Location` header, or a 307/308 response
|
|
176
|
+
whose streaming body cannot be replayed, is returned to the caller without a
|
|
177
|
+
follow-up request. The caller then owns that response body and must consume or
|
|
178
|
+
cancel it. Local admission expiry is checked before the first request; the
|
|
179
|
+
protected service remains authoritative while a permitted redirect chain is in
|
|
180
|
+
progress.
|
|
181
|
+
|
|
182
|
+
`timeoutMs` bounds each resolved-address attempt, and `maxAddresses` caps the
|
|
183
|
+
serial address attempts. This matches qurl-go. Every open also has a whole-open
|
|
184
|
+
deadline that covers DNS and UDP: at least 15 seconds, or the configured
|
|
185
|
+
per-address budget times `maxAddresses + 1` when that is larger. Pass an abort
|
|
186
|
+
signal to `start()` when lifecycle code needs a shorter deadline.
|
|
187
|
+
Content requests use the caller's `RequestInit.signal`; `fetch()` does not add
|
|
188
|
+
an independent application-request deadline.
|
|
189
|
+
|
|
190
|
+
TypeScript consumers of `@layervai/qurl/node` must provide Node and Fetch API
|
|
191
|
+
declarations, for example current `@types/node`, or a configuration that includes
|
|
192
|
+
the `DOM` library for Fetch types.
|
|
193
|
+
|
|
194
|
+
Native opening requires public deployment trust. Set `QURL_DEPLOYMENT` to one
|
|
195
|
+
strict JSON object or to a path that contains that object. The object must have
|
|
196
|
+
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. The
|
|
198
|
+
SDK loads and validates this value once when it constructs the opener. It does
|
|
199
|
+
not perform discovery and it fails before DNS if the verified link names an
|
|
200
|
+
unknown cell.
|
|
201
|
+
|
|
202
|
+
Use `opener.health()` for the local `idle`, `starting`, `healthy`, `renewing`,
|
|
203
|
+
`degraded`, `expired`, or `closed` state. A degraded state includes a typed
|
|
204
|
+
renewal failure class but no raw session capability. After bounded background
|
|
205
|
+
retries stop, `start()` makes one explicit recovery attempt and refreshes the
|
|
206
|
+
health result. If that explicit attempt fails while the prior grant is still
|
|
207
|
+
usable, the opener re-arms its bounded background attempts after it returns the
|
|
208
|
+
error. This does not put an open or sleep on the `fetch()` path. Stop and await
|
|
209
|
+
in-flight content requests before you call `await opener.close()` during
|
|
210
|
+
shutdown. Close cancels and waits for an active NHP exchange, then wipes the
|
|
211
|
+
mutable private-key, visitor-secret, and session-token buffers. JavaScript can
|
|
212
|
+
create immutable string copies during JSON and HTTP processing, so the SDK
|
|
213
|
+
cannot promise full memory zeroization before garbage collection. Never log the
|
|
214
|
+
qURL, ACK body, request cookies, or request headers.
|
|
215
|
+
|
|
216
|
+
A cold `start()` failure leaves health at `idle`. Catch `PortalBusyError` for an
|
|
217
|
+
authenticated COOKIE busy response and `PortalInvalidReplyError` for a malformed
|
|
218
|
+
authenticated reply. The thrown typed error is the cold-start diagnostic.
|
|
219
|
+
|
|
220
|
+
Local qv2 verification checks the signed bytes, trust, and clock-free claim
|
|
221
|
+
ordering. As in the Go SDK, it does not compare `nbf` or `exp` with the local
|
|
222
|
+
clock. The authenticated NHP open is the authoritative live validity check.
|
|
223
|
+
The opener also follows the Go NHP 1.1 COOKIE rule: an authenticated COOKIE is
|
|
224
|
+
a busy result and does not use ACK counter correlation or local clock skew
|
|
225
|
+
checks. Replaying it can only force another busy result; it cannot grant access.
|
|
226
|
+
|
|
227
|
+
## REST-Shaped API (Compatibility)
|
|
228
|
+
|
|
229
|
+
The original REST-shaped methods remain fully supported and share the same
|
|
230
|
+
client. Use them for the qURL/resource/token management surface that has no
|
|
231
|
+
portal-verb equivalent (listing, updating, revoking, quotas, webhooks, ...) or
|
|
232
|
+
if you already build on them:
|
|
233
|
+
|
|
234
|
+
```typescript
|
|
235
|
+
// Create a protected link (portal equivalent: createPortalForUrl)
|
|
31
236
|
const result = await client.create({
|
|
32
237
|
target_url: 'https://api.example.com/data',
|
|
33
238
|
expires_in: '24h',
|
|
@@ -35,8 +240,8 @@ const result = await client.create({
|
|
|
35
240
|
});
|
|
36
241
|
console.log(result.qurl_link);
|
|
37
242
|
|
|
38
|
-
// Resolve a token (
|
|
39
|
-
const access = await client.resolve('
|
|
243
|
+
// Resolve a token headlessly (portal equivalent: enterPortal)
|
|
244
|
+
const access = await client.resolve('at_k8xqp9h2sj9lx7r4a');
|
|
40
245
|
console.log(`Access granted to ${access.target_url} for ${access.access_grant?.expires_in}s`);
|
|
41
246
|
```
|
|
42
247
|
|
|
@@ -54,7 +259,18 @@ console.log(`Access granted to ${access.target_url} for ${access.access_grant?.e
|
|
|
54
259
|
| `userAgent` | No | `qurl-typescript/<version>` |
|
|
55
260
|
| `debug` | No | `false` |
|
|
56
261
|
|
|
57
|
-
###
|
|
262
|
+
### Portal methods
|
|
263
|
+
|
|
264
|
+
| Method | Description |
|
|
265
|
+
|--------|-------------|
|
|
266
|
+
| `protectUrl(targetUrl, opts?)` | Protect a private URL → portal-minting `ProtectedResource` handle |
|
|
267
|
+
| `resource.createPortal(opts?)` / `createPortal(resourceOrId, opts?)` | Mint a short-lived portal link (`Portal`) |
|
|
268
|
+
| `createPortalForUrl(targetUrl, opts?)` | Protect + mint in one API call → `{ portal, resource }` |
|
|
269
|
+
| `connectorResource(connectorId)` | Handle for a service qURL Connector already protects |
|
|
270
|
+
| `resourceById(id)` | Handle from a stored resource id (no API call) |
|
|
271
|
+
| `enterPortal(linkOrToken)` | Open a qURL link programmatically → `ResourceHandle` |
|
|
272
|
+
|
|
273
|
+
### REST-shaped methods
|
|
58
274
|
|
|
59
275
|
| Method | Description |
|
|
60
276
|
|--------|-------------|
|
|
@@ -69,7 +285,6 @@ console.log(`Access granted to ${access.target_url} for ${access.access_grant?.e
|
|
|
69
285
|
| `mintLink(id, input?)` | Mint a new access link |
|
|
70
286
|
| `resolve(input)` | Resolve token + grant network access |
|
|
71
287
|
| `getQuota()` | Get quota/usage info |
|
|
72
|
-
| `bootstrapAgent(input)` | Bootstrap a qURL Connector agent |
|
|
73
288
|
| `listResources(input?)` / `listAllResources(input?)` / `createResource(input)` / `getResource(id)` | Resource management |
|
|
74
289
|
| `updateResource(id, input)` / `deleteResource(id)` | Update or revoke resources |
|
|
75
290
|
| `createQurlForResource(id, input?)` | Mint a qURL for an existing resource |
|
|
@@ -122,7 +337,7 @@ Non-400 errors (401, 403, 429, 5xx, and unexpected 400 body shapes) still throw
|
|
|
122
337
|
|
|
123
338
|
## Error Handling
|
|
124
339
|
|
|
125
|
-
|
|
340
|
+
Match errors by type, not message text — every failure throws a typed `QURLError` subclass, and message wording is not part of the API contract:
|
|
126
341
|
|
|
127
342
|
```typescript
|
|
128
343
|
import {
|
|
@@ -134,16 +349,16 @@ import {
|
|
|
134
349
|
} from '@layervai/qurl';
|
|
135
350
|
|
|
136
351
|
try {
|
|
137
|
-
await client.
|
|
352
|
+
await client.enterPortal('https://qurl.link/#at_k8xqp9h2sj9lx7r4a');
|
|
138
353
|
} catch (err) {
|
|
139
|
-
if (err instanceof
|
|
140
|
-
console.error('Invalid input:', err.invalidFields);
|
|
141
|
-
} else if (err instanceof RateLimitError) {
|
|
142
|
-
console.error(`Rate limited — retry after ${err.retryAfter}s`);
|
|
143
|
-
} else if (err instanceof AuthenticationError) {
|
|
354
|
+
if (err instanceof AuthenticationError) {
|
|
144
355
|
console.error('Bad API key');
|
|
145
356
|
} else if (err instanceof NotFoundError) {
|
|
146
|
-
console.error('
|
|
357
|
+
console.error('Portal doesn\'t exist or already expired');
|
|
358
|
+
} else if (err instanceof RateLimitError) {
|
|
359
|
+
console.error(`Rate limited — retry after ${err.retryAfter}s`);
|
|
360
|
+
} else if (err instanceof ValidationError) {
|
|
361
|
+
console.error('Invalid input:', err.detail, err.invalidFields);
|
|
147
362
|
} else if (err instanceof QURLError) {
|
|
148
363
|
console.error(`API error [${err.code}]: ${err.detail}`);
|
|
149
364
|
}
|
|
@@ -161,6 +376,13 @@ try {
|
|
|
161
376
|
| `NetworkError` | — | Connection failure |
|
|
162
377
|
| `TimeoutError` | — | Request exceeded timeout |
|
|
163
378
|
|
|
379
|
+
Client-detected failures use `status: 0` with a discriminating `code`:
|
|
380
|
+
`"client_validation"` for bad input caught before a request, and — on the
|
|
381
|
+
portal surface — `"resource_not_found"` / `"ambiguous_resource"` when
|
|
382
|
+
`connectorResource` cannot resolve a connector id to exactly one resource,
|
|
383
|
+
and `"unexpected_response"` when a response is missing required fields (e.g.
|
|
384
|
+
`enterPortal` failing closed on a grant with no resource URL).
|
|
385
|
+
|
|
164
386
|
## Pagination
|
|
165
387
|
|
|
166
388
|
```typescript
|
|
@@ -190,15 +412,50 @@ const clientWithLogger = new QURLClient({
|
|
|
190
412
|
|
|
191
413
|
## Retry Behavior
|
|
192
414
|
|
|
193
|
-
The client
|
|
194
|
-
|
|
195
|
-
- **GET
|
|
196
|
-
|
|
197
|
-
- **
|
|
198
|
-
|
|
415
|
+
The client retries only requests whose replay contract is explicit:
|
|
416
|
+
|
|
417
|
+
- **GET**: Retries on 429, 502, 503, 504 and transport failures, including a
|
|
418
|
+
dropped response body after successful headers arrive
|
|
419
|
+
- **POST/PATCH**: Retries complete status responses only on 429. A transport
|
|
420
|
+
failure while reading a response body is not replayed because the mutation
|
|
421
|
+
may have applied.
|
|
422
|
+
- **POST/PATCH fetch failures**: Retried with the `Idempotency-Key` generated on
|
|
423
|
+
the first attempt when no response is available
|
|
424
|
+
- **DELETE**: Never replayed automatically, including on 429. A transport
|
|
425
|
+
failure after dispatch makes the mutation outcome unknown, and the HTTP verb
|
|
426
|
+
alone cannot prove a replay safe. Reconcile resource state before you issue
|
|
427
|
+
a deliberate retry. This matches qurl-go's explicit-caller-retry model.
|
|
428
|
+
- **`Retry-After` header**: Preserved on 429 and 503 responses, including when
|
|
429
|
+
a mid-body transport failure produces a status-derived error (RFC 7231
|
|
430
|
+
§7.1.3), and honored by automatic GET retries. Currently the SDK only parses
|
|
431
|
+
**delta-seconds** values (e.g.
|
|
432
|
+
`Retry-After: 30`); HTTP-date values (`Retry-After: Wed, 21 Oct 2026 07:28:00 GMT`)
|
|
433
|
+
silently fall back to exponential backoff. Tracked in
|
|
434
|
+
[#61](https://github.com/layervai/qurl-typescript/issues/61).
|
|
435
|
+
- **Response failures**: Redirects, oversized successful bodies, and malformed
|
|
436
|
+
UTF-8 successful bodies are not retried. Retryable error statuses remain
|
|
437
|
+
retryable when an intermediary returns HTML, an empty body, malformed UTF-8,
|
|
438
|
+
or another complete non-envelope error response. A mid-stream transport
|
|
439
|
+
failure is retried for a successful GET or a GET with a retryable status.
|
|
440
|
+
A hard 4xx GET is not retried. An independent `AbortError` or `TimeoutError`
|
|
441
|
+
from an injected fetch, or while reading a successful response body, is a
|
|
442
|
+
non-retried `NetworkError`. After non-success headers arrive, the SDK
|
|
443
|
+
preserves the status-derived error class and attaches the independent
|
|
444
|
+
failure as its cause without retrying it. Only the SDK's timeout signal
|
|
445
|
+
produces a `TimeoutError`. Mutations require reconciliation.
|
|
446
|
+
|
|
447
|
+
All documented no-content DELETE operations require exactly HTTP 204 with an
|
|
448
|
+
empty response body. Alternate success statuses or response bytes fail closed
|
|
449
|
+
as `unexpected_response` contract errors whose `.status` preserves the
|
|
450
|
+
observed HTTP status. The delete may already have applied; reconcile resource
|
|
451
|
+
state before retrying.
|
|
199
452
|
|
|
200
453
|
Configure with `maxRetries` (default: 3). Set to `0` to disable.
|
|
201
454
|
|
|
455
|
+
When DELETE returns `RateLimitError`, use `retryAfter` for scheduling but
|
|
456
|
+
reconcile current resource state before issuing a deliberate retry; the SDK
|
|
457
|
+
does not assume the rejected response proves the mutation never ran.
|
|
458
|
+
|
|
202
459
|
> **Worst-case latency**: `timeout` is enforced per *attempt*, not for the whole request. Total worst-case latency is roughly `timeout × (maxRetries + 1) + sum(retry delays)`. Operators tuning `timeout` should account for this when sizing health-check budgets.
|
|
203
460
|
|
|
204
461
|
For POST/PATCH requests, the SDK generates a UUIDv7 `Idempotency-Key` once per logical call and reuses it across SDK-managed retries, so the API can return the original result instead of creating duplicate resources. If your application catches an error and calls the SDK again, pass a stable override so the new call deduplicates with the first one. Caller-provided keys must be non-empty printable ASCII strings of at most 256 characters and must not start or end with spaces. Use a unique key for each logical operation; reusing one key for a different request can return the first cached response. To tie retries to your own upstream job or request ID, pass a per-call override:
|
|
@@ -210,8 +467,68 @@ await client.create(
|
|
|
210
467
|
);
|
|
211
468
|
```
|
|
212
469
|
|
|
470
|
+
The portal verbs take the same per-call options as their final argument, e.g.
|
|
471
|
+
`resource.createPortal({ validFor: '5m' }, { idempotencyKey: 'mint-alice-1' })`.
|
|
472
|
+
|
|
213
473
|
SDK-generated keys require `globalThis.crypto.getRandomValues`, which is available in supported Node 20+ runtimes and modern edge/browser runtimes. In constrained runtimes without Web Crypto, pass a caller-provided key with `idempotencyKey`; otherwise POST/PATCH calls throw `RuntimeError` before sending a request.
|
|
214
474
|
|
|
475
|
+
## Security Notes
|
|
476
|
+
|
|
477
|
+
- Treat API keys and qURL links like credentials. Do not log them.
|
|
478
|
+
- SDK API requests use manual redirect handling. Redirect-capable HTTP statuses
|
|
479
|
+
(300, 301, 302, 303, 305, 307, 308), filtered `opaqueredirect` responses in
|
|
480
|
+
browsers and Node native fetch, and responses a custom fetch reports as
|
|
481
|
+
already redirected are rejected as a typed `QURLError`
|
|
482
|
+
(`code: "unexpected_response"`) without requesting the `Location` target.
|
|
483
|
+
This prevents forwarding `Authorization` and
|
|
484
|
+
`Idempotency-Key` when the fetch implementation honors `redirect: "manual"`
|
|
485
|
+
and accurately exposes `Response.redirected`. A shim must also leave
|
|
486
|
+
`Response.url` empty or report the normalized request URL when it did not
|
|
487
|
+
follow a redirect. Every Response-like shim must provide `headers.get(name)`.
|
|
488
|
+
A non-empty invalid `Response.url` fails closed with an SDK-authored error
|
|
489
|
+
that names this shim requirement but does not reflect the invalid value. A
|
|
490
|
+
non-redirecting 304 is handled as an ordinary unsuccessful API response
|
|
491
|
+
rather than mislabeled as a redirect.
|
|
492
|
+
- API success and error bodies are limited to **1 MiB (1,048,576 bytes)**,
|
|
493
|
+
matching qurl-go's security posture. The SDK checks `Content-Length` when
|
|
494
|
+
present and independently counts streamed bytes, so missing or inaccurate
|
|
495
|
+
headers cannot bypass the limit. Bodies exactly at the limit are accepted.
|
|
496
|
+
This fixed security limit has no override. Callers must request a smaller
|
|
497
|
+
page, and the SDKs must not independently widen the limit.
|
|
498
|
+
A maximum-size list page can exceed the cap after JSON escaping; request a
|
|
499
|
+
smaller page if a list call reports the body-limit error. For resumable list
|
|
500
|
+
reads, use the page method, save each successful `next_cursor`, and resume
|
|
501
|
+
from that cursor with a smaller `limit`. A shared configurable-cap decision
|
|
502
|
+
is tracked in [#249](https://github.com/layervai/qurl-typescript/issues/249).
|
|
503
|
+
Standards-compliant fetch implementations are bounded while streaming;
|
|
504
|
+
custom Response-like shims that omit `body` are validated after their
|
|
505
|
+
`text()`/`json()` result has already been materialized by that shim.
|
|
506
|
+
Oversized bodies fail with a typed error before JSON decoding while
|
|
507
|
+
preserving the observed status-derived error class and `Retry-After`.
|
|
508
|
+
Transient error statuses retain the normal retry policy for GET and
|
|
509
|
+
idempotency-key-backed mutations; oversized successful responses and DELETE
|
|
510
|
+
responses are not retried.
|
|
511
|
+
Successful JSON bodies with malformed UTF-8 are rejected without replay.
|
|
512
|
+
Server-provided error title/detail snippets and `invalidFields` keys/values
|
|
513
|
+
have controls and bidirectional formatting characters removed, are
|
|
514
|
+
normalized to one line, and are capped at 512 UTF-8 bytes. Machine-readable
|
|
515
|
+
error codes, RFC 7807 type/instance values, and request IDs are kept only
|
|
516
|
+
when they need no normalization and fit the same limit. At most 100
|
|
517
|
+
`invalidFields` entries are retained, and each retained
|
|
518
|
+
`invalidFields` or debug `body_keys` collection has an 8 KiB UTF-8 budget.
|
|
519
|
+
Redirect/body-limit contract-error details do not include `Location` values
|
|
520
|
+
or response-body snippets. Standard request debug logging includes the request
|
|
521
|
+
URL, so do not place credentials in identifiers or enable debug output in a
|
|
522
|
+
sensitive logging environment.
|
|
523
|
+
- Prefer short portal lifetimes such as `validFor: '5m'`.
|
|
524
|
+
- Do not ask portal recipients to handle credentials. Recipients only need
|
|
525
|
+
the link.
|
|
526
|
+
- `protectUrl` and `createPortalForUrl` reject malformed target URLs and URLs
|
|
527
|
+
with embedded credentials (`https://user:pass@...`) before any request,
|
|
528
|
+
matching qurl-go.
|
|
529
|
+
- The programmatic opener fails closed: `enterPortal` throws when access is
|
|
530
|
+
granted without a resource URL, and its error messages never echo the link.
|
|
531
|
+
|
|
215
532
|
## Versioning & breaking changes
|
|
216
533
|
|
|
217
534
|
This SDK is pre-1.0; breaking changes between minor versions are possible until the API surface stabilizes. Significant changes are called out in [`CHANGELOG.md`](CHANGELOG.md) and in the corresponding GitHub release notes.
|