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