@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.
- package/README.md +174 -61
- package/dist/cjs/client.d.ts +46 -20
- package/dist/cjs/client.js +338 -54
- package/dist/cjs/errors.d.ts +24 -3
- package/dist/cjs/errors.js +34 -4
- package/dist/cjs/index.d.ts +3 -2
- package/dist/cjs/index.js +5 -1
- package/dist/cjs/node/deployment.d.ts +1 -1
- package/dist/cjs/node/deployment.js +2 -2
- package/dist/cjs/node/native-udp.d.ts +2 -4
- package/dist/cjs/node/native-udp.js +8 -8
- package/dist/cjs/node/portal-opener.d.ts +54 -23
- package/dist/cjs/node/portal-opener.js +643 -340
- package/dist/cjs/node.d.ts +2 -2
- package/dist/cjs/node.js +8 -1
- package/dist/cjs/types.d.ts +18 -2
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/client.d.ts +46 -20
- package/dist/esm/client.d.ts.map +1 -1
- package/dist/esm/client.js +337 -54
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/errors.d.ts +24 -3
- package/dist/esm/errors.d.ts.map +1 -1
- package/dist/esm/errors.js +32 -3
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/index.d.ts +3 -2
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +2 -2
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/node/deployment.d.ts +1 -1
- package/dist/esm/node/deployment.d.ts.map +1 -1
- package/dist/esm/node/deployment.js +2 -2
- package/dist/esm/node/deployment.js.map +1 -1
- package/dist/esm/node/native-udp.d.ts +2 -4
- package/dist/esm/node/native-udp.d.ts.map +1 -1
- package/dist/esm/node/native-udp.js +5 -5
- package/dist/esm/node/native-udp.js.map +1 -1
- package/dist/esm/node/portal-opener.d.ts +54 -23
- package/dist/esm/node/portal-opener.d.ts.map +1 -1
- package/dist/esm/node/portal-opener.js +636 -340
- package/dist/esm/node/portal-opener.js.map +1 -1
- package/dist/esm/node.d.ts +2 -2
- package/dist/esm/node.d.ts.map +1 -1
- package/dist/esm/node.js +1 -1
- package/dist/esm/node.js.map +1 -1
- package/dist/esm/types.d.ts +18 -2
- package/dist/esm/types.d.ts.map +1 -1
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +4 -4
|
@@ -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:
|
|
131
|
+
fetch: defaultContentFetch,
|
|
73
132
|
nowNanos: () => process.hrtime.bigint(),
|
|
74
|
-
|
|
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.
|
|
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.
|
|
94
|
-
(!Number.isFinite(options.
|
|
95
|
-
|
|
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.
|
|
98
|
-
(
|
|
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
|
-
#
|
|
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
|
-
#
|
|
158
|
-
#
|
|
159
|
-
#
|
|
160
|
-
#
|
|
161
|
-
#
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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.#
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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(
|
|
227
|
+
await this.#openSingleFlight(signal);
|
|
201
228
|
}
|
|
202
229
|
catch (error) {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
throw new
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
next = new URL(location, currentUrl);
|
|
274
|
+
if (this.#state === "new" || (this.#state === "starting" && !this.#startingRecovery)) {
|
|
275
|
+
throw new PortalOpenerNotStartedError();
|
|
274
276
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
throw new
|
|
277
|
+
const grant = this.#grant;
|
|
278
|
+
if (this.#state !== "running" || !this.#isGrantReady(grant)) {
|
|
279
|
+
throw new PortalOpenerNotReadyError();
|
|
278
280
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
299
|
-
|
|
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
|
-
|
|
303
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
if (this.#
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
if (
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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.#
|
|
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
|
-
|
|
432
|
+
const renewal = this.#renewalCycle;
|
|
340
433
|
const closing = (async () => {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
|
371
|
-
},
|
|
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
|
|
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
|
|
392
|
-
const
|
|
393
|
-
...this.#exchangeOptions,
|
|
394
|
-
signal,
|
|
395
|
-
});
|
|
486
|
+
const startedAtNanos = this.#runtime.nowNanos();
|
|
487
|
+
const startedAtEpochMs = this.#runtime.nowEpochMs();
|
|
396
488
|
try {
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
401
|
-
|
|
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
|
-
|
|
427
|
-
reply.body.fill(0);
|
|
520
|
+
body.fill(0);
|
|
428
521
|
}
|
|
429
522
|
}
|
|
430
523
|
finally {
|
|
431
|
-
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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.#
|
|
448
|
-
|
|
449
|
-
|
|
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
|
|
556
|
+
this.#renewalTimer = timer;
|
|
557
|
+
timer.unref?.();
|
|
452
558
|
}
|
|
453
|
-
#
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
#
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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:
|
|
695
|
+
resId: link.claims.resourcePublicKeyB64,
|
|
488
696
|
usrData: {
|
|
489
|
-
qurl_claims_b64:
|
|
490
|
-
qurl_issuer_sig_b64:
|
|
491
|
-
qurl_session_secret:
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1068
|
+
validatedCellForLink,
|
|
766
1069
|
validateSessionToken,
|
|
767
1070
|
};
|