@zhin.js/host-http 1.1.17 → 1.2.1
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 +60 -21
- package/lib/access-control.d.ts +127 -0
- package/lib/access-control.js +148 -0
- package/lib/addresses.d.ts +9 -0
- package/lib/addresses.js +9 -0
- package/lib/bridge/bridged-host.d.ts +43 -0
- package/lib/bridge/bridged-host.js +497 -0
- package/lib/bridge/control-channel.d.ts +59 -0
- package/lib/bridge/control-channel.js +131 -0
- package/lib/bridge/flow.d.ts +58 -0
- package/lib/bridge/flow.js +188 -0
- package/lib/bridge/index.d.ts +6 -0
- package/lib/bridge/index.js +6 -0
- package/lib/bridge/instance-transport.d.ts +112 -0
- package/lib/bridge/instance-transport.js +532 -0
- package/lib/bridge/protocol.d.ts +242 -0
- package/lib/bridge/protocol.js +369 -0
- package/lib/console-events.d.ts +5 -3
- package/lib/console-events.js +53 -27
- package/lib/console-plugin-rpc.d.ts +12 -1
- package/lib/console-plugin-rpc.js +16 -66
- package/lib/console-rest-pages-helpers.d.ts +4 -5
- package/lib/console-rest-pages-helpers.js +62 -79
- package/lib/console-rest-pages.js +80 -65
- package/lib/console-rpc-extended/dispatcher.js +1 -6
- package/lib/console-rpc-handlers.d.ts +0 -2
- package/lib/console-rpc-handlers.js +2 -40
- package/lib/console-rpc.d.ts +6 -18
- package/lib/console-rpc.js +6 -6
- package/lib/host-installer.d.ts +6 -3
- package/lib/host-installer.js +10 -40
- package/lib/host-options.d.ts +47 -0
- package/lib/host-options.js +1 -0
- package/lib/http-host.d.ts +124 -48
- package/lib/http-host.js +319 -332
- package/lib/index.d.ts +11 -6
- package/lib/index.js +10 -6
- package/lib/json-body.d.ts +1 -2
- package/lib/json-body.js +1 -2
- package/lib/marketplace-registry.d.ts +17 -0
- package/lib/marketplace-registry.js +52 -0
- package/lib/node/index.d.ts +10 -0
- package/lib/node/index.js +10 -0
- package/lib/{listener-group.d.ts → node/listener-group.d.ts} +2 -3
- package/lib/{listener-group.js → node/listener-group.js} +6 -10
- package/lib/node/listener.d.ts +26 -0
- package/lib/node/listener.js +231 -0
- package/lib/node/node-http.d.ts +38 -0
- package/lib/node/node-http.js +272 -0
- package/lib/node/server.d.ts +13 -0
- package/lib/node/server.js +64 -0
- package/lib/protocol-auth.d.ts +24 -0
- package/lib/protocol-auth.js +48 -0
- package/lib/public-api.d.ts +1 -1
- package/lib/token-registry.d.ts +54 -36
- package/lib/token-registry.js +133 -126
- package/lib/trust-proxy.d.ts +22 -0
- package/lib/trust-proxy.js +168 -0
- package/package.json +14 -6
- package/src/access-control.ts +255 -0
- package/src/addresses.ts +16 -0
- package/src/bridge/bridged-host.ts +548 -0
- package/src/bridge/control-channel.ts +173 -0
- package/src/bridge/flow.ts +208 -0
- package/src/bridge/index.ts +31 -0
- package/src/bridge/instance-transport.ts +627 -0
- package/src/bridge/protocol.ts +574 -0
- package/src/console-events.ts +59 -28
- package/src/console-plugin-rpc.ts +17 -77
- package/src/console-rest-pages-helpers.ts +60 -82
- package/src/console-rest-pages.ts +87 -67
- package/src/console-rpc-extended/dispatcher.ts +1 -6
- package/src/console-rpc-handlers.ts +2 -31
- package/src/console-rpc.ts +10 -24
- package/src/host-installer.ts +12 -44
- package/src/host-options.ts +47 -0
- package/src/http-host.ts +467 -421
- package/src/index.ts +56 -14
- package/src/json-body.ts +7 -2
- package/src/marketplace-registry.ts +62 -0
- package/src/node/index.ts +21 -0
- package/src/{listener-group.ts → node/listener-group.ts} +13 -24
- package/src/node/listener.ts +265 -0
- package/src/node/node-http.ts +263 -0
- package/src/node/server.ts +82 -0
- package/src/protocol-auth.ts +52 -0
- package/src/public-api.ts +2 -0
- package/src/token-registry.ts +143 -146
- package/src/trust-proxy.ts +178 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node `http` / `ws` ↔ Web standard conversion (ADR 0054 §2). This module and the
|
|
3
|
+
* server built on it are the only places in `@zhin.js/host-http` that load
|
|
4
|
+
* `node:http` or `ws`; the gateway reuses them for its listener.
|
|
5
|
+
*/
|
|
6
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
7
|
+
import { WebSocket, type RawData } from 'ws';
|
|
8
|
+
import type { WsTransport } from '../http-host.js';
|
|
9
|
+
|
|
10
|
+
const BODYLESS_METHODS = new Set(['GET', 'HEAD']);
|
|
11
|
+
|
|
12
|
+
export interface NodeRequestOptions {
|
|
13
|
+
/** Absolute request URL. */
|
|
14
|
+
readonly url: URL;
|
|
15
|
+
/** Aborted when the client goes away before the response finished. */
|
|
16
|
+
readonly signal?: AbortSignal;
|
|
17
|
+
/** Bytes buffered from the socket before reading pauses (default 64 KiB). */
|
|
18
|
+
readonly highWaterMark?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Headers of a Node request as a Web `Headers` (repeated headers preserved). */
|
|
22
|
+
export function nodeRequestHeaders(request: IncomingMessage): Headers {
|
|
23
|
+
const headers = new Headers();
|
|
24
|
+
const raw = request.rawHeaders;
|
|
25
|
+
for (let index = 0; index + 1 < raw.length; index += 2) {
|
|
26
|
+
const name = raw[index]!;
|
|
27
|
+
if (name.startsWith(':')) continue;
|
|
28
|
+
try {
|
|
29
|
+
headers.append(name, raw[index + 1]!);
|
|
30
|
+
} catch { /* invalid for the Fetch API: skip */ }
|
|
31
|
+
}
|
|
32
|
+
return headers;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A Web `Request` over a Node request. The body streams from the socket with
|
|
37
|
+
* backpressure; cancelling it (or reading it to the end) never destroys the
|
|
38
|
+
* socket, so the response can still be written on the same connection.
|
|
39
|
+
*/
|
|
40
|
+
export function createNodeRequest(request: IncomingMessage, options: NodeRequestOptions): Request {
|
|
41
|
+
const method = (request.method ?? 'GET').toUpperCase();
|
|
42
|
+
const body = BODYLESS_METHODS.has(method) ? null : nodeBodyStream(request, options.highWaterMark ?? 65_536);
|
|
43
|
+
return new Request(options.url, {
|
|
44
|
+
method,
|
|
45
|
+
headers: nodeRequestHeaders(request),
|
|
46
|
+
...(body ? { body, duplex: 'half' } : {}),
|
|
47
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
48
|
+
} as RequestInit);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function nodeBodyStream(request: IncomingMessage, highWaterMark: number): ReadableStream<Uint8Array> {
|
|
52
|
+
let settled = false;
|
|
53
|
+
let cancelled = false;
|
|
54
|
+
return new ReadableStream<Uint8Array>({
|
|
55
|
+
start(controller) {
|
|
56
|
+
request.on('data', (chunk: Buffer | string) => {
|
|
57
|
+
if (cancelled) return;
|
|
58
|
+
const bytes = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
|
|
59
|
+
controller.enqueue(new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength));
|
|
60
|
+
if ((controller.desiredSize ?? 0) <= 0) request.pause();
|
|
61
|
+
});
|
|
62
|
+
request.once('end', () => {
|
|
63
|
+
if (settled) return;
|
|
64
|
+
settled = true;
|
|
65
|
+
if (!cancelled) controller.close();
|
|
66
|
+
});
|
|
67
|
+
const fail = (error: Error): void => {
|
|
68
|
+
if (settled) return;
|
|
69
|
+
settled = true;
|
|
70
|
+
if (!cancelled) controller.error(error);
|
|
71
|
+
};
|
|
72
|
+
request.once('error', fail);
|
|
73
|
+
request.once('close', () => {
|
|
74
|
+
if (!request.complete) fail(new Error('Request aborted'));
|
|
75
|
+
});
|
|
76
|
+
request.pause();
|
|
77
|
+
},
|
|
78
|
+
pull() {
|
|
79
|
+
request.resume();
|
|
80
|
+
},
|
|
81
|
+
cancel() {
|
|
82
|
+
// Discard the rest instead of destroying the socket.
|
|
83
|
+
cancelled = true;
|
|
84
|
+
request.resume();
|
|
85
|
+
},
|
|
86
|
+
}, new ByteLengthQueuingStrategy({ highWaterMark }));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Writes a Web `Response` to a Node response: status, headers (`set-cookie`
|
|
91
|
+
* kept separate) and the body, honouring `drain` backpressure. A body that is
|
|
92
|
+
* immediately complete is sent with `Content-Length`; otherwise headers are
|
|
93
|
+
* flushed at once (SSE) and the body is chunked. A body stream error destroys
|
|
94
|
+
* the connection; a client disconnect cancels the body.
|
|
95
|
+
*/
|
|
96
|
+
export async function writeNodeResponse(target: ServerResponse, response: Response): Promise<void> {
|
|
97
|
+
target.statusCode = response.status;
|
|
98
|
+
if (response.statusText) target.statusMessage = response.statusText;
|
|
99
|
+
const cookies = response.headers.getSetCookie();
|
|
100
|
+
for (const [name, value] of response.headers) {
|
|
101
|
+
if (name === 'set-cookie') continue;
|
|
102
|
+
target.setHeader(name, value);
|
|
103
|
+
}
|
|
104
|
+
if (cookies.length > 0) target.setHeader('set-cookie', cookies);
|
|
105
|
+
const body = response.body;
|
|
106
|
+
if (!body) {
|
|
107
|
+
target.end();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const reader = body.getReader();
|
|
111
|
+
let disconnected = false;
|
|
112
|
+
let cancelled: Promise<void> | undefined;
|
|
113
|
+
const onClose = (): void => {
|
|
114
|
+
if (target.writableFinished) return;
|
|
115
|
+
disconnected = true;
|
|
116
|
+
cancelled = reader.cancel(new Error('Client disconnected')).catch(() => undefined);
|
|
117
|
+
};
|
|
118
|
+
target.once('close', onClose);
|
|
119
|
+
try {
|
|
120
|
+
let read = reader.read();
|
|
121
|
+
let first = await raceImmediate(read);
|
|
122
|
+
if (first === PENDING) {
|
|
123
|
+
// Nothing to send yet (SSE): commit the headers now.
|
|
124
|
+
target.flushHeaders();
|
|
125
|
+
first = await read;
|
|
126
|
+
}
|
|
127
|
+
if (first.done) {
|
|
128
|
+
target.end();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
let chunk: Uint8Array = first.value;
|
|
132
|
+
read = reader.read();
|
|
133
|
+
const second = await raceImmediate(read);
|
|
134
|
+
if (second !== PENDING && second.done) {
|
|
135
|
+
target.end(chunk);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
for (;;) {
|
|
139
|
+
if (disconnected || target.destroyed) return;
|
|
140
|
+
if (!target.write(chunk)) await waitForDrain(target);
|
|
141
|
+
if (disconnected || target.destroyed) return;
|
|
142
|
+
const next = await read;
|
|
143
|
+
if (next.done) break;
|
|
144
|
+
chunk = next.value;
|
|
145
|
+
read = reader.read();
|
|
146
|
+
}
|
|
147
|
+
if (!target.destroyed) target.end();
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (!disconnected && !target.destroyed) target.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
150
|
+
} finally {
|
|
151
|
+
target.off('close', onClose);
|
|
152
|
+
// A disconnect cancels the body: let its source finish cleaning up first.
|
|
153
|
+
await cancelled;
|
|
154
|
+
try { reader.releaseLock(); } catch { /* read still pending after disconnect */ }
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const PENDING = Symbol('pending');
|
|
159
|
+
|
|
160
|
+
/** Settles with the promise's value if it is ready before the next macrotask, else PENDING. */
|
|
161
|
+
function raceImmediate<T>(promise: Promise<T>): Promise<T | typeof PENDING> {
|
|
162
|
+
return new Promise((resolve, reject) => {
|
|
163
|
+
const timer = setImmediate(() => resolve(PENDING));
|
|
164
|
+
promise.then((value) => {
|
|
165
|
+
clearImmediate(timer);
|
|
166
|
+
resolve(value);
|
|
167
|
+
}, (error: unknown) => {
|
|
168
|
+
clearImmediate(timer);
|
|
169
|
+
reject(error);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function waitForDrain(target: ServerResponse): Promise<void> {
|
|
175
|
+
return new Promise((resolve) => {
|
|
176
|
+
const done = (): void => {
|
|
177
|
+
target.off('drain', done);
|
|
178
|
+
target.off('close', done);
|
|
179
|
+
resolve();
|
|
180
|
+
};
|
|
181
|
+
target.once('drain', done);
|
|
182
|
+
target.once('close', done);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface NodeWebSocketOptions {
|
|
187
|
+
/** Protocol ping interval; a peer that misses one pong is terminated. 0 disables. Default 30 s. */
|
|
188
|
+
readonly keepAliveMs?: number;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** A {@link WsTransport} over a `ws` server socket. */
|
|
192
|
+
export function wrapNodeWebSocket(socket: WebSocket, options: NodeWebSocketOptions = {}): WsTransport {
|
|
193
|
+
const messageListeners = new Set<(data: string | Uint8Array) => void>();
|
|
194
|
+
const closeListeners = new Set<(code: number, reason: string) => void>();
|
|
195
|
+
let closedWith: { code: number; reason: string } | undefined;
|
|
196
|
+
socket.on('message', (data: RawData, isBinary: boolean) => {
|
|
197
|
+
const value = isBinary ? rawBytes(data) : rawBytes(data).toString('utf8');
|
|
198
|
+
for (const listener of [...messageListeners]) {
|
|
199
|
+
try { listener(value); } catch { /* isolate listener failures */ }
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
socket.on('error', () => { /* followed by close */ });
|
|
203
|
+
let alive = true;
|
|
204
|
+
const keepAliveMs = options.keepAliveMs ?? 30_000;
|
|
205
|
+
const timer = keepAliveMs > 0
|
|
206
|
+
? setInterval(() => {
|
|
207
|
+
if (!alive) {
|
|
208
|
+
socket.terminate();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
alive = false;
|
|
212
|
+
try { socket.ping(); } catch { /* closing */ }
|
|
213
|
+
}, keepAliveMs)
|
|
214
|
+
: undefined;
|
|
215
|
+
timer?.unref?.();
|
|
216
|
+
socket.on('pong', () => { alive = true; });
|
|
217
|
+
socket.once('close', (code: number, reason: Buffer) => {
|
|
218
|
+
if (timer) clearInterval(timer);
|
|
219
|
+
closedWith = { code, reason: reason.toString('utf8') };
|
|
220
|
+
messageListeners.clear();
|
|
221
|
+
for (const listener of [...closeListeners]) {
|
|
222
|
+
try { listener(closedWith.code, closedWith.reason); } catch { /* isolate listener failures */ }
|
|
223
|
+
}
|
|
224
|
+
closeListeners.clear();
|
|
225
|
+
});
|
|
226
|
+
return {
|
|
227
|
+
send(data) {
|
|
228
|
+
if (socket.readyState !== WebSocket.OPEN) return;
|
|
229
|
+
socket.send(data);
|
|
230
|
+
},
|
|
231
|
+
close(code, reason) {
|
|
232
|
+
if (socket.readyState === WebSocket.CLOSING || socket.readyState === WebSocket.CLOSED) return;
|
|
233
|
+
try {
|
|
234
|
+
socket.close(code, reason);
|
|
235
|
+
} catch {
|
|
236
|
+
socket.terminate();
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
onMessage(listener) {
|
|
240
|
+
if (closedWith) return () => undefined;
|
|
241
|
+
messageListeners.add(listener);
|
|
242
|
+
return () => { messageListeners.delete(listener); };
|
|
243
|
+
},
|
|
244
|
+
onClose(listener) {
|
|
245
|
+
if (closedWith) {
|
|
246
|
+
const { code, reason } = closedWith;
|
|
247
|
+
queueMicrotask(() => listener(code, reason));
|
|
248
|
+
return () => undefined;
|
|
249
|
+
}
|
|
250
|
+
closeListeners.add(listener);
|
|
251
|
+
return () => { closeListeners.delete(listener); };
|
|
252
|
+
},
|
|
253
|
+
get bufferedAmount() {
|
|
254
|
+
return socket.bufferedAmount;
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function rawBytes(data: RawData): Buffer {
|
|
260
|
+
if (Buffer.isBuffer(data)) return data;
|
|
261
|
+
if (Array.isArray(data)) return Buffer.concat(data);
|
|
262
|
+
return Buffer.from(data);
|
|
263
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
2
|
+
import {
|
|
3
|
+
generationAdmissionBinder,
|
|
4
|
+
type GenerationAdmissionBindable,
|
|
5
|
+
} from '@zhin.js/plugin-runtime';
|
|
6
|
+
import { createHttpRouter, type HttpHost } from '../http-host.js';
|
|
7
|
+
import type { HttpHostAddress, HttpHostOptions } from '../host-options.js';
|
|
8
|
+
import { createNodeHttpListener } from './listener.js';
|
|
9
|
+
|
|
10
|
+
const logger = getLogger('http');
|
|
11
|
+
|
|
12
|
+
/** @internal Process-root lifecycle and generation admission surface. */
|
|
13
|
+
export interface ProcessHttpHost extends HttpHost, GenerationAdmissionBindable<HttpHost> {
|
|
14
|
+
listen(): Promise<HttpHostAddress>;
|
|
15
|
+
close(): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* In-process Node listener for the transport-independent router: Node requests become
|
|
20
|
+
* Web `Request`s, `Response`s are streamed back, `ws` sockets become `WsConnection`s.
|
|
21
|
+
*/
|
|
22
|
+
export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
|
|
23
|
+
const secure = options.tls !== undefined;
|
|
24
|
+
let closeResult: Promise<void> | undefined;
|
|
25
|
+
|
|
26
|
+
const router = createHttpRouter({
|
|
27
|
+
token: options.token,
|
|
28
|
+
corsOrigins: options.corsOrigins,
|
|
29
|
+
apiBase: options.apiBase,
|
|
30
|
+
authExemptPaths: options.authExemptPaths,
|
|
31
|
+
tokenRegistry: options.tokenRegistry,
|
|
32
|
+
secure,
|
|
33
|
+
address: () => listener.address,
|
|
34
|
+
});
|
|
35
|
+
const tokenRegistry = router.tokenRegistry;
|
|
36
|
+
|
|
37
|
+
const listener = createNodeHttpListener({
|
|
38
|
+
host: options.host,
|
|
39
|
+
port: options.port,
|
|
40
|
+
tls: options.tls,
|
|
41
|
+
trustProxy: options.trustProxy,
|
|
42
|
+
handle: (request, info) => router.handle(request, info),
|
|
43
|
+
upgrade: (request) => router.upgrade(request),
|
|
44
|
+
onListening(address) {
|
|
45
|
+
logger.info(
|
|
46
|
+
`listening ${address.origin}`
|
|
47
|
+
+ ` | routes: ${router.routeCount}`
|
|
48
|
+
+ ` | token: ${tokenRegistry.hasAnyToken()
|
|
49
|
+
? tokenRegistry.primaryTokenPrefixForLog()
|
|
50
|
+
: '(none)'}`,
|
|
51
|
+
);
|
|
52
|
+
logger.debug(
|
|
53
|
+
`listen detail | cors: ${router.corsOrigins.join(',')}`
|
|
54
|
+
+ ` | ws: ${router.wsPaths.join(',') || '(none)'}`,
|
|
55
|
+
);
|
|
56
|
+
},
|
|
57
|
+
onClosed() {
|
|
58
|
+
logger.debug(formatCompact({ op: 'http_host_closed' }));
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const runtime: ProcessHttpHost = {
|
|
63
|
+
[generationAdmissionBinder]: (admission) => router[generationAdmissionBinder](admission),
|
|
64
|
+
ws: (pathname, wsOptions) => router.ws(pathname, wsOptions),
|
|
65
|
+
route: (method, path, handler, meta) => router.route(method, path, handler, meta),
|
|
66
|
+
listRoutes: () => router.listRoutes(),
|
|
67
|
+
get address() {
|
|
68
|
+
return listener.address;
|
|
69
|
+
},
|
|
70
|
+
get tokenRegistry() {
|
|
71
|
+
return tokenRegistry;
|
|
72
|
+
},
|
|
73
|
+
listen: () => listener.listen(),
|
|
74
|
+
close(): Promise<void> {
|
|
75
|
+
if (closeResult) return closeResult;
|
|
76
|
+
router.close();
|
|
77
|
+
closeResult = listener.close();
|
|
78
|
+
return closeResult;
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
return runtime;
|
|
82
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { timingSafeEqualString } from './timing-safe-equal.js';
|
|
2
|
+
import { hasForwardingHeaders } from './trust-proxy.js';
|
|
3
|
+
|
|
4
|
+
/** What a protocol credential check needs from an HTTP request or a {@link WsConnection}. */
|
|
5
|
+
export interface ProtocolAuthRequest {
|
|
6
|
+
readonly headers: Headers;
|
|
7
|
+
readonly url: URL | string;
|
|
8
|
+
readonly remoteAddress?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** IPv4 / IPv6 / IPv4-mapped loopback peer. */
|
|
12
|
+
export function isLoopbackAddress(address: string | undefined): boolean {
|
|
13
|
+
if (!address) return false;
|
|
14
|
+
if (address === '::1') return true;
|
|
15
|
+
const v4 = address.startsWith('::ffff:') ? address.slice(7) : address;
|
|
16
|
+
return /^127(?:\.\d{1,3}){3}$/u.test(v4);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Whether a listen address (`http.host`) only accepts connections from this machine:
|
|
21
|
+
* `localhost`, `127.0.0.0/8`, `::1` (bracketed or not). `0.0.0.0`, `::` and any other address are
|
|
22
|
+
* reachable from the network.
|
|
23
|
+
*/
|
|
24
|
+
export function isLoopbackHost(host: string | undefined): boolean {
|
|
25
|
+
if (host === undefined || host === '') return true; // the listener default is 127.0.0.1
|
|
26
|
+
const bare = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
|
|
27
|
+
return bare.toLowerCase() === 'localhost' || isLoopbackAddress(bare);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Access check for a plugin socket mounted with `ws(path, { auth: 'protocol' })` (adapter
|
|
32
|
+
* reverse WebSockets: OneBot, NapCat, Milky…) or a protocol webhook. With an access token the
|
|
33
|
+
* peer must present it as `Authorization: Bearer <token>` or `?access_token=<token>`
|
|
34
|
+
* (constant-time compare); without one only loopback peers are admitted, so an unconfigured
|
|
35
|
+
* socket is never public. Proxied traffic (`X-Forwarded-For` / `Forwarded` present) never counts
|
|
36
|
+
* as loopback: a same-host reverse proxy would otherwise make every client local. A {@link WsConnection} can be passed directly; for an HTTP route pass
|
|
37
|
+
* `{ headers: request.headers, url: context.url, remoteAddress: context.remoteAddress }`.
|
|
38
|
+
*/
|
|
39
|
+
export function verifyProtocolAccessToken(accessToken: string | undefined, request: ProtocolAuthRequest): boolean {
|
|
40
|
+
if (!accessToken) return isLoopbackAddress(request.remoteAddress) && !hasForwardingHeaders(request.headers);
|
|
41
|
+
const header = request.headers.get('authorization');
|
|
42
|
+
if (typeof header === 'string' && header.startsWith('Bearer ')
|
|
43
|
+
&& timingSafeEqualString(header.slice(7).trim(), accessToken)) return true;
|
|
44
|
+
let presented: string | null = null;
|
|
45
|
+
try {
|
|
46
|
+
const url = typeof request.url === 'string' ? new URL(request.url, 'http://localhost') : request.url;
|
|
47
|
+
presented = url.searchParams.get('access_token');
|
|
48
|
+
} catch {
|
|
49
|
+
presented = null;
|
|
50
|
+
}
|
|
51
|
+
return presented !== null && timingSafeEqualString(presented, accessToken);
|
|
52
|
+
}
|
package/src/public-api.ts
CHANGED
|
@@ -8,8 +8,10 @@ export {
|
|
|
8
8
|
type HttpHost,
|
|
9
9
|
type HttpHostAddress,
|
|
10
10
|
type HttpRouteRegistration,
|
|
11
|
+
type RouteContext,
|
|
11
12
|
type WsConnection,
|
|
12
13
|
type WsHandle,
|
|
14
|
+
type WsRouteOptions,
|
|
13
15
|
} from './http-host.js';
|
|
14
16
|
export type { AuthenticatedTokenPrincipal, AuthScope } from './token-registry.js';
|
|
15
17
|
export type { ListedRoute, OpenApiParameter, RouteMeta } from './openapi.js';
|