@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,272 @@
|
|
|
1
|
+
import { WebSocket } from 'ws';
|
|
2
|
+
const BODYLESS_METHODS = new Set(['GET', 'HEAD']);
|
|
3
|
+
/** Headers of a Node request as a Web `Headers` (repeated headers preserved). */
|
|
4
|
+
export function nodeRequestHeaders(request) {
|
|
5
|
+
const headers = new Headers();
|
|
6
|
+
const raw = request.rawHeaders;
|
|
7
|
+
for (let index = 0; index + 1 < raw.length; index += 2) {
|
|
8
|
+
const name = raw[index];
|
|
9
|
+
if (name.startsWith(':'))
|
|
10
|
+
continue;
|
|
11
|
+
try {
|
|
12
|
+
headers.append(name, raw[index + 1]);
|
|
13
|
+
}
|
|
14
|
+
catch { /* invalid for the Fetch API: skip */ }
|
|
15
|
+
}
|
|
16
|
+
return headers;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* A Web `Request` over a Node request. The body streams from the socket with
|
|
20
|
+
* backpressure; cancelling it (or reading it to the end) never destroys the
|
|
21
|
+
* socket, so the response can still be written on the same connection.
|
|
22
|
+
*/
|
|
23
|
+
export function createNodeRequest(request, options) {
|
|
24
|
+
const method = (request.method ?? 'GET').toUpperCase();
|
|
25
|
+
const body = BODYLESS_METHODS.has(method) ? null : nodeBodyStream(request, options.highWaterMark ?? 65_536);
|
|
26
|
+
return new Request(options.url, {
|
|
27
|
+
method,
|
|
28
|
+
headers: nodeRequestHeaders(request),
|
|
29
|
+
...(body ? { body, duplex: 'half' } : {}),
|
|
30
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function nodeBodyStream(request, highWaterMark) {
|
|
34
|
+
let settled = false;
|
|
35
|
+
let cancelled = false;
|
|
36
|
+
return new ReadableStream({
|
|
37
|
+
start(controller) {
|
|
38
|
+
request.on('data', (chunk) => {
|
|
39
|
+
if (cancelled)
|
|
40
|
+
return;
|
|
41
|
+
const bytes = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
|
|
42
|
+
controller.enqueue(new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength));
|
|
43
|
+
if ((controller.desiredSize ?? 0) <= 0)
|
|
44
|
+
request.pause();
|
|
45
|
+
});
|
|
46
|
+
request.once('end', () => {
|
|
47
|
+
if (settled)
|
|
48
|
+
return;
|
|
49
|
+
settled = true;
|
|
50
|
+
if (!cancelled)
|
|
51
|
+
controller.close();
|
|
52
|
+
});
|
|
53
|
+
const fail = (error) => {
|
|
54
|
+
if (settled)
|
|
55
|
+
return;
|
|
56
|
+
settled = true;
|
|
57
|
+
if (!cancelled)
|
|
58
|
+
controller.error(error);
|
|
59
|
+
};
|
|
60
|
+
request.once('error', fail);
|
|
61
|
+
request.once('close', () => {
|
|
62
|
+
if (!request.complete)
|
|
63
|
+
fail(new Error('Request aborted'));
|
|
64
|
+
});
|
|
65
|
+
request.pause();
|
|
66
|
+
},
|
|
67
|
+
pull() {
|
|
68
|
+
request.resume();
|
|
69
|
+
},
|
|
70
|
+
cancel() {
|
|
71
|
+
// Discard the rest instead of destroying the socket.
|
|
72
|
+
cancelled = true;
|
|
73
|
+
request.resume();
|
|
74
|
+
},
|
|
75
|
+
}, new ByteLengthQueuingStrategy({ highWaterMark }));
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Writes a Web `Response` to a Node response: status, headers (`set-cookie`
|
|
79
|
+
* kept separate) and the body, honouring `drain` backpressure. A body that is
|
|
80
|
+
* immediately complete is sent with `Content-Length`; otherwise headers are
|
|
81
|
+
* flushed at once (SSE) and the body is chunked. A body stream error destroys
|
|
82
|
+
* the connection; a client disconnect cancels the body.
|
|
83
|
+
*/
|
|
84
|
+
export async function writeNodeResponse(target, response) {
|
|
85
|
+
target.statusCode = response.status;
|
|
86
|
+
if (response.statusText)
|
|
87
|
+
target.statusMessage = response.statusText;
|
|
88
|
+
const cookies = response.headers.getSetCookie();
|
|
89
|
+
for (const [name, value] of response.headers) {
|
|
90
|
+
if (name === 'set-cookie')
|
|
91
|
+
continue;
|
|
92
|
+
target.setHeader(name, value);
|
|
93
|
+
}
|
|
94
|
+
if (cookies.length > 0)
|
|
95
|
+
target.setHeader('set-cookie', cookies);
|
|
96
|
+
const body = response.body;
|
|
97
|
+
if (!body) {
|
|
98
|
+
target.end();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const reader = body.getReader();
|
|
102
|
+
let disconnected = false;
|
|
103
|
+
let cancelled;
|
|
104
|
+
const onClose = () => {
|
|
105
|
+
if (target.writableFinished)
|
|
106
|
+
return;
|
|
107
|
+
disconnected = true;
|
|
108
|
+
cancelled = reader.cancel(new Error('Client disconnected')).catch(() => undefined);
|
|
109
|
+
};
|
|
110
|
+
target.once('close', onClose);
|
|
111
|
+
try {
|
|
112
|
+
let read = reader.read();
|
|
113
|
+
let first = await raceImmediate(read);
|
|
114
|
+
if (first === PENDING) {
|
|
115
|
+
// Nothing to send yet (SSE): commit the headers now.
|
|
116
|
+
target.flushHeaders();
|
|
117
|
+
first = await read;
|
|
118
|
+
}
|
|
119
|
+
if (first.done) {
|
|
120
|
+
target.end();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
let chunk = first.value;
|
|
124
|
+
read = reader.read();
|
|
125
|
+
const second = await raceImmediate(read);
|
|
126
|
+
if (second !== PENDING && second.done) {
|
|
127
|
+
target.end(chunk);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
for (;;) {
|
|
131
|
+
if (disconnected || target.destroyed)
|
|
132
|
+
return;
|
|
133
|
+
if (!target.write(chunk))
|
|
134
|
+
await waitForDrain(target);
|
|
135
|
+
if (disconnected || target.destroyed)
|
|
136
|
+
return;
|
|
137
|
+
const next = await read;
|
|
138
|
+
if (next.done)
|
|
139
|
+
break;
|
|
140
|
+
chunk = next.value;
|
|
141
|
+
read = reader.read();
|
|
142
|
+
}
|
|
143
|
+
if (!target.destroyed)
|
|
144
|
+
target.end();
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
if (!disconnected && !target.destroyed)
|
|
148
|
+
target.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
149
|
+
}
|
|
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 {
|
|
155
|
+
reader.releaseLock();
|
|
156
|
+
}
|
|
157
|
+
catch { /* read still pending after disconnect */ }
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const PENDING = Symbol('pending');
|
|
161
|
+
/** Settles with the promise's value if it is ready before the next macrotask, else PENDING. */
|
|
162
|
+
function raceImmediate(promise) {
|
|
163
|
+
return new Promise((resolve, reject) => {
|
|
164
|
+
const timer = setImmediate(() => resolve(PENDING));
|
|
165
|
+
promise.then((value) => {
|
|
166
|
+
clearImmediate(timer);
|
|
167
|
+
resolve(value);
|
|
168
|
+
}, (error) => {
|
|
169
|
+
clearImmediate(timer);
|
|
170
|
+
reject(error);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
function waitForDrain(target) {
|
|
175
|
+
return new Promise((resolve) => {
|
|
176
|
+
const done = () => {
|
|
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
|
+
/** A {@link WsTransport} over a `ws` server socket. */
|
|
186
|
+
export function wrapNodeWebSocket(socket, options = {}) {
|
|
187
|
+
const messageListeners = new Set();
|
|
188
|
+
const closeListeners = new Set();
|
|
189
|
+
let closedWith;
|
|
190
|
+
socket.on('message', (data, isBinary) => {
|
|
191
|
+
const value = isBinary ? rawBytes(data) : rawBytes(data).toString('utf8');
|
|
192
|
+
for (const listener of [...messageListeners]) {
|
|
193
|
+
try {
|
|
194
|
+
listener(value);
|
|
195
|
+
}
|
|
196
|
+
catch { /* isolate listener failures */ }
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
socket.on('error', () => { });
|
|
200
|
+
let alive = true;
|
|
201
|
+
const keepAliveMs = options.keepAliveMs ?? 30_000;
|
|
202
|
+
const timer = keepAliveMs > 0
|
|
203
|
+
? setInterval(() => {
|
|
204
|
+
if (!alive) {
|
|
205
|
+
socket.terminate();
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
alive = false;
|
|
209
|
+
try {
|
|
210
|
+
socket.ping();
|
|
211
|
+
}
|
|
212
|
+
catch { /* closing */ }
|
|
213
|
+
}, keepAliveMs)
|
|
214
|
+
: undefined;
|
|
215
|
+
timer?.unref?.();
|
|
216
|
+
socket.on('pong', () => { alive = true; });
|
|
217
|
+
socket.once('close', (code, reason) => {
|
|
218
|
+
if (timer)
|
|
219
|
+
clearInterval(timer);
|
|
220
|
+
closedWith = { code, reason: reason.toString('utf8') };
|
|
221
|
+
messageListeners.clear();
|
|
222
|
+
for (const listener of [...closeListeners]) {
|
|
223
|
+
try {
|
|
224
|
+
listener(closedWith.code, closedWith.reason);
|
|
225
|
+
}
|
|
226
|
+
catch { /* isolate listener failures */ }
|
|
227
|
+
}
|
|
228
|
+
closeListeners.clear();
|
|
229
|
+
});
|
|
230
|
+
return {
|
|
231
|
+
send(data) {
|
|
232
|
+
if (socket.readyState !== WebSocket.OPEN)
|
|
233
|
+
return;
|
|
234
|
+
socket.send(data);
|
|
235
|
+
},
|
|
236
|
+
close(code, reason) {
|
|
237
|
+
if (socket.readyState === WebSocket.CLOSING || socket.readyState === WebSocket.CLOSED)
|
|
238
|
+
return;
|
|
239
|
+
try {
|
|
240
|
+
socket.close(code, reason);
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
socket.terminate();
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
onMessage(listener) {
|
|
247
|
+
if (closedWith)
|
|
248
|
+
return () => undefined;
|
|
249
|
+
messageListeners.add(listener);
|
|
250
|
+
return () => { messageListeners.delete(listener); };
|
|
251
|
+
},
|
|
252
|
+
onClose(listener) {
|
|
253
|
+
if (closedWith) {
|
|
254
|
+
const { code, reason } = closedWith;
|
|
255
|
+
queueMicrotask(() => listener(code, reason));
|
|
256
|
+
return () => undefined;
|
|
257
|
+
}
|
|
258
|
+
closeListeners.add(listener);
|
|
259
|
+
return () => { closeListeners.delete(listener); };
|
|
260
|
+
},
|
|
261
|
+
get bufferedAmount() {
|
|
262
|
+
return socket.bufferedAmount;
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
function rawBytes(data) {
|
|
267
|
+
if (Buffer.isBuffer(data))
|
|
268
|
+
return data;
|
|
269
|
+
if (Array.isArray(data))
|
|
270
|
+
return Buffer.concat(data);
|
|
271
|
+
return Buffer.from(data);
|
|
272
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type GenerationAdmissionBindable } from '@zhin.js/plugin-runtime';
|
|
2
|
+
import { type HttpHost } from '../http-host.js';
|
|
3
|
+
import type { HttpHostAddress, HttpHostOptions } from '../host-options.js';
|
|
4
|
+
/** @internal Process-root lifecycle and generation admission surface. */
|
|
5
|
+
export interface ProcessHttpHost extends HttpHost, GenerationAdmissionBindable<HttpHost> {
|
|
6
|
+
listen(): Promise<HttpHostAddress>;
|
|
7
|
+
close(): Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* In-process Node listener for the transport-independent router: Node requests become
|
|
11
|
+
* Web `Request`s, `Response`s are streamed back, `ws` sockets become `WsConnection`s.
|
|
12
|
+
*/
|
|
13
|
+
export declare function createHttpHost(options?: HttpHostOptions): ProcessHttpHost;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
2
|
+
import { generationAdmissionBinder, } from '@zhin.js/plugin-runtime';
|
|
3
|
+
import { createHttpRouter } from '../http-host.js';
|
|
4
|
+
import { createNodeHttpListener } from './listener.js';
|
|
5
|
+
const logger = getLogger('http');
|
|
6
|
+
/**
|
|
7
|
+
* In-process Node listener for the transport-independent router: Node requests become
|
|
8
|
+
* Web `Request`s, `Response`s are streamed back, `ws` sockets become `WsConnection`s.
|
|
9
|
+
*/
|
|
10
|
+
export function createHttpHost(options = {}) {
|
|
11
|
+
const secure = options.tls !== undefined;
|
|
12
|
+
let closeResult;
|
|
13
|
+
const router = createHttpRouter({
|
|
14
|
+
token: options.token,
|
|
15
|
+
corsOrigins: options.corsOrigins,
|
|
16
|
+
apiBase: options.apiBase,
|
|
17
|
+
authExemptPaths: options.authExemptPaths,
|
|
18
|
+
tokenRegistry: options.tokenRegistry,
|
|
19
|
+
secure,
|
|
20
|
+
address: () => listener.address,
|
|
21
|
+
});
|
|
22
|
+
const tokenRegistry = router.tokenRegistry;
|
|
23
|
+
const listener = createNodeHttpListener({
|
|
24
|
+
host: options.host,
|
|
25
|
+
port: options.port,
|
|
26
|
+
tls: options.tls,
|
|
27
|
+
trustProxy: options.trustProxy,
|
|
28
|
+
handle: (request, info) => router.handle(request, info),
|
|
29
|
+
upgrade: (request) => router.upgrade(request),
|
|
30
|
+
onListening(address) {
|
|
31
|
+
logger.info(`listening ${address.origin}`
|
|
32
|
+
+ ` | routes: ${router.routeCount}`
|
|
33
|
+
+ ` | token: ${tokenRegistry.hasAnyToken()
|
|
34
|
+
? tokenRegistry.primaryTokenPrefixForLog()
|
|
35
|
+
: '(none)'}`);
|
|
36
|
+
logger.debug(`listen detail | cors: ${router.corsOrigins.join(',')}`
|
|
37
|
+
+ ` | ws: ${router.wsPaths.join(',') || '(none)'}`);
|
|
38
|
+
},
|
|
39
|
+
onClosed() {
|
|
40
|
+
logger.debug(formatCompact({ op: 'http_host_closed' }));
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
const runtime = {
|
|
44
|
+
[generationAdmissionBinder]: (admission) => router[generationAdmissionBinder](admission),
|
|
45
|
+
ws: (pathname, wsOptions) => router.ws(pathname, wsOptions),
|
|
46
|
+
route: (method, path, handler, meta) => router.route(method, path, handler, meta),
|
|
47
|
+
listRoutes: () => router.listRoutes(),
|
|
48
|
+
get address() {
|
|
49
|
+
return listener.address;
|
|
50
|
+
},
|
|
51
|
+
get tokenRegistry() {
|
|
52
|
+
return tokenRegistry;
|
|
53
|
+
},
|
|
54
|
+
listen: () => listener.listen(),
|
|
55
|
+
close() {
|
|
56
|
+
if (closeResult)
|
|
57
|
+
return closeResult;
|
|
58
|
+
router.close();
|
|
59
|
+
closeResult = listener.close();
|
|
60
|
+
return closeResult;
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
return runtime;
|
|
64
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** What a protocol credential check needs from an HTTP request or a {@link WsConnection}. */
|
|
2
|
+
export interface ProtocolAuthRequest {
|
|
3
|
+
readonly headers: Headers;
|
|
4
|
+
readonly url: URL | string;
|
|
5
|
+
readonly remoteAddress?: string;
|
|
6
|
+
}
|
|
7
|
+
/** IPv4 / IPv6 / IPv4-mapped loopback peer. */
|
|
8
|
+
export declare function isLoopbackAddress(address: string | undefined): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Whether a listen address (`http.host`) only accepts connections from this machine:
|
|
11
|
+
* `localhost`, `127.0.0.0/8`, `::1` (bracketed or not). `0.0.0.0`, `::` and any other address are
|
|
12
|
+
* reachable from the network.
|
|
13
|
+
*/
|
|
14
|
+
export declare function isLoopbackHost(host: string | undefined): boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Access check for a plugin socket mounted with `ws(path, { auth: 'protocol' })` (adapter
|
|
17
|
+
* reverse WebSockets: OneBot, NapCat, Milky…) or a protocol webhook. With an access token the
|
|
18
|
+
* peer must present it as `Authorization: Bearer <token>` or `?access_token=<token>`
|
|
19
|
+
* (constant-time compare); without one only loopback peers are admitted, so an unconfigured
|
|
20
|
+
* socket is never public. Proxied traffic (`X-Forwarded-For` / `Forwarded` present) never counts
|
|
21
|
+
* 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
|
|
22
|
+
* `{ headers: request.headers, url: context.url, remoteAddress: context.remoteAddress }`.
|
|
23
|
+
*/
|
|
24
|
+
export declare function verifyProtocolAccessToken(accessToken: string | undefined, request: ProtocolAuthRequest): boolean;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { timingSafeEqualString } from './timing-safe-equal.js';
|
|
2
|
+
import { hasForwardingHeaders } from './trust-proxy.js';
|
|
3
|
+
/** IPv4 / IPv6 / IPv4-mapped loopback peer. */
|
|
4
|
+
export function isLoopbackAddress(address) {
|
|
5
|
+
if (!address)
|
|
6
|
+
return false;
|
|
7
|
+
if (address === '::1')
|
|
8
|
+
return true;
|
|
9
|
+
const v4 = address.startsWith('::ffff:') ? address.slice(7) : address;
|
|
10
|
+
return /^127(?:\.\d{1,3}){3}$/u.test(v4);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Whether a listen address (`http.host`) only accepts connections from this machine:
|
|
14
|
+
* `localhost`, `127.0.0.0/8`, `::1` (bracketed or not). `0.0.0.0`, `::` and any other address are
|
|
15
|
+
* reachable from the network.
|
|
16
|
+
*/
|
|
17
|
+
export function isLoopbackHost(host) {
|
|
18
|
+
if (host === undefined || host === '')
|
|
19
|
+
return true; // the listener default is 127.0.0.1
|
|
20
|
+
const bare = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
|
|
21
|
+
return bare.toLowerCase() === 'localhost' || isLoopbackAddress(bare);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Access check for a plugin socket mounted with `ws(path, { auth: 'protocol' })` (adapter
|
|
25
|
+
* reverse WebSockets: OneBot, NapCat, Milky…) or a protocol webhook. With an access token the
|
|
26
|
+
* peer must present it as `Authorization: Bearer <token>` or `?access_token=<token>`
|
|
27
|
+
* (constant-time compare); without one only loopback peers are admitted, so an unconfigured
|
|
28
|
+
* socket is never public. Proxied traffic (`X-Forwarded-For` / `Forwarded` present) never counts
|
|
29
|
+
* 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
|
|
30
|
+
* `{ headers: request.headers, url: context.url, remoteAddress: context.remoteAddress }`.
|
|
31
|
+
*/
|
|
32
|
+
export function verifyProtocolAccessToken(accessToken, request) {
|
|
33
|
+
if (!accessToken)
|
|
34
|
+
return isLoopbackAddress(request.remoteAddress) && !hasForwardingHeaders(request.headers);
|
|
35
|
+
const header = request.headers.get('authorization');
|
|
36
|
+
if (typeof header === 'string' && header.startsWith('Bearer ')
|
|
37
|
+
&& timingSafeEqualString(header.slice(7).trim(), accessToken))
|
|
38
|
+
return true;
|
|
39
|
+
let presented = null;
|
|
40
|
+
try {
|
|
41
|
+
const url = typeof request.url === 'string' ? new URL(request.url, 'http://localhost') : request.url;
|
|
42
|
+
presented = url.searchParams.get('access_token');
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
presented = null;
|
|
46
|
+
}
|
|
47
|
+
return presented !== null && timingSafeEqualString(presented, accessToken);
|
|
48
|
+
}
|
package/lib/public-api.d.ts
CHANGED
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
* Plugin-facing HTTP Host contract exposed from `@zhin.js/host-http`.
|
|
3
3
|
* @module @zhin.js/host-http
|
|
4
4
|
*/
|
|
5
|
-
export { httpHostToken, type HttpHandler, type HttpHost, type HttpHostAddress, type HttpRouteRegistration, type WsConnection, type WsHandle, } from './http-host.js';
|
|
5
|
+
export { httpHostToken, type HttpHandler, type HttpHost, type HttpHostAddress, type HttpRouteRegistration, type RouteContext, type WsConnection, type WsHandle, type WsRouteOptions, } from './http-host.js';
|
|
6
6
|
export type { AuthenticatedTokenPrincipal, AuthScope } from './token-registry.js';
|
|
7
7
|
export type { ListedRoute, OpenApiParameter, RouteMeta } from './openapi.js';
|
package/lib/token-registry.d.ts
CHANGED
|
@@ -1,25 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
export type ScopedTokenConfig = {
|
|
4
|
-
readonly token: string;
|
|
5
|
-
/**
|
|
6
|
-
* `full` is the operator. `user` is an alias of `demo`: an ordinary Console user who sees and
|
|
7
|
-
* calls only what its `permissions` / `roles` allow. Stored and reported as `demo`.
|
|
8
|
-
*/
|
|
9
|
-
readonly scope: AuthScope | 'user';
|
|
10
|
-
/** Root-configured subject binding; never accepted from an HTTP payload. */
|
|
11
|
-
readonly principalId?: string;
|
|
12
|
-
/**
|
|
13
|
-
* Console permissions held by this token (matched against Page `requiredPermissions`).
|
|
14
|
-
* Ignored for `full` scope, which already holds every Console permission.
|
|
15
|
-
*/
|
|
16
|
-
readonly permissions?: readonly string[];
|
|
17
|
-
/** Console roles held by this token (matched against Page `requiredRoles`). */
|
|
18
|
-
readonly roles?: readonly string[];
|
|
19
|
-
};
|
|
1
|
+
import { type ConsolePluginPermissionInfo, type ConsolePluginPermissionLevelResolver } from '@zhin.js/console-protocol';
|
|
2
|
+
import { type ConsoleAccessProvider, type ConsolePrincipal, type ConsoleRequestMeta } from './access-control.js';
|
|
20
3
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
4
|
+
* Scope a Host handler runs with. Every admitted request is `full`: authorization happens
|
|
5
|
+
* before dispatch against the request's `ConsolePrincipal` (ADR 0049).
|
|
6
|
+
*/
|
|
7
|
+
export type AuthScope = 'full';
|
|
8
|
+
/**
|
|
9
|
+
* What a Console caller may see. `unrestricted` is an open Host before setup; otherwise only
|
|
10
|
+
* the listed permissions and roles count.
|
|
23
11
|
*/
|
|
24
12
|
export interface ConsoleAccess {
|
|
25
13
|
readonly unrestricted: boolean;
|
|
@@ -33,29 +21,59 @@ export interface AuthenticatedTokenPrincipal {
|
|
|
33
21
|
readonly scope: AuthScope;
|
|
34
22
|
}
|
|
35
23
|
export type TokenRegistryConfig = {
|
|
24
|
+
/** `http.token`: the bootstrap credential, valid only until the first owner exists. */
|
|
36
25
|
readonly primaryToken?: string;
|
|
37
|
-
readonly scopedTokens?: readonly ScopedTokenConfig[];
|
|
38
|
-
};
|
|
39
|
-
export type DynamicTokenConfig = ScopedTokenConfig & {
|
|
40
|
-
/** Unix epoch milliseconds; omitted credentials remain valid until explicitly revoked. */
|
|
41
|
-
readonly expiresAt?: number;
|
|
42
26
|
};
|
|
27
|
+
/**
|
|
28
|
+
* Bearer authentication for the Host (ADR 0049).
|
|
29
|
+
* - Before setup: no credential configured → open; `http.token` → bootstrap (setup only).
|
|
30
|
+
* - After setup (an enabled global owner exists): only user sessions and API tokens from the
|
|
31
|
+
* {@link ConsoleAccessProvider}; `http.token` is rejected everywhere.
|
|
32
|
+
*/
|
|
43
33
|
export declare class TokenRegistry {
|
|
44
34
|
#private;
|
|
45
35
|
constructor(config?: TokenRegistryConfig);
|
|
46
|
-
|
|
47
|
-
|
|
36
|
+
/** Installs the user/session backend; returns an idempotent uninstaller. */
|
|
37
|
+
useAccessProvider(provider: ConsoleAccessProvider): () => void;
|
|
38
|
+
get accessProvider(): ConsoleAccessProvider | undefined;
|
|
48
39
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
40
|
+
* Installs the plugin permission catalog source (the runtime composition root reads it from
|
|
41
|
+
* the current generation); returns an idempotent uninstaller.
|
|
51
42
|
*/
|
|
52
|
-
|
|
43
|
+
usePluginPermissions(source: () => readonly ConsolePluginPermissionInfo[]): () => void;
|
|
44
|
+
/** Plugin permissions the loaded plugins use or declare, with their levels (empty when none). */
|
|
45
|
+
pluginPermissions(): readonly ConsolePluginPermissionInfo[];
|
|
46
|
+
/** Declared-or-inferred level lookup over {@link pluginPermissions}. */
|
|
47
|
+
pluginPermissionLevels(): ConsolePluginPermissionLevelResolver;
|
|
48
|
+
/** An enabled global owner exists. */
|
|
49
|
+
initialized(): boolean;
|
|
50
|
+
/** Whether `/api` requires a credential (a bootstrap token is configured, or setup is done). */
|
|
53
51
|
hasAnyToken(): boolean;
|
|
54
|
-
/**
|
|
55
|
-
|
|
56
|
-
|
|
52
|
+
/** Whether `token` is the bootstrap token and still accepted (setup not done). */
|
|
53
|
+
isBootstrapToken(token: string): boolean;
|
|
54
|
+
/** Resolves a bearer to a principal; null when it is not (or no longer) valid. */
|
|
55
|
+
authenticate(token: string, meta?: ConsoleRequestMeta): Promise<ConsolePrincipal | null>;
|
|
56
|
+
/**
|
|
57
|
+
* Bearer check for non-Console protocol surfaces (MCP, A2A): the bootstrap token before
|
|
58
|
+
* setup, or a user credential holding `permission` globally. An open Host yields false so
|
|
59
|
+
* the surface keeps its own unauthenticated (loopback) policy.
|
|
60
|
+
*/
|
|
61
|
+
authorizeBearer(token: string, permission: string, meta?: ConsoleRequestMeta): Promise<boolean>;
|
|
62
|
+
/**
|
|
63
|
+
* {@link authorizeBearer} for a principal already resolved from the request's bearer (by this
|
|
64
|
+
* registry, or by the gateway that forwarded the request): the bootstrap token before setup,
|
|
65
|
+
* or a user credential holding `permission` globally. An open Host yields false.
|
|
66
|
+
*/
|
|
67
|
+
authorizePrincipal(principal: ConsolePrincipal | null | undefined, permission: string): boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Synchronous explicit-credential check: `full` only for the still-valid bootstrap token.
|
|
70
|
+
* User credentials need {@link authenticate} (or the request's bound principal).
|
|
71
|
+
*/
|
|
72
|
+
resolve(token: string): AuthScope | null;
|
|
73
|
+
/** Console access for a resolved principal. */
|
|
74
|
+
accessFor(principal: ConsolePrincipal | null | undefined): ConsoleAccess;
|
|
75
|
+
/** Synchronous legacy access: only an open Host is unrestricted. */
|
|
76
|
+
resolveAccess(_token: string): ConsoleAccess;
|
|
57
77
|
primaryTokenPrefixForLog(): string;
|
|
58
78
|
}
|
|
59
|
-
/** WebSocket upgrade paths allowed for demo scope. */
|
|
60
|
-
export declare function isDemoWebSocketPath(pathname: string): boolean;
|
|
61
79
|
export declare function extractBearerToken(authorization: string | undefined, queryToken: string | null): string;
|