@layervai/qurl 0.6.0 → 2.0.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.
Files changed (127) hide show
  1. package/README.md +332 -15
  2. package/dist/cjs/client.d.ts +62 -31
  3. package/dist/cjs/client.js +932 -163
  4. package/dist/cjs/crid.d.ts +3 -0
  5. package/dist/cjs/crid.js +62 -0
  6. package/dist/cjs/errors.d.ts +30 -5
  7. package/dist/cjs/errors.js +41 -6
  8. package/dist/cjs/index.d.ts +5 -3
  9. package/dist/cjs/index.js +11 -1
  10. package/dist/cjs/node/agent-runtime.d.ts +108 -0
  11. package/dist/cjs/node/agent-runtime.js +952 -0
  12. package/dist/cjs/node/agent-state.d.ts +100 -0
  13. package/dist/cjs/node/agent-state.js +311 -0
  14. package/dist/cjs/node/agent-transport.d.ts +17 -0
  15. package/dist/cjs/node/agent-transport.js +118 -0
  16. package/dist/cjs/node/deployment.d.ts +6 -0
  17. package/dist/cjs/node/deployment.js +24 -3
  18. package/dist/cjs/node/file-agent-state.d.ts +15 -0
  19. package/dist/cjs/node/file-agent-state.js +137 -0
  20. package/dist/cjs/node/native-loader.cjs +7 -0
  21. package/dist/cjs/node/native-loader.d.cts +11 -0
  22. package/dist/cjs/node/native-udp.d.ts +8 -3
  23. package/dist/cjs/node/native-udp.js +8 -3
  24. package/dist/cjs/node/nhp-wire.d.ts +9 -0
  25. package/dist/cjs/node/nhp-wire.js +24 -7
  26. package/dist/cjs/node/portal-opener.d.ts +7 -0
  27. package/dist/cjs/node/portal-opener.js +28 -6
  28. package/dist/cjs/node/provider.d.ts +19 -0
  29. package/dist/cjs/node/provider.js +153 -0
  30. package/dist/cjs/node/qv2.d.ts +3 -1
  31. package/dist/cjs/node/qv2.js +16 -2
  32. package/dist/cjs/node/relay.d.ts +12 -0
  33. package/dist/cjs/node/relay.js +111 -0
  34. package/dist/cjs/node/sealed-agent-state.d.ts +19 -0
  35. package/dist/cjs/node/sealed-agent-state.js +193 -0
  36. package/dist/cjs/node/strict-json.d.ts +1 -1
  37. package/dist/cjs/node/strict-json.js +8 -3
  38. package/dist/cjs/node.d.ts +14 -0
  39. package/dist/cjs/node.js +24 -1
  40. package/dist/cjs/share.d.ts +56 -0
  41. package/dist/cjs/share.js +113 -0
  42. package/dist/cjs/types.d.ts +173 -13
  43. package/dist/cjs/version.d.ts +1 -1
  44. package/dist/cjs/version.js +1 -1
  45. package/dist/esm/client.d.ts +62 -31
  46. package/dist/esm/client.d.ts.map +1 -1
  47. package/dist/esm/client.js +932 -164
  48. package/dist/esm/client.js.map +1 -1
  49. package/dist/esm/crid.d.ts +4 -0
  50. package/dist/esm/crid.d.ts.map +1 -0
  51. package/dist/esm/crid.js +58 -0
  52. package/dist/esm/crid.js.map +1 -0
  53. package/dist/esm/errors.d.ts +30 -5
  54. package/dist/esm/errors.d.ts.map +1 -1
  55. package/dist/esm/errors.js +39 -5
  56. package/dist/esm/errors.js.map +1 -1
  57. package/dist/esm/index.d.ts +5 -3
  58. package/dist/esm/index.d.ts.map +1 -1
  59. package/dist/esm/index.js +3 -2
  60. package/dist/esm/index.js.map +1 -1
  61. package/dist/esm/node/agent-runtime.d.ts +109 -0
  62. package/dist/esm/node/agent-runtime.d.ts.map +1 -0
  63. package/dist/esm/node/agent-runtime.js +946 -0
  64. package/dist/esm/node/agent-runtime.js.map +1 -0
  65. package/dist/esm/node/agent-state.d.ts +101 -0
  66. package/dist/esm/node/agent-state.d.ts.map +1 -0
  67. package/dist/esm/node/agent-state.js +298 -0
  68. package/dist/esm/node/agent-state.js.map +1 -0
  69. package/dist/esm/node/agent-transport.d.ts +18 -0
  70. package/dist/esm/node/agent-transport.d.ts.map +1 -0
  71. package/dist/esm/node/agent-transport.js +114 -0
  72. package/dist/esm/node/agent-transport.js.map +1 -0
  73. package/dist/esm/node/deployment.d.ts +6 -0
  74. package/dist/esm/node/deployment.d.ts.map +1 -1
  75. package/dist/esm/node/deployment.js +23 -3
  76. package/dist/esm/node/deployment.js.map +1 -1
  77. package/dist/esm/node/file-agent-state.d.ts +16 -0
  78. package/dist/esm/node/file-agent-state.d.ts.map +1 -0
  79. package/dist/esm/node/file-agent-state.js +134 -0
  80. package/dist/esm/node/file-agent-state.js.map +1 -0
  81. package/dist/esm/node/native-loader.cjs +7 -0
  82. package/dist/esm/node/native-loader.d.cts +11 -0
  83. package/dist/esm/node/native-udp.d.ts +8 -3
  84. package/dist/esm/node/native-udp.d.ts.map +1 -1
  85. package/dist/esm/node/native-udp.js +7 -5
  86. package/dist/esm/node/native-udp.js.map +1 -1
  87. package/dist/esm/node/nhp-wire.d.ts +9 -0
  88. package/dist/esm/node/nhp-wire.d.ts.map +1 -1
  89. package/dist/esm/node/nhp-wire.js +23 -6
  90. package/dist/esm/node/nhp-wire.js.map +1 -1
  91. package/dist/esm/node/portal-opener.d.ts +7 -0
  92. package/dist/esm/node/portal-opener.d.ts.map +1 -1
  93. package/dist/esm/node/portal-opener.js +28 -6
  94. package/dist/esm/node/portal-opener.js.map +1 -1
  95. package/dist/esm/node/provider.d.ts +20 -0
  96. package/dist/esm/node/provider.d.ts.map +1 -0
  97. package/dist/esm/node/provider.js +149 -0
  98. package/dist/esm/node/provider.js.map +1 -0
  99. package/dist/esm/node/qv2.d.ts +3 -1
  100. package/dist/esm/node/qv2.d.ts.map +1 -1
  101. package/dist/esm/node/qv2.js +17 -3
  102. package/dist/esm/node/qv2.js.map +1 -1
  103. package/dist/esm/node/relay.d.ts +13 -0
  104. package/dist/esm/node/relay.d.ts.map +1 -0
  105. package/dist/esm/node/relay.js +105 -0
  106. package/dist/esm/node/relay.js.map +1 -0
  107. package/dist/esm/node/sealed-agent-state.d.ts +20 -0
  108. package/dist/esm/node/sealed-agent-state.d.ts.map +1 -0
  109. package/dist/esm/node/sealed-agent-state.js +190 -0
  110. package/dist/esm/node/sealed-agent-state.js.map +1 -0
  111. package/dist/esm/node/strict-json.d.ts +1 -1
  112. package/dist/esm/node/strict-json.d.ts.map +1 -1
  113. package/dist/esm/node/strict-json.js +8 -3
  114. package/dist/esm/node/strict-json.js.map +1 -1
  115. package/dist/esm/node.d.ts +14 -0
  116. package/dist/esm/node.d.ts.map +1 -1
  117. package/dist/esm/node.js +7 -0
  118. package/dist/esm/node.js.map +1 -1
  119. package/dist/esm/share.d.ts +57 -0
  120. package/dist/esm/share.d.ts.map +1 -0
  121. package/dist/esm/share.js +109 -0
  122. package/dist/esm/share.js.map +1 -0
  123. package/dist/esm/types.d.ts +173 -13
  124. package/dist/esm/types.d.ts.map +1 -1
  125. package/dist/esm/version.d.ts +1 -1
  126. package/dist/esm/version.js +1 -1
  127. package/package.json +17 -8
package/README.md CHANGED
@@ -88,12 +88,15 @@ checks the non-empty 2048-byte boundary; the API remains authoritative for the
88
88
  path grammar and the tunnel-only resource gate. `createPortalForUrl` rejects
89
89
  this option because it creates a URL resource.
90
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)`
91
+ `resource.crid` is the required management identifier. Connector lookup, delete,
92
+ and portal minting use it with no public-key or private-ID fallback. The SDK
93
+ checks that the returned public key matches the CRID.
94
+
95
+ `resource.resourcePublicKey`, `resource.connectorRoutingId`, and
96
+ `resource.knockResourceId` are three distinct server-issued values for verification, reverse routing, and NHP admission. Consume each verbatim; never
97
+ derive or substitute one for another. Use `getConnectorResource(crid)`
95
98
  or `getConnectorResourceBySlug(slug)` for read-only lookup and
96
- `deleteConnectorResource(resourceId)` to revoke it. This replaces the old
99
+ `deleteConnectorResource(crid)` to revoke it. This replaces the old
97
100
  alias-based `connectorResource(connectorId)` method.
98
101
  `ConnectorResource` instances cannot be constructed directly; the client
99
102
  returns them only after validating the complete response contract.
@@ -101,7 +104,7 @@ returns them only after validating the complete response contract.
101
104
  `ensureConnectorResource` and `deleteConnectorResource` throw
102
105
  `ConnectorResourceOutcomeUnknownError` when a dispatched mutation may have
103
106
  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
107
+ or CRID before deciding whether to retry. The wrapper deliberately uses
105
108
  `status: 0`; the original typed error is available as `cause`, including its
106
109
  observed HTTP status.
107
110
 
@@ -117,17 +120,20 @@ is terminal: do not retry it. Obtain a new bootstrap key and use normal
117
120
  owner-authenticated lookup by immutable slug to reconcile the resource first.
118
121
 
119
122
  The API does not apply idempotency replay to DELETE, so after an outcome-unknown
120
- `deleteConnectorResource` call, reconcile by resource ID before issuing a
123
+ `deleteConnectorResource` call, reconcile by CRID before issuing a
121
124
  deliberate retry. A valid exact-201 resource missing only `meta.found_existing`
122
125
  is known to have selected that row but still fails as an unwrapped
123
126
  `unexpected_response` because required ensure metadata is absent.
124
127
 
125
- If you persist the resource id, future calls do not need to recreate the
128
+ If you persist the CRID, future calls do not need to recreate the
126
129
  handle (no API call is made until you mint):
127
130
 
128
131
  ```typescript
129
- const resource = client.resourceById('r_demo1234567');
130
- const portal = await resource.createPortal({ validFor: '1h' });
132
+ async function createPortalForStoredResource(storedCrid: string) {
133
+ // Pass the CRID previously returned by the API.
134
+ const resource = client.resourceByCrid(storedCrid);
135
+ return resource.createPortal({ validFor: '1h' });
136
+ }
131
137
  ```
132
138
 
133
139
  For one-off scripts, `client.createPortalForUrl` combines the two API calls
@@ -162,6 +168,174 @@ NHP UDP. It has no relay or HTTP-resolve fallback. Connector assignment and
162
168
  registration are separate producer operations and are not part of the portal
163
169
  opener.
164
170
 
171
+ If you already hold a CRID, mint a fresh share link directly:
172
+
173
+ ```typescript
174
+ const share = await client.shareResource(crid, { ttlSeconds: 300 });
175
+ // Optional: verify the response CRID against DER SPKI bytes you already trust
176
+ // before the secret leaves this process.
177
+ await share.verifyCrid(resourcePublicKeyDer);
178
+ await deliverToRecipient(share.link); // Secret; returned once and not retrievable
179
+ console.log(share.qurlId); // Safe handle for revoking only this link later
180
+ ```
181
+
182
+ `shareResource` returns the current share-safe `#qv2t1...` link. Recipients
183
+ open that URL in the qURL browser flow. For native TypeScript opening, pass
184
+ `share.link` to `createPortalOpener` from `@layervai/qurl/node` with trusted
185
+ `QURL_DEPLOYMENT` configuration and `expectedCRID: advertisedCRID`.
186
+ Obtain `advertisedCRID` independently of the response carrying the link.
187
+ The opener verifies the issuer signature and CRID/key binding before each
188
+ native access request, including renewals. Empty, malformed, unsupported,
189
+ and mismatched CRIDs fail closed. Omitting the option retains link-only access.
190
+ This verifies resource identity, not content or current revocation state.
191
+ The access-binding check accepts only locally registered versions and their
192
+ registered digest widths, matching the Go SDK and browser agent. Upgrade the
193
+ SDK when a new version is activated. This is stricter than the forwarding
194
+ and supplied-key helper below. See the native opener example below.
195
+ `verifyCrid` does not open the link or make a network request.
196
+
197
+ `verifyCrid` verifies that the response CRID derives from the trusted resource
198
+ key; it does not independently bind the secret link fragment to that key.
199
+ It intentionally follows qurl-go's environment-agnostic key-match rule: CRID
200
+ version zero is rejected as reserved, while every other structurally valid
201
+ version is accepted and environment classification is not part of key-to-digest
202
+ verification. It compares all 32 digest bytes for a full CRID and all 24 for
203
+ a truncated CRID; the response determines the digest width. CRIDs use their canonical lowercase base32 spelling; uppercase or
204
+ mixed-case spellings fail closed. Binary inputs of any length are hashed;
205
+ `invalid_crid_key` is reserved for non-binary or unreadable (detached) runtime
206
+ values, and a readable binary key that does not match reports `crid_mismatch`.
207
+ Use `error instanceof CRIDVerificationError` (or `error.status === 0`) to
208
+ distinguish these local verification failures from a server error that happens
209
+ to use the same problem code.
210
+ `qurlId` and `singleUse` are undefined when omitted by the service.
211
+ `ShareLink` keeps `.link` directly readable but redacts it from JSON, Node
212
+ inspection, and object spread to reduce accidental credential logging. Read
213
+ `.link` directly before delivery; do not spread or clone a `ShareLink`, because
214
+ those operations deliberately omit the credential. Browser developer consoles
215
+ can still display non-enumerable properties, so do not log the object there.
216
+ The constructor snapshots a caller-provided expiry, and the `ShareLink`
217
+ properties are frozen. Each `expiresAt` read returns a defensive `Date` copy;
218
+ changing that copy does not change the share metadata.
219
+
220
+ Omitting `ttlSeconds` matches qurl-go's zero-value behavior and requests the
221
+ platform default. TypeScript additionally rejects an explicit zero so a
222
+ computed countdown cannot silently become a longer-lived default credential.
223
+ A response can report `expiresInSeconds: 0`; do not reuse that value as a
224
+ request TTL. Expiry parsing uses the SDK's shared API date parser.
225
+
226
+ The service chooses single-use policy; this matches the Go SDK options.
227
+
228
+ qURL Connector assignment and registration use native UDP through
229
+ `qurl-connector` and `qurl-go`. This package does not expose an HTTP enrollment API.
230
+ Like the Go SDK, credential minting uses HTTPS and token consumption uses
231
+ native UDP. The Go SDK currently exposes minting through its restricted
232
+ `RegisteredAgentResourceHTTPDoer` bridge; this SDK uses `createApiKey`.
233
+ The service controls which credential kinds each caller can mint.
234
+ It can mint the one-shot credential consumed by that native enrollment flow:
235
+
236
+ ```typescript
237
+ const enrollment = await client.createApiKey({
238
+ kind: 'enrollment_token',
239
+ name: 'prod-dashboard enrollment',
240
+ target: 'connector',
241
+ claims: [{ type: 'connector', id: 'prod-dashboard' }],
242
+ expires_in: '15m',
243
+ });
244
+ if (!enrollment.api_key) throw new Error('Enrollment response omitted its one-time token');
245
+ await deliverEnrollmentTokenSecurely(enrollment.api_key);
246
+ ```
247
+
248
+ Durable `api_key` credentials require explicit scopes and do not accept
249
+ `expires_in`; enrollment tokens derive their scopes from `target`/`claims` and
250
+ expire within 24 hours. A connector claim's `id` is its immutable connector
251
+ slug. Request enums are validated against the current service contract;
252
+ response types remain additive for forward-compatible reads, and new request
253
+ enum values require a matching SDK release. Use
254
+ `isApiKeyRequestScope(scope)` to validate response scopes before writing them
255
+ back. Reject unknown scopes; do not filter them out, which can remove permissions.
256
+ When changing only a name, omit `scopes` from `updateApiKey`.
257
+
258
+ ```typescript
259
+ import { isApiKeyRequestScope } from '@layervai/qurl';
260
+
261
+ const scopes = key.scopes;
262
+ if (!scopes?.length || !scopes.every(isApiKeyRequestScope)) {
263
+ throw new Error('Cannot reuse these scopes; check the current SDK contract');
264
+ }
265
+ await client.updateApiKey(keyId, { scopes });
266
+ ```
267
+
268
+ This credential surface requires the kind-first qurl-service contract at or
269
+ after commit `047cf31e1cdf545e3060e0f9294d738a19fb997b`.
270
+
271
+ ## Delegated qURL batches
272
+
273
+ Services with a delegated mint capability can create up to 100 ordered grants
274
+ without an API call per recipient:
275
+
276
+ ```typescript
277
+ import { DelegatedBatchOutcomeUnknownError, QURLClient } from '@layervai/qurl';
278
+
279
+ const client = new QURLClient({ apiKey: process.env.QURL_API_KEY! });
280
+ const key = crypto.randomUUID();
281
+ const mintCapability = process.env.QURL_MINT_CAPABILITY!;
282
+ const recipients = [{ name: 'Alice' }];
283
+
284
+ const accepted = await client
285
+ .createDelegatedQurlBatch(
286
+ {
287
+ mint_capability: mintCapability,
288
+ grants: recipients.map((recipient) => ({ label: recipient.name, expires_in: '1h' })),
289
+ },
290
+ { idempotencyKey: key },
291
+ )
292
+ .catch((error: unknown) => {
293
+ if (error instanceof DelegatedBatchOutcomeUnknownError && error.batchId) {
294
+ console.error('Poll the accepted batch before retrying', { batchId: error.batchId });
295
+ }
296
+ // A deliberate retry must reuse this key and exact body.
297
+ throw error;
298
+ });
299
+
300
+ let etag = accepted.etag;
301
+ let retryAfter = accepted.retry_after;
302
+ let state;
303
+ const deadline = Date.now() + 60_000;
304
+ for (let polls = 0; polls < 30 && Date.now() < deadline; polls++) {
305
+ await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
306
+ state = await client.getDelegatedQurlBatch(accepted.batch_id, { etag });
307
+ if (state.http_status === 304) {
308
+ retryAfter = state.retry_after ?? retryAfter;
309
+ } else if (state.http_status === 202) {
310
+ ({ etag, retry_after: retryAfter } = state);
311
+ } else {
312
+ break;
313
+ }
314
+ }
315
+
316
+ if (state?.http_status !== 200) throw new Error('Delegated qURL batch did not finish');
317
+
318
+ if (state.results[0]?.status === 'succeeded') {
319
+ const qurlId = state.results[0].qurl.qurl_id;
320
+ const metadata = await client.getDelegatedQurl(qurlId);
321
+ console.log(metadata.status);
322
+ await client.deleteDelegatedQurl(qurlId);
323
+ }
324
+ ```
325
+
326
+ The idempotency key must contain 32–256 visible ASCII characters. A UUID meets
327
+ this requirement.
328
+
329
+ One read makes one HTTP attempt. The caller owns the poll count, total deadline,
330
+ and wait from `retry_after`. A `304` means the prior ETag is still current. A
331
+ `200` contains the terminal, input-ordered results. Keep each returned qURL as
332
+ an opaque bearer link and apply any deployment-specific origin check before
333
+ publishing it.
334
+
335
+ Delegated DELETE calls are not retried automatically. After a transport failure
336
+ or `503 mutation_outcome_unknown`, retry the same qURL ID until the service
337
+ returns `204`.
338
+
165
339
  ## Opening Portals
166
340
 
167
341
  Most recipients open qURL links directly and do not use this SDK at all. If
@@ -201,6 +375,7 @@ const { createPortalOpener } = require('@layervai/qurl/node');
201
375
  async function uploadPrivateObject(uploadBody) {
202
376
  const opener = createPortalOpener({
203
377
  qurl: process.env.PRIVATE_UPLOAD_QURL,
378
+ expectedCRID: process.env.PRIVATE_UPLOAD_CRID ?? '', // Independently held; missing fails closed.
204
379
  });
205
380
  try {
206
381
  await opener.start();
@@ -378,9 +553,9 @@ console.log(`Access granted to ${access.target_url} for ${access.access_grant?.e
378
553
  | `resource.createPortal(opts?)` / `createPortal(resourceOrId, opts?)` | Mint a short-lived portal link; existing resources can set `targetPath` |
379
554
  | `createPortalForUrl(targetUrl, opts?)` | Protect + mint a URL resource; rejects `targetPath` |
380
555
  | `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 |
383
- | `resourceById(id)` | Handle from a stored resource id (no API call) |
556
+ | `getConnectorResource(crid)` / `getConnectorResourceBySlug(slug)` | Load a validated Connector resource by immutable identity |
557
+ | `deleteConnectorResource(crid)` | Revoke a Connector resource by CRID |
558
+ | `resourceByCrid(crid)` | Handle from a stored CRID (no API call) |
384
559
  | `enterPortal(linkOrToken)` | Open a qURL link programmatically → `ResourceHandle` |
385
560
 
386
561
  ### REST-shaped methods
@@ -442,7 +617,7 @@ if (result.failed > 0) {
442
617
 
443
618
  Non-400 errors (401, 403, 429, 5xx, and unexpected 400 body shapes) still throw the appropriate `QURLError` subclass.
444
619
 
445
- **Slimmer per-item shape** — `BatchItemSuccess` returns `{ resource_id, qurl_link, qurl_site, expires_at? }` per item. Unlike single `client.create()`, the batch response intentionally **omits `qurl_id` and `label`** to keep the payload compact. If you migrate a per-item `create()` loop to `batchCreate` and rely on `qurl_id` for downstream addressing, fetch each via `client.get(resource_id)` after the batch (or stay on the single-create path).
620
+ **Slimmer per-item shape** — `BatchItemSuccess` returns `{ crid, resource_id, qurl_link, qurl_site, expires_at? }` per item. Unlike single `client.create()`, the batch response intentionally **omits `qurl_id` and `label`** to keep the payload compact. If you migrate a per-item `create()` loop to `batchCreate` and rely on `qurl_id` for downstream addressing, check `item.crid` is present, then fetch each via `client.get(item.crid)` after the batch (or stay on the single-create path).
446
621
 
447
622
  **Result ordering** — `result.results` is **not** guaranteed to be sorted by `index`. Each entry's `index` field carries the position in the original `items` array, so build per-input-position state by keying on `r.index` (e.g., `for (const r of result.results) { byInputIndex[r.index] = r; }`) rather than relying on iteration order.
448
623
 
@@ -488,6 +663,7 @@ try {
488
663
  | `ServerError` | 5xx | Server-side failure |
489
664
  | `NetworkError` | — | Connection failure |
490
665
  | `TimeoutError` | — | Request exceeded timeout |
666
+ | `DelegatedBatchOutcomeUnknownError` | — | Dispatched batch create needs reconciliation |
491
667
 
492
668
  Client-detected failures use `status: 0` with a discriminating `code`:
493
669
  `"client_validation"` for bad input caught before a request, and — on the
@@ -504,7 +680,8 @@ const page = await client.list({ limit: 10, status: 'active' });
504
680
 
505
681
  // Auto-paginate through all results
506
682
  for await (const qurl of client.listAll({ status: 'active' })) {
507
- console.log(qurl.resource_id);
683
+ if (!qurl.crid) throw new Error("The API response has no resource CRID");
684
+ console.log(qurl.crid);
508
685
  }
509
686
  ```
510
687
 
@@ -588,6 +765,10 @@ SDK-generated keys require `globalThis.crypto.getRandomValues`, which is availab
588
765
  ## Security Notes
589
766
 
590
767
  - Treat API keys and qURL links like credentials. Do not log them.
768
+ - Resource/qURL path arguments reject pasted access tokens and full URLs before
769
+ dispatch without echoing the caller input. Pass the opaque identifier returned
770
+ by the API (public resource key or CRID); never derive it from a secret qURL
771
+ link. Private `r_` storage IDs are not public API identifiers.
591
772
  - SDK API requests use manual redirect handling. Redirect-capable HTTP statuses
592
773
  (300, 301, 302, 303, 305, 307, 308), filtered `opaqueredirect` responses in
593
774
  browsers and Node native fetch, and responses a custom fetch reports as
@@ -651,3 +832,139 @@ When upgrading, check the release notes for migration guidance — recent breaki
651
832
  ## License
652
833
 
653
834
  MIT
835
+
836
+ Resource verification requires Web Crypto (`crypto.subtle`). In browsers, use
837
+ an HTTPS page or localhost. Raw REST response types keep `crid` optional;
838
+ resource handles and portals require a validated CRID.
839
+
840
+ ## Node producer runtime (SDK 2.x, NHP 1.1)
841
+
842
+ The SDK implements the producer lifecycle in TypeScript. A small optional native
843
+ package, `@layervai/qurl-state-fs`, supplies descriptor-relative filesystem
844
+ operations on Linux and macOS. There is no bundled Go runtime. The portable
845
+ `@layervai/qurl` entry point does not load the native module.
846
+
847
+ ```ts
848
+ import { FileAgentState, connectAgentRuntime } from '@layervai/qurl/node';
849
+
850
+ const store = new FileAgentState('/private/agent/state.json');
851
+ const runtime = await connectAgentRuntime(store, {
852
+ hub: { host: 'hub.nhp.layerv.ai', port: 443, server_public_key_b64: hubPublicKey },
853
+ headless: true,
854
+ enrollmentCredential: bootstrapCredential,
855
+ });
856
+ try {
857
+ // runtime.client uses the durable device credential, with bounded reloads.
858
+ const grant = await runtime.knock(knockResourceID, {
859
+ protectedResourceID: crid, runID: '0123456789abcdef', runAttempt: 1n,
860
+ });
861
+ // Keep the original receipt. It retains the issuing cell across relocation.
862
+ await runtime.retire(grant.receipt);
863
+ } finally {
864
+ await runtime.close();
865
+ store.close();
866
+ }
867
+ ```
868
+
869
+ A registered state opens without enrollment or network work while its assignment
870
+ lease is current. An explicit Hub option or the `hub` field in `QURL_DEPLOYMENT`
871
+ provides refresh trust. `runtime.refresh()` handles assignment renewal and
872
+ relocation. `knock()` attempts renewal near lease expiry and refuses expired
873
+ assignments. Account enrollment uses an explicit `otpProvider` instead of
874
+ `headless`. Resume an interrupted enrollment with the same credential and
875
+ metadata. The SDK persists activation and completion replay authority before
876
+ sending the corresponding mutation.
877
+
878
+ `recoverAgentRuntime(store, recoveryCredential, { hub })` starts explicit device
879
+ credential recovery. It persists the issue nonce, replacement candidate, and
880
+ recovery horizon. It saves the recovered credential before mandatory assignment
881
+ refresh. A failed refresh resumes without another recovery grant. No startup
882
+ path silently resets an existing identity.
883
+
884
+ Legacy pre-v6 pending-completion state remains readable, but enrollment returns
885
+ `RECOVERY_MIGRATION_REQUIRED` without UDP or state changes. Those records lack
886
+ an authenticated recovery deadline; explicit recovery or reprovisioning is required.
887
+ Filesystem state creation honors the process umask and creates missing ancestors.
888
+ Keep owner read/write/search permissions enabled (for example, umask `077`);
889
+ unsafe existing permissions or an incompatible umask fail closed.
890
+
891
+ `FileAgentState` uses a private directory, mode 0600 files, a process-safe setup
892
+ lock, exclusive temporary files, file and directory fsync, and atomic replacement.
893
+ It rejects symlinks, hard links, unsafe permissions, and directory or lock
894
+ replacement. Await `runtime.close()` before closing the store. Inside `withLock`,
895
+ use the supplied locked handle; using the outer store waits until the lock timeout.
896
+ Crash-abandoned `.qurl-*` temporary files remain private but need offline cleanup;
897
+ automatic sweeps could race other state files in the same directory. Filesystem state
898
+ uses synchronous native I/O, including fsync, on the event loop. Use local storage;
899
+ slow or network filesystems can block the process. Continuity checks also run before
900
+ lifecycle datagrams and cannot be cached safely across external directory changes.
901
+ An existing state directory must be owned by the process user with mode 0700.
902
+ On macOS, `/var`, `/tmp`, and `/etc` resolve through their standard `/private` paths.
903
+ A save error after rename can mean the new state is present but its durability is
904
+ unconfirmed; do not assume the previous state remains. Filesystem state
905
+ currently requires Linux or macOS; unsupported platforms fail closed. Install
906
+ scripts may be disabled when a matching prebuilt native binary is available.
907
+ Linux x64/arm64 prebuilds distinguish glibc and musl; CI tests Ubuntu 24.04 and
908
+ Alpine 3.22. macOS prebuilds cover x64 and arm64.
909
+
910
+ For sealed state, use `openSealedFileAgentState(path, providerID, keyWrapper,
911
+ expectedAgentID)`. Each save uses a fresh AES-256-GCM key and verifies wrapping and
912
+ unwrapping before commit. The envelope is compatible with the pinned Go SDK.
913
+ Sealing authenticates the agent, provider, purpose, version, and wrapped-key
914
+ metadata. It does not detect rollback to an older valid envelope. JavaScript
915
+ strings cannot be reliably erased; avoid logging state or credentials.
916
+
917
+ AWS adapters are separate exports from `@layervai/qurl-aws` (ESM and CommonJS).
918
+ Install the AWS client for the subpath you use: `/ssm`, `/secrets-manager`, or
919
+ `/kms`. These clients are optional peer dependencies. Importing the root barrel
920
+ requires all three clients.
921
+
922
+
923
+ - `createSSMAgentStateStore(ssmClient, parameterName, { kmsKeyID, tier })` stores
924
+ SecureString parameters and enforces tier size limits.
925
+ - `createSecretsManagerAgentStateStore(secretsClient, secretID, kmsKeyID)` handles
926
+ missing-secret creation races with one write token.
927
+ - `createKMSAgentStateKeyWrapper(kmsClient, keyID)` wraps only the data key and
928
+ authenticates all four state-binding fields in the KMS encryption context.
929
+
930
+ SSM and Secrets Manager stores serialize lifecycle calls within one store handle.
931
+ Loads outside a lifecycle lock can read the previous committed state.
932
+ They require one process to own each state object: these services do not provide
933
+ an agent setup transaction lock. KMS is a key wrapper, not a state store.
934
+
935
+ ## Explicit relay and discovery
936
+
937
+ Native UDP remains the default transport. An unknown native cell fails closed, including
938
+ when a relay allowlist exists. Select `transport: 'relay'` explicitly to use HTTPS
939
+ relay. Explicit relay trust uses signed qv2 claims and the relay allowlist, not
940
+ the native cell catalog. Relay URLs must match that deployment trust.
941
+ Redirects and oversized responses are refused. A hostname-only allowlist entry
942
+ authorizes all ports on that host; use `host:port` to limit the port.
943
+
944
+ `createStaticProvider(deployment)` snapshots fixed trust. For discovery, use
945
+ `createDiscoveryProvider({ fetcher, pinSHA256, manifestKeys, requireSignature,
946
+ minVersion, expectedProfile })` and `createHTTPManifestFetcher(httpsURL)`. Supply
947
+ a manifest pin or signing keys. Discovery is relay-only and requires
948
+ `transport: 'relay'`; native UDP requires a static cell catalog. Discovery verifies the domain-separated low-S
949
+ signature when configured, validity times, profile, and monotonic version floor.
950
+ It never returns stale trust after a failed refresh. Persist `minVersion` in
951
+ configuration when downgrade protection must survive process restarts.
952
+ Like Go, discovery fetches on each resolution, including renewal retries; it has
953
+ no built-in cache. Use static trust for high-volume opens, or supply a provider
954
+ with a deployment-owned refresh policy that refuses expired or failed-refresh trust.
955
+
956
+ ```ts
957
+ const opener = createPortalOpener({ qurl: signedLink, provider, transport: 'relay' });
958
+ ```
959
+
960
+ The SHA-pinned reviewed baseline and behavior gates are in `parity-manifest.json`.
961
+ CI reports the candidate SHA and tests that candidate against pinned Go behavior.
962
+ It does not require release or dependency PRs to match the baseline tree.
963
+ Run `npm run build`, `npm test`, `npm run smoke:dist`, and `npm run parity:go`.
964
+ Set `QURL_GO_REFERENCE` to a clean checkout at the manifest's exact Go revision.
965
+ The direct gate compares producer wire bytes and sealed-state reads and writes
966
+ across both languages. It also runs the TypeScript lifecycle against a local Go UDP
967
+ peer: registration, restart, relocation, exact retirement, and credential recovery.
968
+ This checks protocol interoperability; the peer is not a sandbox authority.
969
+ Shared conformance vectors cover assignment, registration,
970
+ OTP, and completion packet construction and reply decryption.
@@ -1,4 +1,7 @@
1
- import type { AccessCodeListOutput, ApiKey, ApiKeyListOutput, BillingInvoiceListOutput, BatchCreateInput, BatchCreateOutput, ClientOptions, ConnectorInstallation, ConnectorInstallationListOutput, CreateAccessCodeInput, CreateAccessCodeOutput, CreateApiKeyInput, CreateApiKeyOutput, CreateBillingCheckoutInput, CreateInput, CreateOutput, CreatePortalOptions, CreateQurlForResourceInput, CreateResourceInput, CreateWebhookInput, Customer, CheckoutSession, Domain, DomainListOutput, DomainVerifyResult, ExtendInput, Invoice, ListInput, ListOutput, ListApiKeysInput, ListBillingInvoicesInput, ListConnectorInstallationsInput, ListDomainsInput, ListWebhookDeliveriesInput, ListWebhooksInput, MintInput, MintOutput, Portal, PortalSession, ProtectUrlOptions, QURL, QurlSummary, Quota, RedeemAccessCodeInput, RedeemAccessCodeOutput, RegisterDomainInput, Resource, ResourceDetail, ResourceHandle, ResourceListInput, ResourceListOutput, RequestOptions, ResolveInput, ResolveOutput, SessionListOutput, SessionTerminateOutput, UpdateApiKeyInput, UpdateCustomerInput, UpdateInput, UpdateResourceInput, UpdateResourceQurlInput, UpdateWebhookInput, UsageCurrentPeriod, UsageDaily, Webhook, WebhookDelivery, WebhookDeliveryListOutput, WebhookEventTypeInfo, WebhookListOutput, WebhookWithSecret } from "./types.js";
1
+ import { ShareLink } from "./share.js";
2
+ import type { AccessCodeListOutput, ApiKey, ApiKeyListOutput, ApiKeyRequestScope, BillingInvoiceListOutput, BatchCreateInput, BatchCreateOutput, ClientOptions, ConnectorInstallation, ConnectorInstallationListOutput, CreateAccessCodeInput, CreateAccessCodeOutput, CreateApiKeyInput, CreateApiKeyOutput, CreateBillingCheckoutInput, CreateDelegatedQurlBatchInput, CreateDelegatedQurlBatchOptions, CreateInput, CreateOutput, CreatePortalOptions, CreateQurlForResourceInput, CreateResourceInput, CreateWebhookInput, Customer, CheckoutSession, Domain, DomainListOutput, DomainVerifyResult, DelegatedQurl, DelegatedQurlBatchAccepted, GetDelegatedQurlBatchOptions, GetDelegatedQurlBatchOutput, ExtendInput, Invoice, ListInput, ListOutput, ListApiKeysInput, ListBillingInvoicesInput, ListConnectorInstallationsInput, ListDomainsInput, ListWebhookDeliveriesInput, ListWebhooksInput, MintInput, MintOutput, Portal, PortalSession, ProtectUrlOptions, QURL, QurlSummary, Quota, RedeemAccessCodeInput, RedeemAccessCodeOutput, RegisterDomainInput, Resource, ResourceDetail, ResourceHandle, ResourceListInput, ResourceListOutput, RequestOptions, ResolveInput, ResolveOutput, SessionListOutput, SessionTerminateOutput, ShareResourceOptions, UpdateApiKeyInput, UpdateCustomerInput, UpdateInput, UpdateResourceInput, UpdateResourceQurlInput, UpdateWebhookInput, UsageCurrentPeriod, UsageDaily, Webhook, WebhookDelivery, WebhookDeliveryListOutput, WebhookEventTypeInfo, WebhookListOutput, WebhookWithSecret } from "./types.js";
3
+ /** Narrow a response scope before reusing it in a create/update request. */
4
+ export declare function isApiKeyRequestScope(scope: string): scope is ApiKeyRequestScope;
2
5
  /**
3
6
  * Shape of qurl-go's offline signed-fragment links: three or more
4
7
  * dot-separated base64url parts (<version>.<claims>.<secret>.<sig>).
@@ -77,34 +80,34 @@ export declare class QURLClient {
77
80
  */
78
81
  protectUrl(targetUrl: string, opts?: ProtectUrlOptions, requestOptions?: RequestOptions): Promise<ProtectedResource>;
79
82
  /**
80
- * Return a portal-minting handle for a stored resource id, without an API
83
+ * Return a portal-minting handle for a stored CRID, without an API
81
84
  * call.
82
85
  *
83
- * Use when you persisted a LayerV resource id and want to mint more
84
- * portals for it (qurl-go: `Client.ResourceByID`):
86
+ * Use when you persisted a LayerV CRID and want to mint more
87
+ * portals for it (qurl-go: `Client.ResourceByCRID`):
85
88
  *
86
89
  * ```ts
87
- * const resource = client.resourceById("r_demo1234567");
90
+ * const resource = client.resourceByCrid(storedCrid);
88
91
  * const portal = await resource.createPortal({ validFor: "1h" });
89
92
  * ```
90
93
  *
91
94
  * The handle carries no server metadata; use {@link getResource} when you
92
95
  * need the full resource details.
93
96
  */
94
- resourceById(id: string): ProtectedResource;
97
+ resourceByCrid(id: string): ProtectedResource;
95
98
  /** Find or create the active qURL Connector resource for an immutable slug. */
96
99
  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>;
100
+ /** Fetch a qURL Connector resource by its CRID. */
101
+ getConnectorResource(crid: string): Promise<ConnectorResource>;
99
102
  /** Fetch the single active qURL Connector resource for an immutable slug. */
100
103
  getConnectorResourceBySlug(slug: string): Promise<ConnectorResource>;
101
104
  /**
102
- * Revoke a qURL Connector resource by immutable public resource ID.
105
+ * Revoke a qURL Connector resource by CRID.
103
106
  *
104
107
  * The API does not apply Idempotency-Key replay to DELETE. After an
105
108
  * outcome-unknown failure, reconcile by ID before issuing a deliberate retry.
106
109
  */
107
- deleteConnectorResource(resourceId: string): Promise<void>;
110
+ deleteConnectorResource(crid: string): Promise<void>;
108
111
  /**
109
112
  * Mint a short-lived qURL link (a portal) for a protected resource.
110
113
  *
@@ -112,7 +115,7 @@ export declare class QURLClient {
112
115
  * reach one private resource. Recipients open `portal.link` directly and
113
116
  * need no LayerV credentials. Prefer short lifetimes such as
114
117
  * `{ validFor: "5m" }`. Accepts a {@link ProtectedResource} handle or a
115
- * public resource ID string. REST-shaped equivalents: {@link mintLink} /
118
+ * CRID string. REST-shaped equivalents: {@link mintLink} /
116
119
  * {@link createQurlForResource}.
117
120
  *
118
121
  * Duration options take a string (`"5m"`, `"24h"`; server-validated) or a
@@ -128,7 +131,7 @@ export declare class QURLClient {
128
131
  *
129
132
  * Convenience for one-off scripts (qurl-go: `Client.CreatePortalForURL`).
130
133
  * The returned resource handle is reusable for minting more portals but
131
- * carries only the resource id and the caller-supplied target URL; use
134
+ * carries the CRID, verification key, and the caller-supplied target URL; use
132
135
  * {@link protectUrl} when you need the full server-populated resource
133
136
  * metadata. Portal options match {@link createPortal}; `targetUrl` gets
134
137
  * the same malformed-URL and embedded-credentials rejection as
@@ -223,11 +226,30 @@ export declare class QURLClient {
223
226
  * ```
224
227
  */
225
228
  batchCreate(input: BatchCreateInput, options?: RequestOptions): Promise<BatchCreateOutput>;
229
+ /**
230
+ * Start one durable delegated qURL batch. The SDK sends this mutation once.
231
+ * If the result may have committed, retry explicitly with the same key and
232
+ * semantic body.
233
+ */
234
+ createDelegatedQurlBatch(input: CreateDelegatedQurlBatchInput, options: CreateDelegatedQurlBatchOptions): Promise<DelegatedQurlBatchAccepted>;
235
+ /**
236
+ * Read one delegated qURL batch state. One call performs one logical read;
237
+ * callers retain control of the poll count and total deadline.
238
+ */
239
+ getDelegatedQurlBatch(batchId: string, options?: GetDelegatedQurlBatchOptions): Promise<GetDelegatedQurlBatchOutput>;
240
+ /** Read owner-visible metadata for one delegated qURL. */
241
+ getDelegatedQurl(qurlId: string): Promise<DelegatedQurl>;
242
+ /**
243
+ * Revoke one delegated qURL. DELETE is never retried automatically. A 503
244
+ * `mutation_outcome_unknown` is safe to retry with the same qURL ID.
245
+ */
246
+ deleteDelegatedQurl(qurlId: string): Promise<void>;
226
247
  /**
227
248
  * Get a qURL resource and its access tokens.
228
249
  *
229
- * Accepts either a resource ID (`r_` prefix) or a qURL display ID (`q_`
230
- * prefix); the API resolves `q_` IDs to the parent resource automatically.
250
+ * Accepts a CRID or qURL
251
+ * display ID (`q_` prefix); the API resolves display IDs to the parent
252
+ * resource automatically.
231
253
  */
232
254
  get(id: string): Promise<QURL>;
233
255
  /**
@@ -258,17 +280,17 @@ export declare class QURLClient {
258
280
  /**
259
281
  * Delete (revoke) a qURL resource and all its access tokens.
260
282
  *
261
- * Only accepts a resource ID (`r_` prefix), not a qURL display ID (`q_`
262
- * prefix). Per the OpenAPI spec: *"Requires a resource ID (r_ prefix).
263
- * To revoke a single token, use DELETE /v1/resources/:id/qurls/:qurl_id"*.
264
- * A client-side prefix check catches the mistake before the API round-trip.
283
+ * Requires a CRID returned by the API. To revoke one qURL, use
284
+ * {@link revokeResourceQurl} instead.
285
+ *
286
+ * @throws {ValidationError} If `id` is not a canonical CRID.
265
287
  */
266
288
  delete(id: string): Promise<void>;
267
289
  /**
268
290
  * Extend a qURL's expiration.
269
291
  *
270
- * Accepts either a resource ID (`r_` prefix) or a qURL display ID (`q_`
271
- * prefix). Convenience method — delegates to {@link update} with only the
292
+ * Accepts a CRID or qURL
293
+ * display ID (`q_` prefix). Delegates to {@link update} with only the
272
294
  * expiration fields. `ExtendInput` shares its `extend_by` / `expires_at`
273
295
  * fields with `UpdateInput` but is *narrower in two ways*: (1) exactly
274
296
  * one of the two must be present (XOR via `?: never`), where `UpdateInput`
@@ -283,8 +305,9 @@ export declare class QURLClient {
283
305
  /**
284
306
  * Update a qURL — extend expiration, change description, rename tags.
285
307
  *
286
- * Accepts either a resource ID (`r_` prefix) or a qURL display ID (`q_`
287
- * prefix); the API resolves `q_` IDs to the parent resource automatically.
308
+ * Accepts a CRID or qURL
309
+ * display ID (`q_` prefix); the API resolves display IDs to the parent
310
+ * resource automatically.
288
311
  */
289
312
  update(id: string, input: UpdateInput, options?: RequestOptions): Promise<QURL>;
290
313
  /**
@@ -292,8 +315,9 @@ export declare class QURLClient {
292
315
  *
293
316
  * Portal-flow equivalent: {@link createPortal}.
294
317
  *
295
- * Accepts either a resource ID (`r_` prefix) or a qURL display ID (`q_`
296
- * prefix); the API resolves `q_` IDs to the parent resource automatically.
318
+ * Accepts a CRID or qURL
319
+ * display ID (`q_` prefix); the API resolves display IDs to the parent
320
+ * resource automatically.
297
321
  *
298
322
  * Passing `{}` or an object with all fields `null`/`undefined` is
299
323
  * equivalent to omitting the second argument: no body is sent, and the
@@ -342,6 +366,13 @@ export declare class QURLClient {
342
366
  * Portal-flow equivalent: {@link createPortal} (same endpoint).
343
367
  */
344
368
  createQurlForResource(id: string, input?: CreateQurlForResourceInput, options?: RequestOptions): Promise<CreateOutput>;
369
+ /**
370
+ * Mint a fresh, short-lived share link for an existing CRID.
371
+ * The returned link is secret and is not retrievable after this response.
372
+ * If you hold the trusted resource DER SPKI, call `ShareLink.verifyCrid()`
373
+ * before delivery to verify that the response CRID derives from that key.
374
+ */
375
+ shareResource(id: string, options?: ShareResourceOptions, requestOptions?: RequestOptions): Promise<ShareLink>;
345
376
  /** Revoke a specific qURL token on a resource. */
346
377
  revokeResourceQurl(id: string, qurlId: string): Promise<void>;
347
378
  /** Update a specific qURL token on a resource. */
@@ -505,19 +536,19 @@ export declare class QURLClient {
505
536
  /**
506
537
  * A LayerV-protected resource, bound to the client that produced it.
507
538
  *
508
- * Obtained from {@link QURLClient.protectUrl} or {@link QURLClient.resourceById} —
539
+ * Obtained from {@link QURLClient.protectUrl} or {@link QURLClient.resourceByCrid} —
509
540
  * not constructed directly. Mint short-lived access links for the resource
510
541
  * with {@link createPortal} (qurl-go: `qurl.Resource`).
511
542
  */
512
543
  export declare class ProtectedResource {
513
544
  #private;
514
- /** The LayerV public resource ID. */
515
- readonly id: string;
545
+ /** The public resource CRID. */
546
+ readonly crid: string;
516
547
  /** The private URL protected by this resource, when known. */
517
548
  readonly targetUrl?: string;
518
549
  /**
519
550
  * Full server-populated {@link Resource} metadata when the handle came
520
- * from an API response; `undefined` for {@link QURLClient.resourceById}
551
+ * from an API response; `undefined` for {@link QURLClient.resourceByCrid}
521
552
  * handles and for the one-call {@link QURLClient.createPortalForUrl} path.
522
553
  */
523
554
  readonly details?: Resource;
@@ -550,9 +581,9 @@ declare const CONNECTOR_RESOURCE_CONSTRUCTOR_TOKEN: unique symbol;
550
581
  */
551
582
  export declare class ConnectorResource {
552
583
  #private;
553
- readonly resourceId: string;
554
- /** Producer-supplied CRID carried verbatim; verify it against a trusted key before trust. */
555
- readonly crid?: string;
584
+ readonly resourcePublicKey: string;
585
+ /** Required public identifier, verified against the returned resource key. */
586
+ readonly crid: string;
556
587
  readonly connectorRoutingId: string;
557
588
  readonly knockResourceId: string;
558
589
  readonly slug: string;