@layervai/qurl 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +174 -61
  2. package/dist/cjs/client.d.ts +46 -20
  3. package/dist/cjs/client.js +338 -54
  4. package/dist/cjs/errors.d.ts +24 -3
  5. package/dist/cjs/errors.js +34 -4
  6. package/dist/cjs/index.d.ts +3 -2
  7. package/dist/cjs/index.js +5 -1
  8. package/dist/cjs/node/deployment.d.ts +1 -1
  9. package/dist/cjs/node/deployment.js +2 -2
  10. package/dist/cjs/node/native-udp.d.ts +2 -4
  11. package/dist/cjs/node/native-udp.js +8 -8
  12. package/dist/cjs/node/portal-opener.d.ts +54 -23
  13. package/dist/cjs/node/portal-opener.js +643 -340
  14. package/dist/cjs/node.d.ts +2 -2
  15. package/dist/cjs/node.js +8 -1
  16. package/dist/cjs/types.d.ts +18 -2
  17. package/dist/cjs/version.d.ts +1 -1
  18. package/dist/cjs/version.js +1 -1
  19. package/dist/esm/client.d.ts +46 -20
  20. package/dist/esm/client.d.ts.map +1 -1
  21. package/dist/esm/client.js +337 -54
  22. package/dist/esm/client.js.map +1 -1
  23. package/dist/esm/errors.d.ts +24 -3
  24. package/dist/esm/errors.d.ts.map +1 -1
  25. package/dist/esm/errors.js +32 -3
  26. package/dist/esm/errors.js.map +1 -1
  27. package/dist/esm/index.d.ts +3 -2
  28. package/dist/esm/index.d.ts.map +1 -1
  29. package/dist/esm/index.js +2 -2
  30. package/dist/esm/index.js.map +1 -1
  31. package/dist/esm/node/deployment.d.ts +1 -1
  32. package/dist/esm/node/deployment.d.ts.map +1 -1
  33. package/dist/esm/node/deployment.js +2 -2
  34. package/dist/esm/node/deployment.js.map +1 -1
  35. package/dist/esm/node/native-udp.d.ts +2 -4
  36. package/dist/esm/node/native-udp.d.ts.map +1 -1
  37. package/dist/esm/node/native-udp.js +5 -5
  38. package/dist/esm/node/native-udp.js.map +1 -1
  39. package/dist/esm/node/portal-opener.d.ts +54 -23
  40. package/dist/esm/node/portal-opener.d.ts.map +1 -1
  41. package/dist/esm/node/portal-opener.js +636 -340
  42. package/dist/esm/node/portal-opener.js.map +1 -1
  43. package/dist/esm/node.d.ts +2 -2
  44. package/dist/esm/node.d.ts.map +1 -1
  45. package/dist/esm/node.js +1 -1
  46. package/dist/esm/node.js.map +1 -1
  47. package/dist/esm/types.d.ts +18 -2
  48. package/dist/esm/types.d.ts.map +1 -1
  49. package/dist/esm/version.d.ts +1 -1
  50. package/dist/esm/version.js +1 -1
  51. package/package.json +4 -4
@@ -1,18 +1,19 @@
1
1
  import { randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { setMaxListeners } from "node:events";
2
3
  import { NHP_MAX_BODY_SIZE, NHP_TYPE_ACK, NHP_TYPE_COOKIE } from "./nhp-wire.js";
3
4
  import { fingerprintKey, loadPortalDeployment, } from "./deployment.js";
4
- import { nativeKnock, NATIVE_DEFAULT_MAX_ADDRESSES, NATIVE_DEFAULT_TIMEOUT_MS, } from "./native-udp.js";
5
+ import { nativeKnock } from "./native-udp.js";
5
6
  import { isStrictJsonObject, parseStrictJson } from "./strict-json.js";
6
7
  import { verifyQv2Link } from "./qv2.js";
7
8
  const SESSION_COOKIE = "qurl_vsession";
8
9
  const MAX_REDIRECT_REQUESTS = 10;
9
- const RENEWAL_NUMERATOR = 3;
10
- const RENEWAL_DENOMINATOR = 4;
11
- const MAX_BACKGROUND_RENEWAL_ATTEMPTS = 4;
12
- const MIN_BACKGROUND_RETRY_MS = 100;
13
- // qurl-go bounds the full portal open at 15 seconds. Larger caller-selected
14
- // per-address budgets remain usable, but every DNS plus UDP sequence is finite.
15
10
  const DEFAULT_OPEN_TIMEOUT_MS = 15_000;
11
+ const MAX_OPEN_TIMEOUT_MS = 60_000;
12
+ const MIN_RENEWAL_GAP_MS = 5_000;
13
+ const MIN_RENEWAL_LEAD_MS = 5_000;
14
+ const MAX_RENEWAL_LEAD_MS = 60_000;
15
+ const INITIAL_RETRY_MS = 500;
16
+ const MAX_RETRY_MS = 2_000;
16
17
  // Node changes a larger setTimeout delay to 1 ms. Clamp long grants so an
17
18
  // authenticated but unexpected lifetime cannot create a hot renewal loop.
18
19
  const MAX_TIMER_DELAY_MS = 2_147_483_647;
@@ -44,6 +45,48 @@ export class PortalStateError extends Error {
44
45
  this.name = "PortalStateError";
45
46
  }
46
47
  }
48
+ export class PortalOpenerNotStartedError extends PortalStateError {
49
+ constructor() {
50
+ super("portal opener has not started");
51
+ this.name = "PortalOpenerNotStartedError";
52
+ }
53
+ }
54
+ export class PortalOpenerNotReadyError extends PortalStateError {
55
+ constructor(options) {
56
+ super("portal opener has no active session", options);
57
+ this.name = "PortalOpenerNotReadyError";
58
+ }
59
+ }
60
+ export class PortalOpenerClosedError extends PortalStateError {
61
+ constructor(options) {
62
+ super("portal opener is closed", options);
63
+ this.name = "PortalOpenerClosedError";
64
+ }
65
+ }
66
+ export class PortalOpenTimeoutError extends PortalStateError {
67
+ constructor() {
68
+ super("native NHP open exceeded its overall deadline");
69
+ this.name = "PortalOpenTimeoutError";
70
+ }
71
+ }
72
+ export class PortalTargetChangedError extends PortalStateError {
73
+ constructor() {
74
+ super("portal renewal changed the authenticated target");
75
+ this.name = "PortalTargetChangedError";
76
+ }
77
+ }
78
+ export class PortalRedirectError extends PortalStateError {
79
+ constructor(message = "protected request redirect refused", options) {
80
+ super(message, options);
81
+ this.name = "PortalRedirectError";
82
+ }
83
+ }
84
+ export class PortalTooManyRedirectsError extends PortalStateError {
85
+ constructor() {
86
+ super(`portal fetch stopped at the ${MAX_REDIRECT_REQUESTS}-request redirect limit`);
87
+ this.name = "PortalTooManyRedirectsError";
88
+ }
89
+ }
47
90
  export class PortalBusyError extends PortalStateError {
48
91
  constructor() {
49
92
  super("qURL platform is busy; retry start later");
@@ -56,11 +99,20 @@ export class PortalInvalidReplyError extends PortalStateError {
56
99
  this.name = "PortalInvalidReplyError";
57
100
  }
58
101
  }
102
+ const defaultContentFetch = (input, init) => {
103
+ // Resolve lazily so importing the Node entry point does not require global
104
+ // Fetch when a consumer supplies its own protected-content implementation.
105
+ const implementation = globalThis.fetch;
106
+ if (typeof implementation !== "function") {
107
+ return Promise.reject(new PortalConfigurationError("portal fetch requires global Fetch or CreatePortalOpenerOptions.fetch"));
108
+ }
109
+ return implementation.call(globalThis, input, init);
110
+ };
59
111
  const defaultRuntime = {
60
112
  knock: nativeKnock,
61
- fetch: globalThis.fetch.bind(globalThis),
113
+ fetch: defaultContentFetch,
62
114
  nowNanos: () => process.hrtime.bigint(),
63
- randomFraction: () => randomBytes(2).readUInt16BE(0) / 0xffff,
115
+ nowEpochMs: () => Date.now(),
64
116
  setTimer: (callback, delayMs) => setTimeout(callback, delayMs),
65
117
  clearTimer: (timer) => clearTimeout(timer),
66
118
  setDeadlineTimer: (callback, delayMs) => setTimeout(callback, delayMs),
@@ -73,291 +125,323 @@ export function createPortalOpenerWithRuntime(options, runtime) {
73
125
  if (!options || typeof options !== "object") {
74
126
  throw new PortalConfigurationError("native portal opener options are required");
75
127
  }
76
- if (options.transport !== "native-only") {
77
- throw new PortalConfigurationError("native portal opener transport must be native-only");
78
- }
79
- if (typeof options.qurl !== "string" || options.qurl === "") {
128
+ if (typeof options.qurl !== "string" || options.qurl.trim() === "") {
80
129
  throw new PortalConfigurationError("native portal opener qurl must be a non-empty string");
81
130
  }
82
- if (options.timeoutMs !== undefined &&
83
- (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0 || options.timeoutMs > 60_000)) {
84
- throw new PortalConfigurationError("native portal opener timeoutMs must be from 1 to 60000");
131
+ if (options.openTimeoutMs !== undefined &&
132
+ (!Number.isFinite(options.openTimeoutMs) ||
133
+ options.openTimeoutMs < 1 ||
134
+ options.openTimeoutMs > MAX_OPEN_TIMEOUT_MS)) {
135
+ throw new PortalConfigurationError("native portal opener openTimeoutMs must be from 1 to 60000");
85
136
  }
86
- if (options.maxAddresses !== undefined &&
87
- (!Number.isInteger(options.maxAddresses) ||
88
- options.maxAddresses < 1 ||
89
- options.maxAddresses > 16)) {
90
- throw new PortalConfigurationError("native portal opener maxAddresses must be from 1 to 16");
91
- }
92
- let deployment;
93
- try {
94
- deployment = loadPortalDeployment(options.deployment);
95
- }
96
- catch (error) {
97
- throw new PortalConfigurationError("native qURL deployment trust is invalid", {
98
- cause: error,
99
- });
100
- }
101
- let link;
102
- try {
103
- link = verifyQv2Link(options.qurl, deployment.issuers);
104
- }
105
- catch (error) {
106
- throw new PortalVerificationError("native qURL credential validation failed", {
107
- cause: error,
108
- });
109
- }
110
- return constructVerifiedPortalOpener(options, link, deployment, runtime);
111
- }
112
- function constructVerifiedPortalOpener(options, link, deployment, runtime) {
113
- let retainDevicePrivateKey = false;
114
- try {
115
- const cell = deployment.cells.get(fingerprintKey(link.claims.cellPublicKey));
116
- if (!cell) {
117
- throw new PortalConfigurationError("verified qURL names a cell outside the native catalog");
118
- }
119
- // The 64-bit fingerprint is only an efficient map index. The signed cell
120
- // identity must still match the exact deployment key used by Noise.
121
- if (!timingSafeEqual(cell.serverPublicKey, link.claims.cellPublicKey)) {
122
- throw new PortalConfigurationError("deployment cell key does not match the signed cell key");
123
- }
124
- const opener = new NativePortalOpener(options, link, cell, runtime);
125
- retainDevicePrivateKey = true;
126
- return opener;
127
- }
128
- finally {
129
- if (!retainDevicePrivateKey)
130
- link.devicePrivateKey.fill(0);
137
+ if (options.fetch !== undefined && typeof options.fetch !== "function") {
138
+ throw new PortalConfigurationError("native portal opener fetch must be a function");
131
139
  }
140
+ return new NativePortalOpener(options, runtime);
132
141
  }
133
142
  class NativePortalOpener {
134
- #link;
135
- #cell;
136
143
  #runtime;
137
- #exchangeOptions;
144
+ #fetch;
145
+ #explicitDeployment;
138
146
  #openTimeoutMs;
147
+ #qurl;
148
+ #resolvedDeployment;
139
149
  #sessionSecret;
140
150
  #grant;
141
151
  #boundResourceUrl;
152
+ #startPromise;
142
153
  #openPromise;
143
154
  #openController;
144
155
  #closePromise;
145
156
  #renewalTimer;
146
- #renewalError;
147
- #renewalFailure;
148
- #backgroundAttempts = 0;
149
- #renewing = false;
150
- #closed = false;
151
- constructor(options, link, cell, runtime) {
152
- this.#link = link;
153
- this.#cell = cell;
157
+ #renewalCycle;
158
+ #lifecycleController = new AbortController();
159
+ #state = "new";
160
+ #startingRecovery = false;
161
+ #expiresAtEpochMs;
162
+ #renewAtEpochMs;
163
+ #lastOpenSucceededAt;
164
+ #lastFailureClass = "";
165
+ #consecutiveFailures = 0;
166
+ constructor(options, runtime) {
167
+ this.#qurl = options.qurl;
168
+ this.#explicitDeployment = options.deployment;
154
169
  this.#runtime = runtime;
155
- this.#exchangeOptions = { timeoutMs: options.timeoutMs, maxAddresses: options.maxAddresses };
156
- const attemptTimeoutMs = options.timeoutMs ?? NATIVE_DEFAULT_TIMEOUT_MS;
157
- const maximumAddresses = options.maxAddresses ?? NATIVE_DEFAULT_MAX_ADDRESSES;
158
- this.#openTimeoutMs = Math.max(DEFAULT_OPEN_TIMEOUT_MS, attemptTimeoutMs * (maximumAddresses + 1));
159
- this.#sessionSecret = randomBytes(32);
160
- try {
161
- // A locally valid qv2 credential can still be too large for one native
162
- // NHP packet. Reject it before start or DNS and wipe the sizing copy.
163
- const probe = this.#knockBody();
164
- probe.fill(0);
165
- }
166
- catch (error) {
167
- this.#sessionSecret.fill(0);
168
- throw error;
169
- }
170
+ this.#fetch = options.fetch ?? runtime.fetch;
171
+ this.#openTimeoutMs = options.openTimeoutMs ?? DEFAULT_OPEN_TIMEOUT_MS;
172
+ // Native Fetch attaches one abort listener per in-flight request. This
173
+ // signal is private and intentionally shared so close can stop all work.
174
+ setMaxListeners(0, this.#lifecycleController.signal);
170
175
  }
171
176
  async start(options = {}) {
172
177
  this.#requireOpen();
173
- if (this.#grant &&
174
- this.#runtime.nowNanos() < this.#grant.expiresAtNanos &&
175
- !this.#renewalFailure) {
178
+ if (this.#state === "running" && this.#grant && this.#isGrantReady(this.#grant))
176
179
  return;
177
- }
178
180
  if (options.signal?.aborted) {
179
181
  throw options.signal.reason ?? new DOMException("Aborted", "AbortError");
180
182
  }
181
- const sharedOpen = this.#openPromise !== undefined;
182
- if (!sharedOpen && this.#renewalTimer) {
183
- // Explicit lifecycle recovery takes ownership from the pending retry.
184
- // Cancel it before resetting the bounded background-attempt counter.
185
- this.#runtime.clearTimer(this.#renewalTimer);
186
- this.#renewalTimer = undefined;
183
+ if (this.#state === "running" && !this.#isGrantReady(this.#grant) && this.#renewalCycle) {
184
+ await waitForPromise(this.#renewalCycle, options.signal);
185
+ return this.start(options);
187
186
  }
187
+ const sharedStart = this.#startPromise;
188
+ if (sharedStart)
189
+ return waitForPromise(sharedStart, options.signal);
190
+ let attempt;
191
+ attempt = this.#runStart(options.signal).finally(() => {
192
+ if (this.#startPromise === attempt)
193
+ this.#startPromise = undefined;
194
+ });
195
+ this.#startPromise = attempt;
196
+ return attempt;
197
+ }
198
+ async #runStart(signal) {
199
+ // A pending renewal wait always has a ready grant, so public start() returns
200
+ // before reaching this explicit-open path. Keep that invariant if start()
201
+ // admission rules change: clearing its timer would strand #renewalCycle.
202
+ if (this.#renewalTimer)
203
+ this.#runtime.clearTimer(this.#renewalTimer);
204
+ this.#renewalTimer = undefined;
205
+ const recovery = this.#state === "degraded" || this.#state === "running";
206
+ this.#startingRecovery = recovery;
207
+ this.#state = "starting";
188
208
  try {
189
- await this.#openSingleFlight(options.signal);
209
+ await this.#openSingleFlight(signal);
190
210
  }
191
211
  catch (error) {
192
- // A caller waiting on an existing background open observes that open's
193
- // own health update. An explicit recovery owner replaces stale
194
- // diagnostics and re-arms bounded background recovery while the old
195
- // grant is usable. The timer is asynchronous; start never sleeps.
196
- if (!sharedOpen && this.#grant && !this.#closed) {
197
- this.#backgroundAttempts = 0;
198
- this.#handleRenewalFailure(error);
212
+ if (this.#isClosed())
213
+ throw new PortalOpenerClosedError({ cause: error });
214
+ this.#clearGrant();
215
+ if (signal?.aborted && error === signal.reason) {
216
+ this.#state = recovery ? "degraded" : "new";
217
+ }
218
+ else {
219
+ this.#state = "degraded";
220
+ this.#recordFailure(error);
199
221
  }
200
222
  throw error;
201
223
  }
202
- }
203
- async fetch(input = {}, options = {}) {
204
- this.#requireOpen();
205
- if (options.redirects !== undefined &&
206
- options.redirects !== "follow" &&
207
- options.redirects !== "error") {
208
- throw new PortalStateError("portal fetch redirects must be follow or error");
209
- }
210
- const grant = this.#grant;
211
- if (!grant)
212
- throw new PortalStateError("portal opener must be started before fetch");
213
- if (this.#runtime.nowNanos() >= grant.expiresAtNanos) {
214
- throw new PortalStateError("portal admission expired; call start to open it again", {
215
- cause: this.#renewalError,
216
- });
224
+ finally {
225
+ this.#startingRecovery = false;
217
226
  }
218
- // A successful renewal or close wipes the mutable grant token. Keep the
219
- // unavoidable request string local to this fetch so every redirect leg has
220
- // one stable credential snapshot.
221
- const sessionToken = grant.token.toString("ascii");
222
- const init = typeof input === "function" ? input(new URL(grant.resourceUrl)) : input;
223
- if (!init || typeof init !== "object") {
224
- throw new PortalStateError("portal request builder must return RequestInit");
225
- }
226
- if (Object.prototype.hasOwnProperty.call(init, "redirect") && init.redirect !== undefined) {
227
- throw new PortalStateError("portal fetch owns redirect handling; redirect overrides are not allowed");
228
- }
229
- let currentUrl = grant.resourceUrl;
230
- let method = (init.method ?? "GET").toUpperCase();
231
- let body = init.body;
232
- let headers = new Headers(init.headers);
233
- for (let requestCount = 1;; requestCount++) {
234
- this.#requireOpen();
235
- const requestHeaders = authorizeHeaders(headers, sessionToken);
236
- const response = await this.#runtime.fetch(currentUrl, {
237
- ...init,
238
- method,
239
- body,
240
- headers: requestHeaders,
241
- redirect: "manual",
242
- });
243
- if (response.redirected === true || !responseMatchesRequestUrl(response.url, currentUrl)) {
244
- await discardResponseBody(response);
245
- throw new PortalStateError("portal fetch refused a response that bypassed its manual redirect policy");
227
+ }
228
+ fetch(input = {}, options = {}) {
229
+ return this.#dispatch([], false, input, options);
230
+ }
231
+ fetchDescendant(pathSegments, input = {}, options = {}) {
232
+ return this.#dispatch(pathSegments, true, input, options);
233
+ }
234
+ async #dispatch(pathSegments, descendant, input, options) {
235
+ let body = undefined;
236
+ try {
237
+ // A direct RequestInit already belongs to this call. Capture its body
238
+ // before any option or lifecycle rejection so every pre-fetch exit can
239
+ // release a caller-owned stream. Builders remain lazy until a grant is
240
+ // ready and therefore have no body to release on earlier exits.
241
+ if (typeof input !== "function" && input && typeof input === "object") {
242
+ body = input.body;
246
243
  }
247
- if (![301, 302, 303, 307, 308].includes(response.status))
248
- return response;
249
- if (options.redirects === "error") {
250
- await discardResponseBody(response);
251
- throw new PortalStateError("portal fetch refused a redirect for a fixed signed request");
244
+ let descendantSegments;
245
+ if (descendant) {
246
+ validatePortalDescendantSegments(pathSegments);
247
+ descendantSegments = pathSegments;
252
248
  }
253
- const location = response.headers.get("location");
254
- if (!location)
255
- return response;
256
- if (requestCount >= MAX_REDIRECT_REQUESTS) {
257
- await discardResponseBody(response);
258
- throw new PortalStateError("portal fetch stopped at the 10-request redirect limit");
249
+ if (this.#state === "closed")
250
+ throw new PortalOpenerClosedError();
251
+ if (options.redirects !== undefined &&
252
+ options.redirects !== "follow" &&
253
+ options.redirects !== "error") {
254
+ throw new PortalConfigurationError("portal fetch redirects must be follow or error");
259
255
  }
260
- let next;
261
- try {
262
- next = new URL(location, currentUrl);
256
+ if (this.#state === "new" || (this.#state === "starting" && !this.#startingRecovery)) {
257
+ throw new PortalOpenerNotStartedError();
263
258
  }
264
- catch {
265
- await discardResponseBody(response);
266
- throw new PortalStateError("portal fetch refused an invalid redirect target");
259
+ const grant = this.#grant;
260
+ if (this.#state !== "running" || !this.#isGrantReady(grant)) {
261
+ throw new PortalOpenerNotReadyError();
267
262
  }
268
- let nextOrigin;
269
- try {
270
- nextOrigin = normalizedHttpsOrigin(next);
263
+ // A successful renewal or close wipes the mutable grant token. Keep the
264
+ // unavoidable request string local to this fetch so every redirect leg has
265
+ // one stable credential snapshot.
266
+ const sessionToken = grant.token.toString("ascii");
267
+ const requestTarget = descendantSegments
268
+ ? appendPortalDescendantSegments(new URL(grant.resourceUrl), descendantSegments)
269
+ : new URL(grant.resourceUrl);
270
+ const requestTargetRaw = requestTarget.href;
271
+ const init = typeof input === "function" ? input(new URL(requestTargetRaw)) : input;
272
+ if (!init || typeof init !== "object") {
273
+ throw new PortalConfigurationError("portal request builder must return RequestInit");
271
274
  }
272
- catch {
273
- await discardResponseBody(response);
274
- throw new PortalStateError("portal fetch refused an invalid redirect target");
275
+ if (typeof input === "function")
276
+ body = init.body;
277
+ if (Object.prototype.hasOwnProperty.call(init, "redirect") && init.redirect !== undefined) {
278
+ throw new PortalConfigurationError("portal fetch owns redirect handling; redirect overrides are not allowed");
275
279
  }
276
- if (nextOrigin !== grant.origin) {
277
- await discardResponseBody(response);
278
- throw new PortalStateError("portal fetch refused a redirect outside the authenticated origin");
279
- }
280
- if ([301, 302, 303].includes(response.status) && method !== "GET" && method !== "HEAD") {
281
- method = "GET";
282
- body = undefined;
283
- headers = new Headers(headers);
284
- headers.delete("content-length");
285
- headers.delete("content-type");
280
+ // The builder receives a disposable URL copy. Pin the actual request URL
281
+ // and its Fetch-derived Host to the trusted value after the builder runs.
282
+ let currentUrl = requestTargetRaw;
283
+ let method = (init.method ?? "GET").toUpperCase();
284
+ let headers = new Headers(init.headers);
285
+ if (headers.has("host")) {
286
+ throw new PortalConfigurationError("portal fetch owns the Host derived from the authenticated target");
286
287
  }
287
- else if ((response.status === 307 || response.status === 308) && body !== undefined) {
288
- if (!isReplayableBody(body))
288
+ // Use the lifecycle signal directly when there is no caller signal. When
289
+ // both are present, native composition avoids listener fanout and remains
290
+ // active after headers arrive, preserving Fetch response-body semantics.
291
+ const requestSignal = init.signal
292
+ ? AbortSignal.any([init.signal, this.#lifecycleController.signal])
293
+ : this.#lifecycleController.signal;
294
+ for (let requestCount = 1;; requestCount++) {
295
+ this.#requireOpen();
296
+ throwIfAborted(requestSignal);
297
+ const requestHeaders = authorizeHeaders(headers, sessionToken);
298
+ const response = await this.#fetch.call(globalThis, currentUrl, {
299
+ ...init,
300
+ method,
301
+ body,
302
+ headers: requestHeaders,
303
+ redirect: "manual",
304
+ signal: requestSignal,
305
+ });
306
+ if (requestSignal.aborted) {
307
+ await discardResponseBody(response);
308
+ throwIfAborted(requestSignal);
309
+ }
310
+ if (response.redirected === true || !responseMatchesRequestUrl(response.url, currentUrl)) {
311
+ await discardResponseBody(response);
312
+ throw new PortalRedirectError("portal fetch refused a response that bypassed its manual redirect policy");
313
+ }
314
+ if (![301, 302, 303, 307, 308].includes(response.status))
315
+ return response;
316
+ const location = response.headers.get("location");
317
+ if (!location)
289
318
  return response;
319
+ if (options.redirects === "error") {
320
+ await discardResponseBody(response);
321
+ throw new PortalRedirectError("portal fetch refused a redirect for a fixed signed request");
322
+ }
323
+ if (requestCount >= MAX_REDIRECT_REQUESTS) {
324
+ await discardResponseBody(response);
325
+ throw new PortalTooManyRedirectsError();
326
+ }
327
+ let next;
328
+ try {
329
+ next = new URL(location, currentUrl);
330
+ }
331
+ catch {
332
+ await discardResponseBody(response);
333
+ throw new PortalRedirectError("portal fetch refused an invalid redirect target");
334
+ }
335
+ // Fragments are never part of an HTTP request target, and Fetch omits
336
+ // them from Response.url. Normalize before the next request and its
337
+ // manual-redirect bypass check.
338
+ next.hash = "";
339
+ let nextOrigin;
340
+ try {
341
+ nextOrigin = normalizedHttpsOrigin(next);
342
+ }
343
+ catch {
344
+ await discardResponseBody(response);
345
+ throw new PortalRedirectError("portal fetch refused an invalid redirect target");
346
+ }
347
+ if (nextOrigin !== grant.origin) {
348
+ await discardResponseBody(response);
349
+ throw new PortalRedirectError("portal fetch refused a redirect outside the authenticated origin");
350
+ }
351
+ if ([301, 302, 303].includes(response.status) && method !== "GET" && method !== "HEAD") {
352
+ method = "GET";
353
+ body = undefined;
354
+ headers = new Headers(headers);
355
+ headers.delete("content-length");
356
+ headers.delete("content-type");
357
+ }
358
+ else if ((response.status === 307 || response.status === 308) && body !== undefined) {
359
+ if (!isReplayableBody(body))
360
+ return response;
361
+ }
362
+ await discardResponseBody(response);
363
+ currentUrl = next.href;
290
364
  }
291
- await discardResponseBody(response);
292
- currentUrl = next.href;
365
+ }
366
+ catch (error) {
367
+ await discardRequestBody(body);
368
+ if (error instanceof PortalOpenerClosedError)
369
+ throw error;
370
+ if (this.#isClosed())
371
+ throw new PortalOpenerClosedError({ cause: error });
372
+ throw error;
293
373
  }
294
374
  }
295
375
  health() {
296
- if (this.#closed)
297
- return { state: "closed" };
298
- if (this.#openPromise && !this.#grant)
299
- return { state: "starting" };
300
- const grant = this.#grant;
301
- if (!grant)
302
- return { state: "idle" };
303
- const remainingNanos = grant.expiresAtNanos - this.#runtime.nowNanos();
304
- if (remainingNanos <= 0n) {
305
- return {
306
- state: "expired",
307
- expiresInMs: 0,
308
- backgroundAttempts: this.#backgroundAttempts,
309
- renewalFailure: this.#renewalFailure,
310
- };
311
- }
312
- const expiresInMs = Number((remainingNanos + 999999n) / 1000000n);
376
+ const ready = this.#state === "running" && this.#isGrantReady(this.#grant);
377
+ let state;
378
+ if (this.#state === "closed")
379
+ state = "closed";
380
+ else if (this.#state === "new")
381
+ state = "new";
382
+ else if (this.#state === "starting" && !this.#startingRecovery)
383
+ state = "starting";
384
+ else if (ready)
385
+ state = "ready";
386
+ else
387
+ state = "degraded";
313
388
  return {
314
- state: this.#renewing ? "renewing" : this.#renewalFailure ? "degraded" : "healthy",
315
- expiresInMs,
316
- backgroundAttempts: this.#backgroundAttempts,
317
- renewalFailure: this.#renewalFailure,
389
+ state,
390
+ ready,
391
+ expiresAt: dateFromEpoch(this.#expiresAtEpochMs),
392
+ renewAt: dateFromEpoch(this.#renewAtEpochMs),
393
+ lastOpenSucceededAt: dateFromEpoch(this.#lastOpenSucceededAt),
394
+ lastFailureClass: this.#lastFailureClass,
395
+ consecutiveFailures: this.#consecutiveFailures,
318
396
  };
319
397
  }
320
398
  close() {
321
399
  if (this.#closePromise)
322
400
  return this.#closePromise;
323
- this.#closed = true;
401
+ this.#state = "closed";
324
402
  if (this.#renewalTimer)
325
403
  this.#runtime.clearTimer(this.#renewalTimer);
326
404
  this.#renewalTimer = undefined;
405
+ this.#expiresAtEpochMs = undefined;
406
+ this.#renewAtEpochMs = undefined;
407
+ this.#lastOpenSucceededAt = undefined;
408
+ this.#lastFailureClass = "";
409
+ this.#consecutiveFailures = 0;
410
+ this.#startingRecovery = false;
411
+ this.#lifecycleController.abort(new PortalOpenerClosedError());
412
+ this.#openController?.abort(new PortalOpenerClosedError());
327
413
  const active = this.#openPromise;
328
- this.#openController?.abort(new PortalStateError("portal opener closed while a native open was in progress"));
414
+ const renewal = this.#renewalCycle;
329
415
  const closing = (async () => {
330
- try {
331
- await active;
332
- }
333
- catch {
334
- // The initiating caller receives this error. close is idempotent cleanup.
335
- }
336
- finally {
337
- this.#grant?.token.fill(0);
338
- this.#grant = undefined;
339
- this.#boundResourceUrl = undefined;
340
- this.#link.devicePrivateKey.fill(0);
341
- this.#sessionSecret.fill(0);
342
- this.#renewalError = undefined;
343
- this.#renewalFailure = undefined;
344
- this.#backgroundAttempts = 0;
345
- this.#renewing = false;
346
- }
416
+ await Promise.allSettled([active, renewal].filter((item) => item !== undefined));
417
+ this.#clearGrant();
418
+ this.#boundResourceUrl = undefined;
419
+ this.#qurl = "";
420
+ this.#sessionSecret?.fill(0);
421
+ this.#sessionSecret = undefined;
422
+ this.#resolvedDeployment = undefined;
347
423
  })();
348
424
  this.#closePromise = closing;
349
425
  return closing;
350
426
  }
351
- #openSingleFlight(signal) {
427
+ #openSingleFlight(signal, expiresAtNanos) {
352
428
  this.#requireOpen();
353
429
  const active = this.#openPromise;
354
430
  if (active)
355
431
  return waitForPromise(active, signal);
356
432
  const controller = new AbortController();
357
433
  const removeAbortListener = linkAbortSignal(signal, controller);
434
+ const remainingMs = expiresAtNanos === undefined
435
+ ? this.#openTimeoutMs
436
+ : nanosToCeilingMilliseconds(expiresAtNanos - this.#runtime.nowNanos());
437
+ const deadlineMs = Math.min(this.#openTimeoutMs, remainingMs);
438
+ if (deadlineMs <= 0) {
439
+ removeAbortListener();
440
+ return Promise.reject(new PortalOpenTimeoutError());
441
+ }
358
442
  const deadline = this.#runtime.setDeadlineTimer(() => {
359
- controller.abort(new Error("native NHP open exceeded its overall deadline"));
360
- }, this.#openTimeoutMs);
443
+ controller.abort(new PortalOpenTimeoutError());
444
+ }, deadlineMs);
361
445
  deadline.unref?.();
362
446
  const current = this.#performOpen(controller.signal).finally(() => {
363
447
  this.#runtime.clearDeadlineTimer(deadline);
@@ -373,111 +457,228 @@ class NativePortalOpener {
373
457
  }
374
458
  async #performOpen(signal) {
375
459
  this.#requireOpen();
376
- const body = this.#knockBody();
460
+ const deployment = this.#loadDeployment();
461
+ const link = this.#verifyLink(deployment);
377
462
  try {
463
+ const cell = validatedCellForLink(link, deployment);
464
+ this.#sessionSecret ??= randomBytes(32);
465
+ const body = this.#knockBody(link);
378
466
  // Start the local validity bound before DNS and UDP I/O. The cell starts
379
467
  // its grant no earlier than this, so this client never assumes extra RTT.
380
- const openedAtNanos = this.#runtime.nowNanos();
381
- const reply = await this.#runtime.knock(this.#cell, this.#link.devicePrivateKey, body, {
382
- ...this.#exchangeOptions,
383
- signal,
384
- });
468
+ const startedAtNanos = this.#runtime.nowNanos();
469
+ const startedAtEpochMs = this.#runtime.nowEpochMs();
385
470
  try {
386
- if (this.#closed) {
387
- throw new PortalStateError("portal opener was closed while start was in progress");
471
+ const reply = await this.#runtime.knock(cell, link.devicePrivateKey, body, {
472
+ signal,
473
+ });
474
+ try {
475
+ this.#requireOpen();
476
+ if (reply.type === NHP_TYPE_COOKIE)
477
+ throw new PortalBusyError();
478
+ if (reply.type !== NHP_TYPE_ACK) {
479
+ throw new PortalInvalidReplyError("native NHP returned an unexpected reply type");
480
+ }
481
+ const openedAtNanos = this.#runtime.nowNanos();
482
+ const openedAtEpochMs = this.#runtime.nowEpochMs();
483
+ const grant = parseGrant(reply.body, startedAtNanos, startedAtEpochMs, openedAtNanos, openedAtEpochMs);
484
+ if (this.#boundResourceUrl !== undefined &&
485
+ grant.resourceUrl !== this.#boundResourceUrl) {
486
+ grant.token.fill(0);
487
+ throw new PortalTargetChangedError();
488
+ }
489
+ if (this.#runtime.nowNanos() >= grant.expiresAtNanos) {
490
+ grant.token.fill(0);
491
+ throw new PortalInvalidReplyError("native NHP admission expired before it became usable");
492
+ }
493
+ this.#resolvedDeployment ??= deployment;
494
+ this.#installGrant(grant);
388
495
  }
389
- if (reply.type === NHP_TYPE_COOKIE) {
390
- throw new PortalBusyError();
496
+ finally {
497
+ // ACK and deny bodies can contain a bearer. Wipe them on every parse path.
498
+ reply.body.fill(0);
391
499
  }
392
- if (reply.type !== NHP_TYPE_ACK) {
393
- throw new PortalInvalidReplyError("native NHP returned an unexpected reply type");
394
- }
395
- const grant = parseGrant(reply.body, openedAtNanos);
396
- if (this.#boundResourceUrl !== undefined && grant.resourceUrl !== this.#boundResourceUrl) {
397
- grant.token.fill(0);
398
- throw new PortalInvalidReplyError("native NHP renewal changed the authenticated resource URL");
399
- }
400
- if (this.#runtime.nowNanos() >= grant.expiresAtNanos) {
401
- grant.token.fill(0);
402
- throw new PortalStateError("native NHP admission expired before it became usable");
403
- }
404
- const old = this.#grant;
405
- this.#grant = grant;
406
- this.#boundResourceUrl ??= grant.resourceUrl;
407
- old?.token.fill(0);
408
- this.#renewalError = undefined;
409
- this.#renewalFailure = undefined;
410
- this.#backgroundAttempts = 0;
411
- this.#renewing = false;
412
- this.#scheduleRenewal(grant);
413
500
  }
414
501
  finally {
415
- // ACK and deny bodies can contain a bearer. Wipe them on every parse path.
416
- reply.body.fill(0);
502
+ body.fill(0);
417
503
  }
418
504
  }
419
505
  finally {
420
- body.fill(0);
506
+ link.devicePrivateKey.fill(0);
421
507
  }
422
508
  }
423
509
  #scheduleRenewal(grant) {
424
510
  if (this.#renewalTimer)
425
511
  this.#runtime.clearTimer(this.#renewalTimer);
426
- // The grant lifetime starts before DNS and UDP I/O. Base renewal on the
427
- // actual remaining lifetime so exchange latency cannot move renewal past
428
- // the conservative local expiry boundary.
429
- const remainingNanos = grant.expiresAtNanos - this.#runtime.nowNanos();
430
- const remainingMs = remainingNanos <= 0n ? 0 : Number(remainingNanos / 1000000n);
431
- const delayMs = Math.min(MAX_TIMER_DELAY_MS, Math.max(1, Math.floor((remainingMs * RENEWAL_NUMERATOR) / RENEWAL_DENOMINATOR)));
432
- this.#renewalTimer = this.#runtime.setTimer(() => {
433
- this.#renewalTimer = undefined;
434
- if (this.#closed)
512
+ const delayMs = Math.min(MAX_TIMER_DELAY_MS, Math.max(0, nanosToCeilingMilliseconds(grant.renewAtNanos - this.#runtime.nowNanos())));
513
+ const timer = this.#runtime.setTimer(() => {
514
+ if (this.#renewalTimer === timer)
515
+ this.#renewalTimer = undefined;
516
+ if (this.#state !== "running" || this.#grant !== grant)
435
517
  return;
436
- this.#renewing = true;
437
- this.#backgroundAttempts++;
438
- void this.#openSingleFlight().catch((error) => this.#handleRenewalFailure(error));
518
+ if (this.#runtime.nowNanos() < grant.renewAtNanos) {
519
+ this.#scheduleRenewal(grant);
520
+ return;
521
+ }
522
+ const cycle = this.#runRenewalCycle(grant)
523
+ .catch((error) => {
524
+ // A background task must never create an unhandled rejection. An
525
+ // unexpected internal failure loses readiness instead of risking the
526
+ // host process or leaving an unsupervised running state.
527
+ if (this.#isClosed() || this.#grant !== grant)
528
+ return;
529
+ this.#recordFailure(error);
530
+ this.#expireGrant(grant);
531
+ })
532
+ .finally(() => {
533
+ if (this.#renewalCycle === cycle)
534
+ this.#renewalCycle = undefined;
535
+ });
536
+ this.#renewalCycle = cycle;
439
537
  }, delayMs);
440
- this.#renewalTimer.unref?.();
538
+ this.#renewalTimer = timer;
539
+ timer.unref?.();
441
540
  }
442
- #handleRenewalFailure(error) {
443
- if (this.#closed)
444
- return;
445
- this.#recordRenewalFailure(error);
446
- if (this.#backgroundAttempts >= MAX_BACKGROUND_RENEWAL_ATTEMPTS)
447
- return;
448
- const grant = this.#grant;
449
- if (!grant)
450
- return;
451
- const delayMs = backgroundRetryDelay(grant.expiresAtNanos - this.#runtime.nowNanos(), MAX_BACKGROUND_RENEWAL_ATTEMPTS - this.#backgroundAttempts, this.#runtime.randomFraction());
452
- if (delayMs === undefined)
453
- return;
454
- this.#renewalTimer = this.#runtime.setTimer(() => {
455
- this.#renewalTimer = undefined;
456
- if (this.#closed)
541
+ async #runRenewalCycle(grant) {
542
+ let delayMs = INITIAL_RETRY_MS;
543
+ while (this.#state === "running" && this.#grant === grant) {
544
+ if (!this.#isGrantReady(grant)) {
545
+ this.#expireGrant(grant);
457
546
  return;
458
- this.#renewing = true;
459
- this.#backgroundAttempts++;
460
- void this.#openSingleFlight().catch((next) => this.#handleRenewalFailure(next));
461
- }, delayMs);
462
- this.#renewalTimer.unref?.();
547
+ }
548
+ try {
549
+ await this.#openSingleFlight(this.#lifecycleController.signal, grant.expiresAtNanos);
550
+ return;
551
+ }
552
+ catch (error) {
553
+ if (this.#isClosed())
554
+ return;
555
+ if (this.#grant !== grant)
556
+ return;
557
+ this.#recordFailure(error);
558
+ if (error instanceof PortalTargetChangedError) {
559
+ if (!(await this.#waitUntil(grant.expiresAtNanos)))
560
+ return;
561
+ this.#expireGrant(grant);
562
+ return;
563
+ }
564
+ const remaining = nanosToCeilingMilliseconds(grant.expiresAtNanos - this.#runtime.nowNanos());
565
+ if (remaining <= 0)
566
+ continue;
567
+ if (!(await this.#waitForRenewal(Math.min(delayMs, remaining))))
568
+ return;
569
+ delayMs = Math.min(delayMs * 2, MAX_RETRY_MS);
570
+ }
571
+ }
572
+ }
573
+ #waitForRenewal(delayMs) {
574
+ const signal = this.#lifecycleController.signal;
575
+ if (signal.aborted)
576
+ return Promise.resolve(false);
577
+ return new Promise((resolve) => {
578
+ let settled = false;
579
+ let timer;
580
+ const finish = (result) => {
581
+ if (settled)
582
+ return;
583
+ settled = true;
584
+ signal.removeEventListener("abort", abort);
585
+ if (timer && this.#renewalTimer === timer)
586
+ this.#renewalTimer = undefined;
587
+ resolve(result);
588
+ };
589
+ const abort = () => {
590
+ if (timer)
591
+ this.#runtime.clearTimer(timer);
592
+ finish(false);
593
+ };
594
+ timer = this.#runtime.setTimer(() => finish(true), delayMs);
595
+ if (settled) {
596
+ this.#runtime.clearTimer(timer);
597
+ return;
598
+ }
599
+ this.#renewalTimer = timer;
600
+ timer.unref?.();
601
+ if (signal.aborted)
602
+ abort();
603
+ else
604
+ signal.addEventListener("abort", abort, { once: true });
605
+ });
606
+ }
607
+ async #waitUntil(deadlineNanos) {
608
+ while (this.#runtime.nowNanos() < deadlineNanos) {
609
+ const remaining = nanosToCeilingMilliseconds(deadlineNanos - this.#runtime.nowNanos());
610
+ if (!(await this.#waitForRenewal(Math.min(MAX_TIMER_DELAY_MS, remaining))))
611
+ return false;
612
+ }
613
+ return true;
614
+ }
615
+ #installGrant(grant) {
616
+ this.#clearGrant();
617
+ this.#grant = grant;
618
+ this.#boundResourceUrl ??= grant.resourceUrl;
619
+ this.#expiresAtEpochMs = grant.expiresAtEpochMs;
620
+ this.#renewAtEpochMs = grant.renewAtEpochMs;
621
+ this.#lastOpenSucceededAt = grant.openedAtEpochMs;
622
+ this.#lastFailureClass = "";
623
+ this.#consecutiveFailures = 0;
624
+ this.#state = "running";
625
+ this.#scheduleRenewal(grant);
626
+ }
627
+ #recordFailure(error) {
628
+ this.#lastFailureClass =
629
+ error instanceof PortalTargetChangedError ? "target_changed" : "open_failed";
630
+ this.#consecutiveFailures++;
631
+ }
632
+ #expireGrant(grant) {
633
+ if (this.#grant !== grant || this.#state !== "running")
634
+ return;
635
+ this.#clearGrant();
636
+ this.#state = "degraded";
637
+ }
638
+ #clearGrant() {
639
+ this.#grant?.token.fill(0);
640
+ this.#grant = undefined;
641
+ }
642
+ #isGrantReady(grant) {
643
+ return grant !== undefined && this.#runtime.nowNanos() < grant.expiresAtNanos;
644
+ }
645
+ #loadDeployment() {
646
+ if (this.#resolvedDeployment)
647
+ return this.#resolvedDeployment;
648
+ try {
649
+ return loadPortalDeployment(this.#explicitDeployment);
650
+ }
651
+ catch (error) {
652
+ throw new PortalConfigurationError("native qURL deployment trust is invalid", {
653
+ cause: error,
654
+ });
655
+ }
463
656
  }
464
- #recordRenewalFailure(error) {
465
- this.#renewing = false;
466
- this.#renewalError = error;
467
- this.#renewalFailure = classifyRenewalFailure(error);
657
+ #verifyLink(deployment) {
658
+ try {
659
+ return verifyQv2Link(this.#qurl, deployment.issuers);
660
+ }
661
+ catch (error) {
662
+ throw new PortalVerificationError("native qURL credential validation failed", {
663
+ cause: error,
664
+ });
665
+ }
468
666
  }
469
- #knockBody() {
667
+ #knockBody(link) {
668
+ const sessionSecret = this.#sessionSecret;
669
+ if (!sessionSecret)
670
+ throw new PortalStateError("portal opener session is not initialized");
470
671
  // Immutable JS strings cannot be zeroized. Keep their lifetime to this one
471
672
  // serialization and retain private capability material only in mutable
472
673
  // buffers between opens.
473
674
  const encoded = JSON.stringify({
474
675
  headerType: 1,
475
676
  aspId: "qurl",
476
- resId: this.#link.claims.resourcePublicKeyB64,
677
+ resId: link.claims.resourcePublicKeyB64,
477
678
  usrData: {
478
- qurl_claims_b64: this.#link.claimsB64,
479
- qurl_issuer_sig_b64: this.#link.signatureB64,
480
- qurl_session_secret: this.#sessionSecret.toString("base64url"),
679
+ qurl_claims_b64: link.claimsB64,
680
+ qurl_issuer_sig_b64: link.signatureB64,
681
+ qurl_session_secret: sessionSecret.toString("base64url"),
481
682
  },
482
683
  });
483
684
  const body = Buffer.from(encoded, "utf8");
@@ -488,11 +689,56 @@ class NativePortalOpener {
488
689
  return body;
489
690
  }
490
691
  #requireOpen() {
491
- if (this.#closed)
492
- throw new PortalStateError("portal opener is closed");
692
+ if (this.#state === "closed")
693
+ throw new PortalOpenerClosedError();
694
+ }
695
+ #isClosed() {
696
+ return this.#state === "closed";
697
+ }
698
+ }
699
+ function validatePortalDescendantSegments(value) {
700
+ if (!Array.isArray(value) || value.length === 0) {
701
+ throw new PortalConfigurationError("portal descendant fetch requires at least one path segment");
702
+ }
703
+ for (let index = 0; index < value.length; index++) {
704
+ const segment = value[index];
705
+ if (typeof segment !== "string") {
706
+ throw new PortalConfigurationError(`portal descendant path segment ${index} must be a string`);
707
+ }
708
+ if (segment === "") {
709
+ throw new PortalConfigurationError(`portal descendant path segment ${index} is empty`);
710
+ }
711
+ if (segment === "." || segment === "..") {
712
+ throw new PortalConfigurationError(`portal descendant path segment ${index} is a dot segment`);
713
+ }
714
+ if (/[\\/?#]/u.test(segment)) {
715
+ throw new PortalConfigurationError(`portal descendant path segment ${index} contains a reserved delimiter`);
716
+ }
717
+ }
718
+ }
719
+ function appendPortalDescendantSegments(target, pathSegments) {
720
+ const separator = target.pathname.endsWith("/") ? "" : "/";
721
+ target.pathname += separator + pathSegments.map(escapePortalDescendantSegment).join("/");
722
+ return target;
723
+ }
724
+ function escapePortalDescendantSegment(segment) {
725
+ const escaped = [];
726
+ for (const byte of Buffer.from(segment, "utf8")) {
727
+ const character = String.fromCharCode(byte);
728
+ // Match Go's url.PathEscape path-segment allow set exactly.
729
+ if ((byte >= 0x41 && byte <= 0x5a) ||
730
+ (byte >= 0x61 && byte <= 0x7a) ||
731
+ (byte >= 0x30 && byte <= 0x39) ||
732
+ "-._~$&+:=@".includes(character)) {
733
+ escaped.push(character);
734
+ }
735
+ else {
736
+ escaped.push(`%${byte.toString(16).toUpperCase().padStart(2, "0")}`);
737
+ }
493
738
  }
739
+ return escaped.join("");
494
740
  }
495
- function parseGrant(body, nowNanos) {
741
+ function parseGrant(body, startedAtNanos, startedAtEpochMs, openedAtNanos, openedAtEpochMs) {
496
742
  let value;
497
743
  try {
498
744
  value = parseStrictJson(body, MAX_ACK_BODY_BYTES);
@@ -548,13 +794,57 @@ function parseGrant(body, nowNanos) {
548
794
  const tokenString = requireString(value.aspToken, "ACK application token");
549
795
  validateSessionToken(tokenString);
550
796
  const token = Buffer.from(tokenString, "ascii");
797
+ const lifetimeNanos = BigInt(openSeconds) * 1000000000n;
798
+ const expiresAtNanos = startedAtNanos + lifetimeNanos;
799
+ let renewAtNanos = expiresAtNanos - renewalLeadNanos(lifetimeNanos);
800
+ const minimumRenewAt = openedAtNanos + BigInt(MIN_RENEWAL_GAP_MS) * 1000000n;
801
+ if (renewAtNanos < minimumRenewAt)
802
+ renewAtNanos = minimumRenewAt;
803
+ if (renewAtNanos > expiresAtNanos)
804
+ renewAtNanos = expiresAtNanos;
551
805
  return {
552
- resourceUrl,
806
+ resourceUrl: parsed.href,
553
807
  origin,
554
- expiresAtNanos: nowNanos + BigInt(openSeconds) * 1000000000n,
808
+ expiresAtNanos,
809
+ renewAtNanos,
810
+ expiresAtEpochMs: startedAtEpochMs + openSeconds * 1_000,
811
+ renewAtEpochMs: openedAtEpochMs + Number(renewAtNanos - openedAtNanos) / 1_000_000,
812
+ openedAtEpochMs,
555
813
  token,
556
814
  };
557
815
  }
816
+ function renewalLeadNanos(lifetimeNanos) {
817
+ let lead = lifetimeNanos / 5n;
818
+ const minimum = BigInt(MIN_RENEWAL_LEAD_MS) * 1000000n;
819
+ const maximum = BigInt(MAX_RENEWAL_LEAD_MS) * 1000000n;
820
+ if (lead < minimum)
821
+ lead = minimum;
822
+ if (lead > maximum)
823
+ lead = maximum;
824
+ if (lead >= lifetimeNanos)
825
+ lead = lifetimeNanos / 2n;
826
+ return lead;
827
+ }
828
+ function validatedCellForLink(link, deployment) {
829
+ const cell = deployment.cells.get(fingerprintKey(link.claims.cellPublicKey));
830
+ if (!cell) {
831
+ throw new PortalConfigurationError("verified qURL names a cell outside the native catalog");
832
+ }
833
+ // The 64-bit fingerprint is only an efficient map index. The signed cell
834
+ // identity must still match the exact deployment key used by Noise.
835
+ if (!timingSafeEqual(cell.serverPublicKey, link.claims.cellPublicKey)) {
836
+ throw new PortalConfigurationError("deployment cell key does not match the signed cell key");
837
+ }
838
+ return cell;
839
+ }
840
+ function nanosToCeilingMilliseconds(value) {
841
+ if (value <= 0n)
842
+ return 0;
843
+ return Number((value + 999999n) / 1000000n);
844
+ }
845
+ function dateFromEpoch(value) {
846
+ return value === undefined ? undefined : new Date(value);
847
+ }
558
848
  function requireString(value, name) {
559
849
  if (typeof value !== "string" || value === "")
560
850
  throw new PortalInvalidReplyError(`${name} is missing or invalid`);
@@ -587,6 +877,8 @@ function normalizedHttpsOrigin(url) {
587
877
  return `https://${url.hostname.toLowerCase()}:${Number(port)}`;
588
878
  }
589
879
  function validateSessionToken(value, decode = (part) => Buffer.from(part, "base64url")) {
880
+ // This is a defense-in-depth capability bound. The ACK envelope limit above
881
+ // currently makes it unreachable, but this validator also has direct tests.
590
882
  if (value.length > 4_096 || value.trim() !== value || hasUnsafeTokenByte(value)) {
591
883
  throw new PortalInvalidReplyError("ACK application token has an invalid shape");
592
884
  }
@@ -669,7 +961,7 @@ function isReplayableBody(body) {
669
961
  body instanceof ArrayBuffer ||
670
962
  ArrayBuffer.isView(body) ||
671
963
  body instanceof Blob ||
672
- (typeof FormData !== "undefined" && body instanceof FormData));
964
+ body instanceof FormData);
673
965
  }
674
966
  async function discardResponseBody(response) {
675
967
  try {
@@ -679,6 +971,35 @@ async function discardResponseBody(response) {
679
971
  // Redirect refusal must keep its stable error even for a broken body shim.
680
972
  }
681
973
  }
974
+ async function discardRequestBody(body) {
975
+ try {
976
+ if (body instanceof ReadableStream) {
977
+ await body.cancel();
978
+ return;
979
+ }
980
+ if (!body || typeof body !== "object")
981
+ return;
982
+ const destroy = Reflect.get(body, "destroy");
983
+ if (typeof destroy === "function") {
984
+ Reflect.apply(destroy, body, []);
985
+ return;
986
+ }
987
+ const asyncIterator = Reflect.get(body, Symbol.asyncIterator);
988
+ if (typeof asyncIterator === "function") {
989
+ const iterator = Reflect.apply(asyncIterator, body, []);
990
+ await iterator.return?.();
991
+ return;
992
+ }
993
+ const iteratorFactory = Reflect.get(body, Symbol.iterator);
994
+ if (typeof iteratorFactory === "function") {
995
+ const iterator = Reflect.apply(iteratorFactory, body, []);
996
+ iterator.return?.();
997
+ }
998
+ }
999
+ catch {
1000
+ // Invalid request handling must keep its stable error for a broken stream.
1001
+ }
1002
+ }
682
1003
  function responseMatchesRequestUrl(responseUrl, requestUrl) {
683
1004
  if (responseUrl === "" || responseUrl === requestUrl)
684
1005
  return true;
@@ -689,6 +1010,10 @@ function responseMatchesRequestUrl(responseUrl, requestUrl) {
689
1010
  return false;
690
1011
  }
691
1012
  }
1013
+ function throwIfAborted(signal) {
1014
+ if (signal.aborted)
1015
+ throw signal.reason ?? new DOMException("Aborted", "AbortError");
1016
+ }
692
1017
  function linkAbortSignal(signal, controller) {
693
1018
  if (!signal)
694
1019
  return () => undefined;
@@ -721,37 +1046,8 @@ function waitForPromise(promise, signal) {
721
1046
  });
722
1047
  });
723
1048
  }
724
- function classifyRenewalFailure(error) {
725
- if (error instanceof PortalDenyError)
726
- return { kind: "denied" };
727
- if (error instanceof PortalBusyError)
728
- return { kind: "busy" };
729
- if (error instanceof PortalInvalidReplyError)
730
- return { kind: "invalid_reply" };
731
- if (error instanceof PortalStateError ||
732
- error instanceof PortalConfigurationError ||
733
- error instanceof PortalVerificationError)
734
- return { kind: "local_state" };
735
- return { kind: "transport" };
736
- }
737
- function backgroundRetryDelay(remainingNanos, remainingAttempts, randomFraction) {
738
- if (remainingNanos <= 0n || remainingAttempts < 1)
739
- return undefined;
740
- const remainingMs = Number(remainingNanos / 1000000n);
741
- if (remainingMs < 1)
742
- return undefined;
743
- // Divide the current remaining lifetime into one slot per possible attempt
744
- // plus a final expiry reserve. Recompute after every failed exchange so long
745
- // grants retry over minutes while short grants retain several useful tries.
746
- const slotMs = Math.floor(remainingMs / (remainingAttempts + 1));
747
- if (slotMs < 1)
748
- return undefined;
749
- const boundedRandom = Math.max(0, Math.min(1, randomFraction));
750
- const jittered = Math.floor(slotMs * (0.75 + boundedRandom * 0.25));
751
- return Math.min(MAX_TIMER_DELAY_MS, slotMs, Math.max(Math.min(MIN_BACKGROUND_RETRY_MS, slotMs), jittered));
752
- }
753
1049
  export const portalOpenerTesting = {
754
- constructVerifiedPortalOpener,
1050
+ validatedCellForLink,
755
1051
  validateSessionToken,
756
1052
  };
757
1053
  //# sourceMappingURL=portal-opener.js.map