@zhin.js/host-http 1.1.16 → 1.2.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 +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 +5 -1
- package/lib/console-plugin-rpc.js +5 -40
- 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 -14
- 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 +10 -5
- package/lib/index.js +9 -5
- 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 +5 -51
- 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 -19
- 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 +55 -13
- 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,574 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway ↔ instance control channel protocol (ADR 0054 §3).
|
|
3
|
+
*
|
|
4
|
+
* The channel is the Node child-process IPC channel opened by the gateway with
|
|
5
|
+
* `serialization: 'advanced'` (structured clone: `Uint8Array` travels as is). Every message is
|
|
6
|
+
* `{ type, id?, payload }`. Both sides import this module so the message shapes, limits and the
|
|
7
|
+
* protocol version stay in one place.
|
|
8
|
+
*
|
|
9
|
+
* Streams
|
|
10
|
+
* - HTTP exchanges and WebSocket connections are identified by a gateway-assigned `id`.
|
|
11
|
+
* - Bodies travel in chunks of at most {@link BridgeLimits.maxChunkBytes}. Flow control is
|
|
12
|
+
* credit based: each direction of a stream starts with {@link BridgeLimits.initialCreditBytes}
|
|
13
|
+
* of credit; the receiver grants more (`http.request.credit` / `http.response.credit`) as its
|
|
14
|
+
* consumer reads. A sender never has more than the window in flight, so memory per stream is
|
|
15
|
+
* bounded on both ends. A receiver that gets more than it granted fails the stream.
|
|
16
|
+
* - WebSocket frames are whole messages (at most {@link BridgeLimits.maxWsMessageBytes}); the
|
|
17
|
+
* receiver acknowledges delivered bytes with `ws.drain`, which drives `bufferedAmount`.
|
|
18
|
+
* - Either side cancels: `http.request.abort` (gateway: client went away / timeout),
|
|
19
|
+
* `http.response.error` (instance: handler or body failed), `ws.close` (both).
|
|
20
|
+
*
|
|
21
|
+
* This module must not import `node:http` / `ws`: the instance loads it.
|
|
22
|
+
*/
|
|
23
|
+
import type { ConsolePluginPermissionInfo } from '@zhin.js/console-protocol';
|
|
24
|
+
import type { ConsoleAuditEntry, ConsolePrincipal } from '../access-control.js';
|
|
25
|
+
import type { HttpHostAddress } from '../host-options.js';
|
|
26
|
+
import type { RouteMeta } from '../openapi.js';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Bumped on any incompatible change to the control channel messages.
|
|
30
|
+
* - 1: `ready` / `restart-request` / `shutdown` (B1.1).
|
|
31
|
+
* - 2: route table, HTTP streams, WebSocket frames, forwarded principal (B1.2); interim delegated
|
|
32
|
+
* credential resolution (`auth.*`).
|
|
33
|
+
* - 3: Console accounts live in the gateway (B1.4): `auth.*` removed; `catalog` (plugin permission
|
|
34
|
+
* catalog) and `audit` (instance-side audit entries) from the instance; `principal-revoked`
|
|
35
|
+
* from the gateway.
|
|
36
|
+
*/
|
|
37
|
+
export const INSTANCE_BRIDGE_PROTOCOL_VERSION = 3;
|
|
38
|
+
|
|
39
|
+
/** Protocol versions this build can talk to. */
|
|
40
|
+
export const SUPPORTED_BRIDGE_PROTOCOL_VERSIONS: readonly number[] = Object.freeze([INSTANCE_BRIDGE_PROTOCOL_VERSION]);
|
|
41
|
+
|
|
42
|
+
export function isBridgeProtocolCompatible(version: number): boolean {
|
|
43
|
+
return SUPPORTED_BRIDGE_PROTOCOL_VERSIONS.includes(version);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Set by the gateway on the instance child process; marks the process as a supervised instance.
|
|
48
|
+
* Its value is the gateway's {@link INSTANCE_BRIDGE_PROTOCOL_VERSION}, so the instance can
|
|
49
|
+
* refuse an incompatible gateway before it starts anything.
|
|
50
|
+
*/
|
|
51
|
+
export const INSTANCE_CHILD_ENV = 'ZHIN_RUNTIME_CHILD';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Set by the gateway on the instance child process: the gateway's local control endpoint (socket
|
|
55
|
+
* path — including the `os.tmpdir()` fallback for deep project roots — or named pipe). Whoever
|
|
56
|
+
* connects to it is the local owner (ADR 0054 §6), so the instance keeps the Agent's file and
|
|
57
|
+
* shell tools away from it. Absent when the gateway runs without a local socket.
|
|
58
|
+
*/
|
|
59
|
+
export const GATEWAY_SOCKET_ENV = 'ZHIN_GATEWAY_SOCKET';
|
|
60
|
+
|
|
61
|
+
export class BridgeProtocolMismatchError extends Error {
|
|
62
|
+
readonly code = 'ZHIN_BRIDGE_PROTOCOL_MISMATCH';
|
|
63
|
+
constructor(readonly peerVersion: number, readonly side: 'gateway' | 'instance') {
|
|
64
|
+
super(`${side === 'gateway' ? 'Gateway' : 'Instance'} control channel protocol ${peerVersion} is not supported `
|
|
65
|
+
+ `(this build speaks ${SUPPORTED_BRIDGE_PROTOCOL_VERSIONS.join(', ')}); upgrade zhin.js and @zhin.js/gateway together`);
|
|
66
|
+
this.name = 'BridgeProtocolMismatchError';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Per-frame size limits and stream windows. Both ends use the same defaults. */
|
|
71
|
+
export interface BridgeLimits {
|
|
72
|
+
/** Largest body chunk in one message. */
|
|
73
|
+
readonly maxChunkBytes: number;
|
|
74
|
+
/** Credit each stream direction starts with (also the most a receiver buffers). */
|
|
75
|
+
readonly initialCreditBytes: number;
|
|
76
|
+
/** Request line + headers (names and values) of one request or response head. */
|
|
77
|
+
readonly maxHeadBytes: number;
|
|
78
|
+
/** Largest WebSocket message (text is measured as UTF-16 length × 3 upper bound). */
|
|
79
|
+
readonly maxWsMessageBytes: number;
|
|
80
|
+
/** Unacknowledged WebSocket bytes a sender queues before closing with 1013. */
|
|
81
|
+
readonly maxWsBufferedBytes: number;
|
|
82
|
+
/** Concurrent HTTP exchanges + WebSocket connections per instance. */
|
|
83
|
+
readonly maxStreams: number;
|
|
84
|
+
/** Entries in a route table. */
|
|
85
|
+
readonly maxRoutes: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const DEFAULT_BRIDGE_LIMITS: BridgeLimits = Object.freeze({
|
|
89
|
+
maxChunkBytes: 64 * 1024,
|
|
90
|
+
initialCreditBytes: 256 * 1024,
|
|
91
|
+
maxHeadBytes: 64 * 1024,
|
|
92
|
+
maxWsMessageBytes: 16 * 1024 * 1024,
|
|
93
|
+
maxWsBufferedBytes: 32 * 1024 * 1024,
|
|
94
|
+
maxStreams: 4096,
|
|
95
|
+
maxRoutes: 4096,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/** Gateway: time to wait for a response head before failing with `timeout`. */
|
|
99
|
+
export const DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS = 120_000;
|
|
100
|
+
/** Gateway: time to wait for `ws.accepted` / `ws.rejected`. */
|
|
101
|
+
export const DEFAULT_BRIDGE_WS_OPEN_TIMEOUT_MS = 30_000;
|
|
102
|
+
|
|
103
|
+
const MAX_ID_LENGTH = 64;
|
|
104
|
+
const MAX_TEXT_LENGTH = 1024;
|
|
105
|
+
|
|
106
|
+
export interface BridgeMessage<Type extends string, Payload> {
|
|
107
|
+
readonly type: Type;
|
|
108
|
+
readonly id?: string;
|
|
109
|
+
readonly payload: Payload;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A message that belongs to one stream (`id` is required). */
|
|
113
|
+
export interface BridgeStreamMessage<Type extends string, Payload> extends BridgeMessage<Type, Payload> {
|
|
114
|
+
readonly id: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Header list; repeated names (e.g. `set-cookie`) stay separate. */
|
|
118
|
+
export type BridgeHeaders = readonly (readonly [string, string])[];
|
|
119
|
+
|
|
120
|
+
// ---- route table
|
|
121
|
+
|
|
122
|
+
export interface BridgeHttpRoute {
|
|
123
|
+
readonly method: string;
|
|
124
|
+
/** `/path` or `/prefix/*`. */
|
|
125
|
+
readonly pattern: string;
|
|
126
|
+
readonly meta?: RouteMeta;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface BridgeWsRoute {
|
|
130
|
+
readonly path: string;
|
|
131
|
+
/** `host`: the gateway authenticates before upgrade; `protocol`: the route authenticates itself. */
|
|
132
|
+
readonly auth: 'host' | 'protocol';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Routes the instance currently serves. The gateway authenticates `{apiBase}` routes and
|
|
137
|
+
* `auth: 'host'` sockets and forwards the rest unauthenticated (ADR 0054 §3).
|
|
138
|
+
*/
|
|
139
|
+
export interface BridgeRouteTable {
|
|
140
|
+
/** Increases with every table the instance sends. */
|
|
141
|
+
readonly revision: number;
|
|
142
|
+
/** Committed Runtime generation, when known. */
|
|
143
|
+
readonly generation?: number;
|
|
144
|
+
readonly apiBase: string;
|
|
145
|
+
readonly http: readonly BridgeHttpRoute[];
|
|
146
|
+
readonly ws: readonly BridgeWsRoute[];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export const EMPTY_ROUTE_TABLE: BridgeRouteTable = Object.freeze({
|
|
150
|
+
revision: 0,
|
|
151
|
+
apiBase: '/api',
|
|
152
|
+
http: Object.freeze([]),
|
|
153
|
+
ws: Object.freeze([]),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ---- instance → gateway
|
|
157
|
+
|
|
158
|
+
export interface InstanceReadyPayload {
|
|
159
|
+
/** {@link INSTANCE_BRIDGE_PROTOCOL_VERSION} the instance was built with. */
|
|
160
|
+
readonly version: number;
|
|
161
|
+
/** Committed Runtime generation at the time the instance became ready. */
|
|
162
|
+
readonly generation?: number;
|
|
163
|
+
readonly pid: number;
|
|
164
|
+
/** Route table at ready time (also sent earlier with `routes`). */
|
|
165
|
+
readonly routes?: BridgeRouteTable;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface InstanceRestartRequestPayload {
|
|
169
|
+
/** Short machine-readable reason, e.g. `console`, `dependencies`, `self-change`. */
|
|
170
|
+
readonly reason: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface HttpResponseHeadPayload {
|
|
174
|
+
readonly status: number;
|
|
175
|
+
readonly statusText?: string;
|
|
176
|
+
readonly headers: BridgeHeaders;
|
|
177
|
+
/** False for a bodiless response: no `http.response.body` follows, `http.response.end` does. */
|
|
178
|
+
readonly body: boolean;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface BodyChunkPayload { readonly chunk: Uint8Array }
|
|
182
|
+
export interface CreditPayload { readonly bytes: number }
|
|
183
|
+
export interface StreamErrorPayload { readonly message: string }
|
|
184
|
+
export interface WsRejectedPayload { readonly status: 401 | 403 | 404 | 503 }
|
|
185
|
+
export interface WsMessagePayload { readonly data: string | Uint8Array }
|
|
186
|
+
export interface WsClosePayload { readonly code: number; readonly reason: string }
|
|
187
|
+
|
|
188
|
+
export type InstanceReadyMessage = BridgeMessage<'ready', InstanceReadyPayload>;
|
|
189
|
+
export type InstanceRestartRequestMessage = BridgeMessage<'restart-request', InstanceRestartRequestPayload>;
|
|
190
|
+
export type InstanceRoutesMessage = BridgeMessage<'routes', { readonly table: BridgeRouteTable }>;
|
|
191
|
+
export type HttpResponseHeadMessage = BridgeStreamMessage<'http.response.head', HttpResponseHeadPayload>;
|
|
192
|
+
export type HttpResponseBodyMessage = BridgeStreamMessage<'http.response.body', BodyChunkPayload>;
|
|
193
|
+
export type HttpResponseEndMessage = BridgeStreamMessage<'http.response.end', Record<string, never>>;
|
|
194
|
+
export type HttpResponseErrorMessage = BridgeStreamMessage<'http.response.error', StreamErrorPayload>;
|
|
195
|
+
export type HttpRequestCreditMessage = BridgeStreamMessage<'http.request.credit', CreditPayload>;
|
|
196
|
+
export type WsAcceptedMessage = BridgeStreamMessage<'ws.accepted', Record<string, never>>;
|
|
197
|
+
export type WsRejectedMessage = BridgeStreamMessage<'ws.rejected', WsRejectedPayload>;
|
|
198
|
+
export type WsMessageMessage = BridgeStreamMessage<'ws.message', WsMessagePayload>;
|
|
199
|
+
export type WsCloseMessage = BridgeStreamMessage<'ws.close', WsClosePayload>;
|
|
200
|
+
export type WsDrainMessage = BridgeStreamMessage<'ws.drain', CreditPayload>;
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Plugin permission catalog of the instance's current generation (ADR 0049 amendment): the
|
|
204
|
+
* gateway uses it for level grants in `/auth/me`, `iam.roles.list` and its own authorization.
|
|
205
|
+
* Sent on listen and whenever it changes (generation commit).
|
|
206
|
+
*/
|
|
207
|
+
export interface InstanceCatalogPayload {
|
|
208
|
+
readonly pluginPermissions: readonly ConsolePluginPermissionInfo[];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** An audit entry written by the instance (its RPC dispatcher); the gateway owns the audit log. */
|
|
212
|
+
export interface InstanceAuditPayload {
|
|
213
|
+
readonly entry: ConsoleAuditEntry;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export type InstanceCatalogMessage = BridgeMessage<'catalog', InstanceCatalogPayload>;
|
|
217
|
+
export type InstanceAuditMessage = BridgeMessage<'audit', InstanceAuditPayload>;
|
|
218
|
+
|
|
219
|
+
export type InstanceToGatewayMessage =
|
|
220
|
+
| InstanceReadyMessage
|
|
221
|
+
| InstanceCatalogMessage
|
|
222
|
+
| InstanceAuditMessage
|
|
223
|
+
| InstanceRestartRequestMessage
|
|
224
|
+
| InstanceRoutesMessage
|
|
225
|
+
| HttpResponseHeadMessage
|
|
226
|
+
| HttpResponseBodyMessage
|
|
227
|
+
| HttpResponseEndMessage
|
|
228
|
+
| HttpResponseErrorMessage
|
|
229
|
+
| HttpRequestCreditMessage
|
|
230
|
+
| WsAcceptedMessage
|
|
231
|
+
| WsRejectedMessage
|
|
232
|
+
| WsMessageMessage
|
|
233
|
+
| WsCloseMessage
|
|
234
|
+
| WsDrainMessage;
|
|
235
|
+
|
|
236
|
+
// ---- gateway → instance
|
|
237
|
+
|
|
238
|
+
export interface GatewayShutdownPayload {
|
|
239
|
+
/** Why the gateway stops the instance, e.g. `stop`, `restart`, `protocol-mismatch`. */
|
|
240
|
+
readonly reason: string;
|
|
241
|
+
/** Graceful stop budget; the gateway escalates to SIGTERM / SIGKILL after it. */
|
|
242
|
+
readonly budgetMs: number;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export interface HttpRequestPayload {
|
|
246
|
+
readonly method: string;
|
|
247
|
+
/** Absolute URL as the gateway received it. */
|
|
248
|
+
readonly url: string;
|
|
249
|
+
readonly headers: BridgeHeaders;
|
|
250
|
+
/** True when `http.request.body` chunks (then `http.request.end`) follow. */
|
|
251
|
+
readonly body: boolean;
|
|
252
|
+
/** Principal the gateway authenticated (ADR 0054 §3); authorization happens in the instance. */
|
|
253
|
+
readonly principal?: ConsolePrincipal;
|
|
254
|
+
readonly remoteAddress?: string;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export interface WsOpenPayload {
|
|
258
|
+
readonly url: string;
|
|
259
|
+
readonly headers: BridgeHeaders;
|
|
260
|
+
readonly principal?: ConsolePrincipal;
|
|
261
|
+
readonly remoteAddress?: string;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* A user's credentials were revoked or narrowed (disabled, deleted, password changed, bindings
|
|
266
|
+
* changed): the instance closes that principal's live connections (SSE, WebSocket).
|
|
267
|
+
*/
|
|
268
|
+
export interface PrincipalRevokedPayload {
|
|
269
|
+
/** `ConsoleUserPrincipal.userId`. */
|
|
270
|
+
readonly principalId: string;
|
|
271
|
+
readonly reason?: string;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export type GatewayShutdownMessage = BridgeMessage<'shutdown', GatewayShutdownPayload>;
|
|
275
|
+
export type GatewayPrincipalRevokedMessage = BridgeMessage<'principal-revoked', PrincipalRevokedPayload>;
|
|
276
|
+
export type GatewayAddressesMessage = BridgeMessage<'host.addresses', { readonly addresses: readonly HttpHostAddress[] }>;
|
|
277
|
+
export type HttpRequestMessage = BridgeStreamMessage<'http.request', HttpRequestPayload>;
|
|
278
|
+
export type HttpRequestBodyMessage = BridgeStreamMessage<'http.request.body', BodyChunkPayload>;
|
|
279
|
+
export type HttpRequestEndMessage = BridgeStreamMessage<'http.request.end', Record<string, never>>;
|
|
280
|
+
export type HttpRequestAbortMessage = BridgeStreamMessage<'http.request.abort', StreamErrorPayload>;
|
|
281
|
+
export type HttpResponseCreditMessage = BridgeStreamMessage<'http.response.credit', CreditPayload>;
|
|
282
|
+
export type WsOpenMessage = BridgeStreamMessage<'ws.open', WsOpenPayload>;
|
|
283
|
+
|
|
284
|
+
export type GatewayToInstanceMessage =
|
|
285
|
+
| GatewayShutdownMessage
|
|
286
|
+
| GatewayPrincipalRevokedMessage
|
|
287
|
+
| GatewayAddressesMessage
|
|
288
|
+
| HttpRequestMessage
|
|
289
|
+
| HttpRequestBodyMessage
|
|
290
|
+
| HttpRequestEndMessage
|
|
291
|
+
| HttpRequestAbortMessage
|
|
292
|
+
| HttpResponseCreditMessage
|
|
293
|
+
| WsOpenMessage
|
|
294
|
+
| WsMessageMessage
|
|
295
|
+
| WsCloseMessage
|
|
296
|
+
| WsDrainMessage;
|
|
297
|
+
|
|
298
|
+
// ---- validation (messages are untrusted on both ends)
|
|
299
|
+
|
|
300
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
301
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function isFiniteNonNegative(value: unknown): value is number {
|
|
305
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function isText(value: unknown, max = MAX_TEXT_LENGTH): value is string {
|
|
309
|
+
return typeof value === 'string' && value.length <= max;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function hasValidId(message: Record<string, unknown>): boolean {
|
|
313
|
+
return message.id === undefined || (typeof message.id === 'string' && message.id.length > 0 && message.id.length <= MAX_ID_LENGTH);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function hasStreamId(message: Record<string, unknown>): boolean {
|
|
317
|
+
return typeof message.id === 'string' && message.id.length > 0 && message.id.length <= MAX_ID_LENGTH;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Bytes a head occupies (UTF-16 length; ASCII headers are exact). */
|
|
321
|
+
export function headSize(url: string, headers: BridgeHeaders): number {
|
|
322
|
+
let size = url.length;
|
|
323
|
+
for (const [name, value] of headers) size += name.length + value.length + 4;
|
|
324
|
+
return size;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function isHeaders(value: unknown, limits: BridgeLimits, extra = 0): value is BridgeHeaders {
|
|
328
|
+
if (!Array.isArray(value)) return false;
|
|
329
|
+
let size = extra;
|
|
330
|
+
for (const entry of value) {
|
|
331
|
+
if (!Array.isArray(entry) || entry.length !== 2) return false;
|
|
332
|
+
if (typeof entry[0] !== 'string' || typeof entry[1] !== 'string') return false;
|
|
333
|
+
size += entry[0].length + entry[1].length + 4;
|
|
334
|
+
if (size > limits.maxHeadBytes) return false;
|
|
335
|
+
}
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function isChunk(value: unknown, limits: BridgeLimits): value is Uint8Array {
|
|
340
|
+
return value instanceof Uint8Array && value.byteLength <= limits.maxChunkBytes;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function isCredit(payload: Record<string, unknown>): boolean {
|
|
344
|
+
return Number.isSafeInteger(payload.bytes) && (payload.bytes as number) > 0;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Upper bound of the UTF-8 size of a text frame. */
|
|
348
|
+
export function wsMessageSize(data: string | Uint8Array): number {
|
|
349
|
+
return typeof data === 'string' ? data.length * 3 : data.byteLength;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function isWsData(value: unknown, limits: BridgeLimits): value is string | Uint8Array {
|
|
353
|
+
if (typeof value === 'string') return value.length <= limits.maxWsMessageBytes;
|
|
354
|
+
return value instanceof Uint8Array && value.byteLength <= limits.maxWsMessageBytes;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function isWsClose(payload: Record<string, unknown>): boolean {
|
|
358
|
+
return Number.isSafeInteger(payload.code) && (payload.code as number) >= 1000 && (payload.code as number) <= 4999
|
|
359
|
+
&& isText(payload.reason, 123);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function isAddress(value: unknown): value is HttpHostAddress {
|
|
363
|
+
if (!isRecord(value)) return false;
|
|
364
|
+
return isText(value.host) && Number.isSafeInteger(value.port) && (value.protocol === 'http' || value.protocol === 'https')
|
|
365
|
+
&& typeof value.secure === 'boolean' && isText(value.origin);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Validates a principal forwarded by the gateway; `undefined` when malformed. */
|
|
369
|
+
export function parseBridgePrincipal(value: unknown): ConsolePrincipal | undefined {
|
|
370
|
+
if (!isRecord(value)) return undefined;
|
|
371
|
+
if (value.kind === 'bootstrap') {
|
|
372
|
+
return typeof value.open === 'boolean' ? Object.freeze({ kind: 'bootstrap', open: value.open }) : undefined;
|
|
373
|
+
}
|
|
374
|
+
if (value.kind !== 'user' && value.kind !== 'api-token') return undefined;
|
|
375
|
+
if (!isText(value.userId) || !isText(value.username) || !isText(value.displayName) || !isText(value.credentialId)) return undefined;
|
|
376
|
+
if (!Array.isArray(value.roles) || !value.roles.every((role) => isText(role))) return undefined;
|
|
377
|
+
if (!Array.isArray(value.grants) || value.grants.length > 1024) return undefined;
|
|
378
|
+
const grants = [];
|
|
379
|
+
for (const grant of value.grants) {
|
|
380
|
+
if (!isRecord(grant) || !isRecord(grant.scope)) return undefined;
|
|
381
|
+
if (grant.role !== undefined && !isText(grant.role)) return undefined;
|
|
382
|
+
const scope = grant.scope;
|
|
383
|
+
let parsedScope;
|
|
384
|
+
if (scope.kind === 'global') parsedScope = Object.freeze({ kind: 'global' as const });
|
|
385
|
+
else if ((scope.kind === 'adapter' || scope.kind === 'endpoint') && isText(scope.key)) {
|
|
386
|
+
parsedScope = Object.freeze({ kind: scope.kind, key: scope.key });
|
|
387
|
+
} else return undefined;
|
|
388
|
+
if (!Array.isArray(grant.permissions) || !grant.permissions.every((permission) => isText(permission))) return undefined;
|
|
389
|
+
grants.push(Object.freeze({
|
|
390
|
+
...(grant.role !== undefined ? { role: grant.role as string } : {}),
|
|
391
|
+
scope: parsedScope,
|
|
392
|
+
permissions: Object.freeze([...grant.permissions as string[]]),
|
|
393
|
+
}));
|
|
394
|
+
}
|
|
395
|
+
return Object.freeze({
|
|
396
|
+
kind: value.kind,
|
|
397
|
+
userId: value.userId,
|
|
398
|
+
username: value.username,
|
|
399
|
+
displayName: value.displayName,
|
|
400
|
+
credentialId: value.credentialId,
|
|
401
|
+
grants: Object.freeze(grants),
|
|
402
|
+
roles: Object.freeze([...value.roles as string[]]),
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function isRouteTable(value: unknown, limits: BridgeLimits): value is BridgeRouteTable {
|
|
407
|
+
if (!isRecord(value)) return false;
|
|
408
|
+
if (!Number.isSafeInteger(value.revision) || (value.revision as number) < 0) return false;
|
|
409
|
+
if (value.generation !== undefined && !isFiniteNonNegative(value.generation)) return false;
|
|
410
|
+
if (!isText(value.apiBase)) return false;
|
|
411
|
+
if (!Array.isArray(value.http) || !Array.isArray(value.ws)) return false;
|
|
412
|
+
if (value.http.length + value.ws.length > limits.maxRoutes) return false;
|
|
413
|
+
for (const route of value.http) {
|
|
414
|
+
if (!isRecord(route) || !isText(route.method, 16) || !isText(route.pattern, 2048)) return false;
|
|
415
|
+
if (route.meta !== undefined && !isRecord(route.meta)) return false;
|
|
416
|
+
}
|
|
417
|
+
for (const route of value.ws) {
|
|
418
|
+
if (!isRecord(route) || !isText(route.path, 2048) || (route.auth !== 'host' && route.auth !== 'protocol')) return false;
|
|
419
|
+
}
|
|
420
|
+
return true;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const MAX_CATALOG_ENTRIES = 4096;
|
|
424
|
+
const PLUGIN_PERMISSION_LEVELS: ReadonlySet<unknown> = new Set(['read', 'operate', 'manage']);
|
|
425
|
+
|
|
426
|
+
function isCatalogEntry(value: unknown): value is ConsolePluginPermissionInfo {
|
|
427
|
+
if (!isRecord(value)) return false;
|
|
428
|
+
if (!isText(value.id, 256) || !PLUGIN_PERMISSION_LEVELS.has(value.level) || typeof value.declared !== 'boolean') return false;
|
|
429
|
+
if (value.description !== undefined && !isText(value.description)) return false;
|
|
430
|
+
return Array.isArray(value.owners) && value.owners.length <= 256 && value.owners.every((owner) => isText(owner, 256));
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function isAuditEntry(value: unknown): value is ConsoleAuditEntry {
|
|
434
|
+
if (!isRecord(value)) return false;
|
|
435
|
+
if (!isText(value.actor, 256) || !isText(value.action, 512)) return false;
|
|
436
|
+
if (value.outcome !== 'ok' && value.outcome !== 'denied' && value.outcome !== 'error') return false;
|
|
437
|
+
if (value.target !== undefined && !isText(value.target, 512)) return false;
|
|
438
|
+
if (value.ip !== undefined && !isText(value.ip, 256)) return false;
|
|
439
|
+
if (value.detail !== undefined && !isRecord(value.detail)) return false;
|
|
440
|
+
return true;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function isOptionalPrincipal(value: unknown): boolean {
|
|
444
|
+
return value === undefined || parseBridgePrincipal(value) !== undefined;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function isOptionalRemote(value: unknown): boolean {
|
|
448
|
+
return value === undefined || isText(value, 256);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Validates an untrusted message received from the instance. */
|
|
452
|
+
export function parseInstanceMessage(
|
|
453
|
+
value: unknown,
|
|
454
|
+
limits: BridgeLimits = DEFAULT_BRIDGE_LIMITS,
|
|
455
|
+
): InstanceToGatewayMessage | undefined {
|
|
456
|
+
if (!isRecord(value) || !hasValidId(value) || !isRecord(value.payload)) return undefined;
|
|
457
|
+
const payload = value.payload;
|
|
458
|
+
switch (value.type) {
|
|
459
|
+
case 'ready':
|
|
460
|
+
if (!Number.isSafeInteger(payload.version)) return undefined;
|
|
461
|
+
if (!Number.isSafeInteger(payload.pid)) return undefined;
|
|
462
|
+
if (payload.generation !== undefined && !isFiniteNonNegative(payload.generation)) return undefined;
|
|
463
|
+
if (payload.routes !== undefined && !isRouteTable(payload.routes, limits)) return undefined;
|
|
464
|
+
return value as unknown as InstanceReadyMessage;
|
|
465
|
+
case 'restart-request':
|
|
466
|
+
if (typeof payload.reason !== 'string') return undefined;
|
|
467
|
+
return value as unknown as InstanceRestartRequestMessage;
|
|
468
|
+
case 'routes':
|
|
469
|
+
return isRouteTable(payload.table, limits) ? value as unknown as InstanceRoutesMessage : undefined;
|
|
470
|
+
case 'catalog': {
|
|
471
|
+
const list = payload.pluginPermissions;
|
|
472
|
+
if (!Array.isArray(list) || list.length > MAX_CATALOG_ENTRIES || !list.every(isCatalogEntry)) return undefined;
|
|
473
|
+
return value as unknown as InstanceCatalogMessage;
|
|
474
|
+
}
|
|
475
|
+
case 'audit':
|
|
476
|
+
return isAuditEntry(payload.entry) ? value as unknown as InstanceAuditMessage : undefined;
|
|
477
|
+
default:
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
if (!hasStreamId(value)) return undefined;
|
|
481
|
+
switch (value.type) {
|
|
482
|
+
case 'http.response.head':
|
|
483
|
+
if (!Number.isSafeInteger(payload.status) || (payload.status as number) < 200 || (payload.status as number) > 599) return undefined;
|
|
484
|
+
if (payload.statusText !== undefined && !isText(payload.statusText)) return undefined;
|
|
485
|
+
if (typeof payload.body !== 'boolean' || !isHeaders(payload.headers, limits)) return undefined;
|
|
486
|
+
return value as unknown as HttpResponseHeadMessage;
|
|
487
|
+
case 'http.response.body':
|
|
488
|
+
return isChunk(payload.chunk, limits) ? value as unknown as HttpResponseBodyMessage : undefined;
|
|
489
|
+
case 'http.response.end':
|
|
490
|
+
case 'ws.accepted':
|
|
491
|
+
return value as unknown as InstanceToGatewayMessage;
|
|
492
|
+
case 'http.response.error':
|
|
493
|
+
return isText(payload.message) ? value as unknown as HttpResponseErrorMessage : undefined;
|
|
494
|
+
case 'http.request.credit':
|
|
495
|
+
case 'ws.drain':
|
|
496
|
+
return isCredit(payload) ? value as unknown as InstanceToGatewayMessage : undefined;
|
|
497
|
+
case 'ws.rejected':
|
|
498
|
+
return [401, 403, 404, 503].includes(payload.status as number) ? value as unknown as WsRejectedMessage : undefined;
|
|
499
|
+
case 'ws.message':
|
|
500
|
+
return isWsData(payload.data, limits) ? value as unknown as WsMessageMessage : undefined;
|
|
501
|
+
case 'ws.close':
|
|
502
|
+
return isWsClose(payload) ? value as unknown as WsCloseMessage : undefined;
|
|
503
|
+
default:
|
|
504
|
+
return undefined;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** Validates an untrusted message received from the gateway. */
|
|
509
|
+
export function parseGatewayMessage(
|
|
510
|
+
value: unknown,
|
|
511
|
+
limits: BridgeLimits = DEFAULT_BRIDGE_LIMITS,
|
|
512
|
+
): GatewayToInstanceMessage | undefined {
|
|
513
|
+
if (!isRecord(value) || !hasValidId(value) || !isRecord(value.payload)) return undefined;
|
|
514
|
+
const payload = value.payload;
|
|
515
|
+
switch (value.type) {
|
|
516
|
+
case 'shutdown':
|
|
517
|
+
if (typeof payload.reason !== 'string' || !isFiniteNonNegative(payload.budgetMs)) return undefined;
|
|
518
|
+
return value as unknown as GatewayShutdownMessage;
|
|
519
|
+
case 'host.addresses':
|
|
520
|
+
if (!Array.isArray(payload.addresses) || payload.addresses.length > 64 || !payload.addresses.every(isAddress)) return undefined;
|
|
521
|
+
return value as unknown as GatewayAddressesMessage;
|
|
522
|
+
case 'principal-revoked':
|
|
523
|
+
if (!isText(payload.principalId) || payload.principalId.length === 0) return undefined;
|
|
524
|
+
if (payload.reason !== undefined && !isText(payload.reason, 64)) return undefined;
|
|
525
|
+
return value as unknown as GatewayPrincipalRevokedMessage;
|
|
526
|
+
default:
|
|
527
|
+
break;
|
|
528
|
+
}
|
|
529
|
+
if (!hasStreamId(value)) return undefined;
|
|
530
|
+
switch (value.type) {
|
|
531
|
+
case 'http.request':
|
|
532
|
+
if (!isText(payload.method, 16) || !/^[A-Za-z]+$/u.test(payload.method)) return undefined;
|
|
533
|
+
if (!isText(payload.url, limits.maxHeadBytes) || typeof payload.body !== 'boolean') return undefined;
|
|
534
|
+
if (!isHeaders(payload.headers, limits, payload.url.length)) return undefined;
|
|
535
|
+
if (!isOptionalPrincipal(payload.principal) || !isOptionalRemote(payload.remoteAddress)) return undefined;
|
|
536
|
+
return value as unknown as HttpRequestMessage;
|
|
537
|
+
case 'ws.open':
|
|
538
|
+
if (!isText(payload.url, limits.maxHeadBytes) || !isHeaders(payload.headers, limits, payload.url.length)) return undefined;
|
|
539
|
+
if (!isOptionalPrincipal(payload.principal) || !isOptionalRemote(payload.remoteAddress)) return undefined;
|
|
540
|
+
return value as unknown as WsOpenMessage;
|
|
541
|
+
case 'http.request.body':
|
|
542
|
+
return isChunk(payload.chunk, limits) ? value as unknown as HttpRequestBodyMessage : undefined;
|
|
543
|
+
case 'http.request.end':
|
|
544
|
+
return value as unknown as HttpRequestEndMessage;
|
|
545
|
+
case 'http.request.abort':
|
|
546
|
+
return isText(payload.message) ? value as unknown as HttpRequestAbortMessage : undefined;
|
|
547
|
+
case 'http.response.credit':
|
|
548
|
+
case 'ws.drain':
|
|
549
|
+
return isCredit(payload) ? value as unknown as GatewayToInstanceMessage : undefined;
|
|
550
|
+
case 'ws.message':
|
|
551
|
+
return isWsData(payload.data, limits) ? value as unknown as WsMessageMessage : undefined;
|
|
552
|
+
case 'ws.close':
|
|
553
|
+
return isWsClose(payload) ? value as unknown as WsCloseMessage : undefined;
|
|
554
|
+
default:
|
|
555
|
+
return undefined;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Headers as a bridge header list (repeated `set-cookie` stays separate). */
|
|
560
|
+
export function toBridgeHeaders(headers: Headers): BridgeHeaders {
|
|
561
|
+
const list: (readonly [string, string])[] = [];
|
|
562
|
+
for (const [name, value] of headers) list.push([name, value]);
|
|
563
|
+
return list;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export function fromBridgeHeaders(list: BridgeHeaders): Headers {
|
|
567
|
+
const headers = new Headers();
|
|
568
|
+
for (const [name, value] of list) {
|
|
569
|
+
try {
|
|
570
|
+
headers.append(name, value);
|
|
571
|
+
} catch { /* not a valid Fetch header: skip */ }
|
|
572
|
+
}
|
|
573
|
+
return headers;
|
|
574
|
+
}
|