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