@zhin.js/host-http 1.1.17 → 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.
Files changed (89) hide show
  1. package/README.md +60 -21
  2. package/lib/access-control.d.ts +127 -0
  3. package/lib/access-control.js +148 -0
  4. package/lib/addresses.d.ts +9 -0
  5. package/lib/addresses.js +9 -0
  6. package/lib/bridge/bridged-host.d.ts +43 -0
  7. package/lib/bridge/bridged-host.js +497 -0
  8. package/lib/bridge/control-channel.d.ts +59 -0
  9. package/lib/bridge/control-channel.js +131 -0
  10. package/lib/bridge/flow.d.ts +58 -0
  11. package/lib/bridge/flow.js +188 -0
  12. package/lib/bridge/index.d.ts +6 -0
  13. package/lib/bridge/index.js +6 -0
  14. package/lib/bridge/instance-transport.d.ts +112 -0
  15. package/lib/bridge/instance-transport.js +532 -0
  16. package/lib/bridge/protocol.d.ts +242 -0
  17. package/lib/bridge/protocol.js +369 -0
  18. package/lib/console-events.d.ts +5 -3
  19. package/lib/console-events.js +53 -27
  20. package/lib/console-plugin-rpc.d.ts +5 -1
  21. package/lib/console-plugin-rpc.js +5 -40
  22. package/lib/console-rest-pages-helpers.d.ts +4 -5
  23. package/lib/console-rest-pages-helpers.js +62 -79
  24. package/lib/console-rest-pages.js +80 -65
  25. package/lib/console-rpc-extended/dispatcher.js +1 -6
  26. package/lib/console-rpc-handlers.d.ts +0 -2
  27. package/lib/console-rpc-handlers.js +2 -40
  28. package/lib/console-rpc.d.ts +6 -14
  29. package/lib/console-rpc.js +6 -6
  30. package/lib/host-installer.d.ts +6 -3
  31. package/lib/host-installer.js +10 -40
  32. package/lib/host-options.d.ts +47 -0
  33. package/lib/host-options.js +1 -0
  34. package/lib/http-host.d.ts +124 -48
  35. package/lib/http-host.js +319 -332
  36. package/lib/index.d.ts +10 -5
  37. package/lib/index.js +9 -5
  38. package/lib/json-body.d.ts +1 -2
  39. package/lib/json-body.js +1 -2
  40. package/lib/marketplace-registry.d.ts +17 -0
  41. package/lib/marketplace-registry.js +52 -0
  42. package/lib/node/index.d.ts +10 -0
  43. package/lib/node/index.js +10 -0
  44. package/lib/{listener-group.d.ts → node/listener-group.d.ts} +2 -3
  45. package/lib/{listener-group.js → node/listener-group.js} +6 -10
  46. package/lib/node/listener.d.ts +26 -0
  47. package/lib/node/listener.js +231 -0
  48. package/lib/node/node-http.d.ts +38 -0
  49. package/lib/node/node-http.js +272 -0
  50. package/lib/node/server.d.ts +13 -0
  51. package/lib/node/server.js +64 -0
  52. package/lib/protocol-auth.d.ts +24 -0
  53. package/lib/protocol-auth.js +48 -0
  54. package/lib/public-api.d.ts +1 -1
  55. package/lib/token-registry.d.ts +54 -36
  56. package/lib/token-registry.js +133 -126
  57. package/lib/trust-proxy.d.ts +22 -0
  58. package/lib/trust-proxy.js +168 -0
  59. package/package.json +14 -6
  60. package/src/access-control.ts +255 -0
  61. package/src/addresses.ts +16 -0
  62. package/src/bridge/bridged-host.ts +548 -0
  63. package/src/bridge/control-channel.ts +173 -0
  64. package/src/bridge/flow.ts +208 -0
  65. package/src/bridge/index.ts +31 -0
  66. package/src/bridge/instance-transport.ts +627 -0
  67. package/src/bridge/protocol.ts +574 -0
  68. package/src/console-events.ts +59 -28
  69. package/src/console-plugin-rpc.ts +5 -51
  70. package/src/console-rest-pages-helpers.ts +60 -82
  71. package/src/console-rest-pages.ts +87 -67
  72. package/src/console-rpc-extended/dispatcher.ts +1 -6
  73. package/src/console-rpc-handlers.ts +2 -31
  74. package/src/console-rpc.ts +10 -19
  75. package/src/host-installer.ts +12 -44
  76. package/src/host-options.ts +47 -0
  77. package/src/http-host.ts +467 -421
  78. package/src/index.ts +55 -13
  79. package/src/json-body.ts +7 -2
  80. package/src/marketplace-registry.ts +62 -0
  81. package/src/node/index.ts +21 -0
  82. package/src/{listener-group.ts → node/listener-group.ts} +13 -24
  83. package/src/node/listener.ts +265 -0
  84. package/src/node/node-http.ts +263 -0
  85. package/src/node/server.ts +82 -0
  86. package/src/protocol-auth.ts +52 -0
  87. package/src/public-api.ts +2 -0
  88. package/src/token-registry.ts +143 -146
  89. package/src/trust-proxy.ts +178 -0
package/lib/http-host.js CHANGED
@@ -1,91 +1,113 @@
1
- import { createServer as createHttpServer, } from 'node:http';
2
- import { createServer as createHttpsServer, } from 'node:https';
3
- import { WebSocket, WebSocketServer } from 'ws';
1
+ /**
2
+ * Transport-independent HTTP/WebSocket route surface (ADR 0054 §2).
3
+ *
4
+ * Routes take a Web standard `Request` and return a `Response`; WebSocket routes
5
+ * receive a {@link WsConnection}. The router owns matching, CORS, Console
6
+ * authentication and generation admission. A transport (the in-process Node
7
+ * server in `@zhin.js/host-http/node`, later the gateway bridge) feeds it
8
+ * `Request`s and upgrade requests. Nothing here imports `node:http` or `ws`.
9
+ */
4
10
  import { formatCompact, getLogger } from '@zhin.js/logger';
5
11
  import { createToken, generationAdmissionBinder, } from '@zhin.js/plugin-runtime';
6
- import { TokenRegistry, extractBearerToken, isDemoWebSocketPath, } from './token-registry.js';
12
+ import { HttpBodyError, jsonResponse } from '@zhin.js/host-http-contract';
13
+ import { TokenRegistry, extractBearerToken, } from './token-registry.js';
7
14
  import { buildOpenApiDocument, } from './openapi.js';
8
- import { isDemoHttpAllowed } from './console-rpc.js';
9
- import { HttpBodyError } from './json-body.js';
15
+ import { auditActor, authorizeConsoleHttp, authorizeConsoleWebSocket, requestMeta, } from './access-control.js';
10
16
  const logger = getLogger('http');
11
17
  export const REMOTE_CONSOLE_ORIGIN = 'https://console.zhin.dev';
12
18
  const DEFAULT_CORS_ORIGINS = Object.freeze([REMOTE_CONSOLE_ORIGIN]);
13
19
  /** @public Stable Plugin Runtime HTTP Host token. */
14
20
  export const httpHostToken = createToken('zhin.host.http');
15
- /** @internal CLI composition-root factory. */
16
- export function createHttpHost(options = {}) {
17
- const host = options.host ?? '127.0.0.1';
18
- const port = options.port ?? 8086;
21
+ const NO_PARAMS = Object.freeze({});
22
+ export function createHttpRouter(options = {}) {
19
23
  const apiBase = normalizePath(options.apiBase ?? '/api');
20
24
  const corsOrigins = Object.freeze([
21
25
  ...new Set([...DEFAULT_CORS_ORIGINS, ...(options.corsOrigins ?? [])]),
22
26
  ]);
23
27
  const authExempt = Object.freeze((options.authExemptPaths ?? []).map((path) => normalizePath(path)));
24
- const tokenRegistry = new TokenRegistry({
28
+ const tokenRegistry = options.tokenRegistry ?? new TokenRegistry({
25
29
  primaryToken: options.token,
26
- scopedTokens: options.tokens,
27
30
  });
31
+ const secure = options.secure === true;
32
+ const readAddress = options.address ?? (() => undefined);
33
+ const forwarded = options.authentication === 'forwarded';
28
34
  const wsRoutes = new Map();
29
35
  const httpRoutes = [];
30
- let address;
36
+ const changeListeners = new Set();
31
37
  let closed = false;
32
- let closeResult;
33
- let listenResult;
34
- const secure = options.tls !== undefined;
35
- const handleRequest = (request, response) => {
36
- void dispatchHttp(request, response);
38
+ const routesChanged = () => {
39
+ for (const listener of [...changeListeners]) {
40
+ try {
41
+ listener();
42
+ }
43
+ catch { /* observers never break registration */ }
44
+ }
37
45
  };
38
- const server = secure
39
- ? createHttpsServer(options.tls, handleRequest)
40
- : createHttpServer(handleRequest);
41
- // Track live TCP sockets so close() can destroy long-lived connections
42
- // (SSE /api/events, keep-alive) that would otherwise block server.close().
43
- const sockets = new Set();
44
- server.on('connection', (socket) => {
45
- sockets.add(socket);
46
- socket.once('close', () => {
47
- sockets.delete(socket);
48
- });
49
- });
50
- const wss = new WebSocketServer({ noServer: true });
51
- // Built-in public health probe (parity with host-router `/pub/health`).
52
- httpRoutes.push(Object.freeze({
53
- method: 'GET',
54
- path: '/pub/health',
55
- pattern: '/pub/health',
56
- prefix: false,
57
- meta: Object.freeze({ summary: 'Health probe', tags: ['pub'] }),
58
- handler: (_request, response) => {
59
- writeJson(response, 200, {
46
+ if (options.builtinRoutes !== false)
47
+ registerBuiltinRoutes();
48
+ function registerBuiltinRoutes() {
49
+ // Built-in public health probe (parity with host-router `/pub/health`).
50
+ httpRoutes.push(Object.freeze({
51
+ method: 'GET',
52
+ path: '/pub/health',
53
+ pattern: '/pub/health',
54
+ prefix: false,
55
+ meta: Object.freeze({ summary: 'Health probe', tags: ['pub'] }),
56
+ handler: () => jsonResponse(200, {
60
57
  success: true,
61
58
  status: 'ok',
62
59
  timestamp: new Date().toISOString(),
63
- });
64
- },
65
- }));
66
- // OpenAPI catalog (parity with host-router `/pub/openapi.json`).
67
- httpRoutes.push(Object.freeze({
68
- method: 'GET',
69
- path: '/pub/openapi.json',
70
- pattern: '/pub/openapi.json',
71
- prefix: false,
72
- meta: Object.freeze({ summary: 'OpenAPI 3.1 document', tags: ['pub'] }),
73
- handler: (request, response) => {
74
- const hostHeader = headerValue(request.headers.host) ?? 'localhost';
75
- const proto = headerValue(request.headers['x-forwarded-proto']) ?? (secure ? 'https' : 'http');
76
- writeJson(response, 200, buildOpenApiDocument(listListedRoutes(), {
77
- apiBase,
78
- serverUrl: `${proto}://${hostHeader}`,
79
- version: process.env.npm_package_version ?? '0.0.0',
80
- }));
81
- },
82
- }));
83
- server.on('upgrade', (request, socket, head) => {
84
- if (closed) {
85
- socket.destroy();
60
+ }),
61
+ }));
62
+ // OpenAPI catalog (parity with host-router `/pub/openapi.json`).
63
+ httpRoutes.push(Object.freeze({
64
+ method: 'GET',
65
+ path: '/pub/openapi.json',
66
+ pattern: '/pub/openapi.json',
67
+ prefix: false,
68
+ meta: Object.freeze({ summary: 'OpenAPI 3.1 document', tags: ['pub'] }),
69
+ handler: (request) => {
70
+ const hostHeader = request.headers.get('host') ?? 'localhost';
71
+ const proto = request.headers.get('x-forwarded-proto') ?? (secure ? 'https' : 'http');
72
+ return jsonResponse(200, buildOpenApiDocument(listListedRoutes(), {
73
+ apiBase,
74
+ serverUrl: `${proto}://${hostHeader}`,
75
+ version: process.env.npm_package_version ?? '0.0.0',
76
+ }));
77
+ },
78
+ }));
79
+ }
80
+ async function authenticateUpgrade(request, pathname) {
81
+ const { url, headers, remoteAddress } = request;
82
+ const principal = forwarded
83
+ ? request.principal
84
+ : await tokenRegistry.authenticate(extractBearerToken(headers.get('authorization') ?? undefined, url.searchParams.get('token')), requestMeta({ headers }, remoteAddress));
85
+ if (!principal)
86
+ return undefined;
87
+ if (!authorizeConsoleWebSocket(principal, pathname)) {
88
+ audit(principal, remoteAddress, `ws:${pathname}`, 'denied');
89
+ return undefined;
90
+ }
91
+ return principal;
92
+ }
93
+ function audit(principal, remoteAddress, action, outcome) {
94
+ const sink = tokenRegistry.accessProvider?.audit;
95
+ if (!sink)
86
96
  return;
97
+ try {
98
+ sink.call(tokenRegistry.accessProvider, {
99
+ actor: auditActor(principal),
100
+ action,
101
+ outcome,
102
+ ...(remoteAddress ? { ip: remoteAddress } : {}),
103
+ });
87
104
  }
88
- const pathname = upgradePath(request.url);
105
+ catch { /* audit never breaks a request */ }
106
+ }
107
+ async function upgrade(request) {
108
+ if (closed)
109
+ return Object.freeze({ accepted: false, status: 404 });
110
+ const pathname = normalizePath(request.url.pathname);
89
111
  const listeners = wsRoutes.get(pathname);
90
112
  const admitted = listeners ? [...listeners].flatMap((entry) => {
91
113
  const release = entry.admission?.acquire();
@@ -95,42 +117,50 @@ export function createHttpHost(options = {}) {
95
117
  }) : [];
96
118
  if (admitted.length === 0) {
97
119
  logger.debug(formatCompact({ op: 'ws_upgrade_unmatched', path: pathname }));
98
- socket.destroy();
99
- return;
120
+ return Object.freeze({ accepted: false, status: 404 });
100
121
  }
101
- const protocolAuthenticated = admitted.every(({ entry }) => entry.options.auth === 'protocol');
102
- const auth = protocolAuthenticated
103
- ? { ok: true, scope: 'full' }
104
- : authenticateUpgrade(request, pathname);
105
- if (!auth.ok) {
106
- socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
107
- socket.destroy();
122
+ let settled = false;
123
+ const releaseAll = () => {
124
+ if (settled)
125
+ return;
126
+ settled = true;
108
127
  for (const item of admitted)
109
128
  item.release?.();
110
- return;
129
+ };
130
+ const protocolAuthenticated = admitted.every(({ entry }) => entry.options.auth === 'protocol');
131
+ let principal;
132
+ if (!protocolAuthenticated) {
133
+ principal = await authenticateUpgrade(request, pathname).catch(() => undefined);
134
+ if (!principal || closed) {
135
+ releaseAll();
136
+ return Object.freeze({ accepted: false, status: 401 });
137
+ }
111
138
  }
112
- try {
113
- wss.handleUpgrade(request, socket, head, (ws) => {
114
- let settled = false;
139
+ return Object.freeze({
140
+ accepted: true,
141
+ reject: releaseAll,
142
+ accept(transport) {
143
+ if (settled) {
144
+ transport.close(1012, 'Root stopped');
145
+ return;
146
+ }
115
147
  const stopSubscriptions = [];
116
- const releaseAdmissions = () => {
117
- if (settled)
148
+ let finished = false;
149
+ transport.onClose(() => {
150
+ if (finished)
118
151
  return;
119
- settled = true;
152
+ finished = true;
120
153
  for (const unsubscribe of stopSubscriptions)
121
154
  unsubscribe();
122
- for (const item of admitted)
123
- item.release?.();
124
- };
125
- ws.once('close', releaseAdmissions);
126
- ws.once('error', releaseAdmissions);
155
+ releaseAll();
156
+ });
127
157
  // Replacement closes new upgrades, not accepted plugin work. Explicit Root stop
128
158
  // still closes its sockets before waiting for their admission leases to drain.
129
159
  const stops = new Set(admitted.map(item => item.entry.admission?.stopSignal).filter(signal => signal !== undefined));
130
160
  for (const signal of stops) {
131
161
  const stop = () => {
132
- if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)
133
- ws.close(1012, 'Root stopped');
162
+ if (!finished)
163
+ transport.close(1012, 'Root stopped');
134
164
  };
135
165
  signal.addEventListener('abort', stop, { once: true });
136
166
  stopSubscriptions.push(() => signal.removeEventListener('abort', stop));
@@ -138,161 +168,124 @@ export function createHttpHost(options = {}) {
138
168
  stop();
139
169
  }
140
170
  const connection = Object.freeze({
141
- socket: ws,
142
- request,
143
- authScope: auth.scope,
171
+ url: request.url,
172
+ headers: request.headers,
173
+ ...(principal ? { principal } : {}),
174
+ ...(request.remoteAddress ? { remoteAddress: request.remoteAddress } : {}),
175
+ send: (data) => transport.send(data),
176
+ close: (code, reason) => transport.close(code, reason),
177
+ onMessage: (listener) => transport.onMessage(listener),
178
+ onClose: (listener) => transport.onClose(listener),
179
+ get bufferedAmount() { return transport.bufferedAmount; },
144
180
  });
145
- for (const item of admitted)
146
- item.entry.listener(connection);
147
- });
148
- }
149
- catch (err) {
150
- for (const item of admitted)
151
- item.release?.();
152
- logger.warn(formatCompact({
153
- op: 'ws_upgrade_failed',
154
- path: pathname,
155
- error: err instanceof Error ? err.message : String(err),
156
- }));
157
- try {
158
- socket.destroy();
159
- }
160
- catch {
161
- /* already destroyed */
162
- }
163
- }
164
- });
165
- function authenticateUpgrade(request, pathname) {
166
- if (!tokenRegistry.hasAnyToken())
167
- return { ok: true, scope: 'full' };
168
- let url;
169
- try {
170
- url = new URL(request.url ?? '/', 'http://localhost');
171
- }
172
- catch {
173
- return { ok: false };
174
- }
175
- const token = extractBearerToken(headerValue(request.headers.authorization), url.searchParams.get('token'));
176
- const scope = tokenRegistry.resolve(token);
177
- if (!scope)
178
- return { ok: false };
179
- if (scope === 'demo' && !isDemoWebSocketPath(pathname))
180
- return { ok: false };
181
- return { ok: true, scope };
181
+ for (const item of admitted) {
182
+ try {
183
+ item.entry.listener(connection);
184
+ }
185
+ catch (error) {
186
+ logger.warn(formatCompact({
187
+ op: 'ws_listener_failed',
188
+ path: pathname,
189
+ error: error instanceof Error ? error.message : String(error),
190
+ }));
191
+ }
192
+ }
193
+ },
194
+ });
182
195
  }
183
- async function dispatchHttp(request, response) {
184
- if (closed) {
185
- response.writeHead(503);
186
- response.end();
187
- return;
188
- }
189
- const method = (request.method ?? 'GET').toUpperCase();
196
+ async function handle(request, info = {}) {
197
+ if (closed)
198
+ return new Response(null, { status: 503 });
199
+ const method = request.method.toUpperCase();
190
200
  let url;
191
201
  try {
192
- url = new URL(request.url ?? '/', 'http://localhost');
202
+ url = new URL(request.url);
193
203
  }
194
204
  catch {
195
- response.writeHead(400);
196
- response.end();
197
- return;
205
+ return new Response(null, { status: 400 });
198
206
  }
199
207
  const pathname = normalizePath(url.pathname);
200
- const origin = headerValue(request.headers.origin);
201
- const corsOk = corsMatch(origin, corsOrigins);
202
- if (method === 'OPTIONS') {
203
- if (corsOk && origin)
204
- applyCors(response, origin, corsOrigins);
205
- response.writeHead(204);
206
- response.end();
207
- return;
208
- }
209
- const auth = authenticateHttp(request, url, pathname);
208
+ const origin = request.headers.get('origin') ?? undefined;
209
+ const cors = origin && corsMatch(origin, corsOrigins) ? corsHeaders(origin, corsOrigins) : undefined;
210
+ if (method === 'OPTIONS')
211
+ return withHeaders(new Response(null, { status: 204 }), cors);
212
+ const auth = await authenticateHttp(request, url, pathname, method, info);
210
213
  if (!auth.ok) {
211
- if (corsOk && origin)
212
- applyCors(response, origin, corsOrigins);
213
- writeJson(response, auth.forbidden ? 403 : 401, {
214
+ return withHeaders(jsonResponse(auth.status, {
214
215
  success: false,
215
- code: auth.forbidden ? 'forbidden' : 'unauthorized',
216
+ code: auth.code,
216
217
  requestOutcome: 'not-dispatched',
217
- error: auth.forbidden ? 'Token scope does not allow this route' : 'Invalid or missing token',
218
- });
219
- return;
218
+ error: auth.error,
219
+ }), cors);
220
220
  }
221
221
  const route = matchHttpRoute(httpRoutes, method, pathname);
222
- if (!route) {
223
- if (corsOk && origin)
224
- applyCors(response, origin, corsOrigins);
225
- response.writeHead(404);
226
- response.end();
227
- return;
222
+ if (!route)
223
+ return withHeaders(new Response(null, { status: 404 }), cors);
224
+ const defaults = { ...cors, 'x-content-type-options': 'nosniff', 'x-frame-options': 'SAMEORIGIN' };
225
+ const release = route.admission?.acquire();
226
+ if (route.admission && !release) {
227
+ return withHeaders(jsonResponse(503, { success: false, code: 'unavailable', requestOutcome: 'not-dispatched' }), defaults);
228
228
  }
229
+ const context = Object.freeze({
230
+ url,
231
+ params: route.prefix ? Object.freeze({ '*': prefixRemainder(route.path, pathname) }) : NO_PARAMS,
232
+ ...(auth.principal ? { principal: auth.principal } : {}),
233
+ ...(info.remoteAddress ? { remoteAddress: info.remoteAddress } : {}),
234
+ });
235
+ let response;
229
236
  try {
230
- if (corsOk && origin)
231
- applyCors(response, origin, corsOrigins);
232
- response.setHeader('X-Content-Type-Options', 'nosniff');
233
- response.setHeader('X-Frame-Options', 'SAMEORIGIN');
234
- const release = route.admission?.acquire();
235
- if (route.admission && !release) {
236
- writeJson(response, 503, { success: false, code: 'unavailable', requestOutcome: 'not-dispatched' });
237
- return;
238
- }
239
- if (!release) {
240
- await route.handler(request, response, url, auth.scope, auth.principal);
241
- return;
242
- }
243
- let released = false;
244
- const releaseResponse = () => {
245
- if (released)
246
- return;
247
- released = true;
248
- response.off('finish', releaseResponse);
249
- response.off('close', releaseResponse);
250
- release();
251
- };
252
- response.once('finish', releaseResponse);
253
- response.once('close', releaseResponse);
254
- await route.handler(request, response, url, auth.scope, auth.principal);
255
- if (response.writableFinished || response.destroyed)
256
- releaseResponse();
237
+ response = await route.handler(request, context);
238
+ if (!(response instanceof Response))
239
+ throw new TypeError('HTTP route handler did not return a Response');
257
240
  }
258
241
  catch (err) {
242
+ release?.();
259
243
  // 统一把请求体错误(400/413)映射回对应状态码,
260
244
  // 避免个别端点未捕获时退化成 500 空响应。
261
245
  if (err instanceof HttpBodyError) {
262
- if (!response.headersSent) {
263
- writeJson(response, err.statusCode, { success: false, error: err.message });
264
- }
265
- return;
246
+ return withHeaders(jsonResponse(err.statusCode, { success: false, error: err.message }), defaults);
266
247
  }
267
248
  logger.warn(formatCompact({
268
249
  op: 'http_handler_failed',
269
250
  path: pathname,
270
251
  error: err instanceof Error ? err.message : String(err),
271
252
  }));
272
- if (!response.headersSent) {
273
- response.writeHead(500);
274
- response.end();
275
- }
253
+ return withHeaders(new Response(null, { status: 500 }), defaults);
276
254
  }
255
+ return withHeaders(response, defaults, release);
277
256
  }
278
- function authenticateHttp(request, url, pathname) {
279
- if (!requiresHttpAuth(pathname, apiBase, authExempt)) {
280
- return { ok: true, scope: 'full' };
257
+ async function authenticateHttp(request, url, pathname, method, info) {
258
+ const remoteAddress = info.remoteAddress;
259
+ if (forwarded) {
260
+ // The gateway authenticated; authorization still happens here (ADR 0054 §3).
261
+ const principal = info.principal;
262
+ if (!requiresHttpAuth(pathname, apiBase, authExempt))
263
+ return { ok: true, ...(principal ? { principal } : {}) };
264
+ return authorizeApi(principal, method, pathname, remoteAddress);
281
265
  }
282
- if (!tokenRegistry.hasAnyToken()) {
283
- return { ok: true, scope: 'full' };
266
+ const token = extractBearerToken(request.headers.get('authorization') ?? undefined, url.searchParams.get('token'));
267
+ const meta = requestMeta(request, remoteAddress, { inProcess: info.inProcess === true });
268
+ if (!requiresHttpAuth(pathname, apiBase, authExempt)) {
269
+ // Public route: still attach a presented credential so page catalogs can filter by it.
270
+ if (token || !tokenRegistry.hasAnyToken()) {
271
+ const principal = await tokenRegistry.authenticate(token, meta);
272
+ if (principal)
273
+ return { ok: true, principal };
274
+ }
275
+ return { ok: true };
284
276
  }
285
- const token = extractBearerToken(headerValue(request.headers.authorization), url.searchParams.get('token'));
286
- const scope = tokenRegistry.resolve(token);
287
- if (!scope)
288
- return { ok: false };
289
- if (scope === 'demo') {
290
- const method = (request.method ?? 'GET').toUpperCase();
291
- if (!isDemoHttpAllowed(method, pathname, apiBase))
292
- return { ok: false, forbidden: true };
277
+ const principal = await tokenRegistry.authenticate(token, meta) ?? undefined;
278
+ return authorizeApi(principal, method, pathname, remoteAddress);
279
+ }
280
+ function authorizeApi(principal, method, pathname, remoteAddress) {
281
+ const relative = pathname === apiBase ? '/' : pathname.slice(apiBase.length);
282
+ const decision = authorizeConsoleHttp(principal, method, relative);
283
+ if (!decision.allowed) {
284
+ if (principal)
285
+ audit(principal, remoteAddress, `http:${method} ${relative}`, 'denied');
286
+ return { ok: false, status: decision.status, code: decision.code, error: decision.error };
293
287
  }
294
- const principal = tokenRegistry.resolvePrincipal(token);
295
- return { ok: true, scope, ...(principal ? { principal } : {}) };
288
+ return { ok: true, ...(principal ? { principal } : {}) };
296
289
  }
297
290
  function listListedRoutes() {
298
291
  return Object.freeze(httpRoutes
@@ -303,7 +296,7 @@ export function createHttpHost(options = {}) {
303
296
  meta: route.meta,
304
297
  })));
305
298
  }
306
- function registerWs(pathname, admission, options = {}) {
299
+ function registerWs(pathname, admission, wsOptions = {}) {
307
300
  const normalized = normalizePath(pathname);
308
301
  let listeners = wsRoutes.get(normalized);
309
302
  if (!listeners) {
@@ -313,18 +306,24 @@ export function createHttpHost(options = {}) {
313
306
  const owned = new Set();
314
307
  return {
315
308
  onConnection(listener) {
316
- const entry = Object.freeze({ listener, admission, options: Object.freeze({ ...options }) });
309
+ const entry = Object.freeze({ listener, admission, options: Object.freeze({ ...wsOptions }) });
317
310
  listeners.add(entry);
318
311
  owned.add(entry);
312
+ routesChanged();
319
313
  return () => {
314
+ if (!owned.delete(entry))
315
+ return;
320
316
  listeners.delete(entry);
321
- owned.delete(entry);
317
+ routesChanged();
322
318
  };
323
319
  },
324
320
  close() {
321
+ if (owned.size === 0)
322
+ return;
325
323
  for (const entry of owned)
326
324
  listeners.delete(entry);
327
325
  owned.clear();
326
+ routesChanged();
328
327
  },
329
328
  };
330
329
  }
@@ -343,120 +342,120 @@ export function createHttpHost(options = {}) {
343
342
  admission,
344
343
  });
345
344
  httpRoutes.push(entry);
345
+ routesChanged();
346
346
  return () => {
347
347
  const index = httpRoutes.indexOf(entry);
348
- if (index >= 0)
349
- httpRoutes.splice(index, 1);
348
+ if (index < 0)
349
+ return;
350
+ httpRoutes.splice(index, 1);
351
+ routesChanged();
350
352
  };
351
353
  }
352
354
  function createBoundHost(admission) {
353
355
  const bound = {
354
356
  [generationAdmissionBinder]: (next) => createBoundHost(next),
355
- ws: (path, options) => registerWs(path, admission, options),
357
+ ws: (path, wsOptions) => registerWs(path, admission, wsOptions),
356
358
  route: (method, path, handler, meta) => registerRoute(method, path, handler, meta, admission),
357
359
  listRoutes: () => listListedRoutes(),
358
- get address() { return runtime.address; },
360
+ get address() { return readAddress(); },
359
361
  get tokenRegistry() { return tokenRegistry; },
360
362
  };
361
363
  return Object.freeze(bound);
362
364
  }
363
- const runtime = {
365
+ return {
364
366
  [generationAdmissionBinder]: (admission) => createBoundHost(admission),
365
- ws: (pathname, options) => registerWs(pathname, undefined, options),
367
+ ws: (pathname, wsOptions) => registerWs(pathname, undefined, wsOptions),
366
368
  route: (method, path, handler, meta) => registerRoute(method, path, handler, meta),
367
- listRoutes() {
368
- return listListedRoutes();
369
- },
370
- get address() {
371
- return address;
372
- },
373
- get tokenRegistry() {
374
- return tokenRegistry;
369
+ listRoutes: () => listListedRoutes(),
370
+ get address() { return readAddress(); },
371
+ get tokenRegistry() { return tokenRegistry; },
372
+ handle,
373
+ upgrade,
374
+ get routeCount() { return httpRoutes.length; },
375
+ get wsPaths() { return Object.freeze([...wsRoutes.keys()]); },
376
+ listWsRoutes() {
377
+ const listed = [];
378
+ for (const [path, entries] of wsRoutes) {
379
+ const active = [...entries].filter((entry) => !entry.admission || entry.admission.active);
380
+ if (active.length === 0)
381
+ continue;
382
+ const auth = active.every((entry) => entry.options.auth === 'protocol') ? 'protocol' : 'host';
383
+ listed.push(Object.freeze({ path, auth }));
384
+ }
385
+ return Object.freeze(listed);
375
386
  },
376
- listen() {
377
- if (closed)
378
- return Promise.reject(new Error('HTTP Host is closed'));
379
- if (listenResult)
380
- return listenResult;
381
- if (address)
382
- return Promise.resolve(address);
383
- listenResult = new Promise((resolve, reject) => {
384
- const onError = (error) => {
385
- listenResult = undefined;
386
- reject(error);
387
- };
388
- server.once('error', onError);
389
- server.listen({ host, port }, () => {
390
- server.off('error', onError);
391
- if (closed) {
392
- reject(new Error('HTTP Host closed before listen completed'));
393
- return;
394
- }
395
- const bound = server.address();
396
- const listenPort = typeof bound === 'object' && bound ? bound.port : port;
397
- const publicHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
398
- const protocol = secure ? 'https' : 'http';
399
- const originHost = publicHost.includes(':') ? `[${publicHost}]` : publicHost;
400
- address = Object.freeze({
401
- host: publicHost, port: listenPort, protocol, secure,
402
- origin: `${protocol}://${originHost}:${listenPort}`,
403
- });
404
- logger.info(`listening ${address.origin}`
405
- + ` | routes: ${httpRoutes.length}`
406
- + ` | token: ${tokenRegistry.hasAnyToken()
407
- ? `${tokenRegistry.primaryTokenPrefixForLog()}…`
408
- : '(none)'}`);
409
- logger.debug(`listen detail | cors: ${corsOrigins.join(',')}`
410
- + ` | ws: ${[...wsRoutes.keys()].join(',') || '(none)'}`);
411
- resolve(address);
412
- });
413
- });
414
- return listenResult;
387
+ onRoutesChanged(listener) {
388
+ changeListeners.add(listener);
389
+ return () => { changeListeners.delete(listener); };
415
390
  },
391
+ apiBase,
392
+ corsOrigins,
416
393
  close() {
417
- if (closeResult)
418
- return closeResult;
419
394
  closed = true;
420
395
  wsRoutes.clear();
421
396
  httpRoutes.length = 0;
422
- closeResult = (async () => {
423
- // A pending listen must settle before deciding whether there is a
424
- // listener to close. Otherwise it can bind after close has resolved.
425
- await listenResult?.catch(() => undefined);
426
- if (!server.listening) {
427
- address = undefined;
428
- return;
429
- }
430
- await new Promise((resolve, reject) => {
431
- // Long-lived connections (SSE, keep-alive, WS) never end on their own:
432
- // destroy tracked sockets and terminate WS clients before close(),
433
- // otherwise the close callbacks would never fire.
434
- for (const socket of sockets)
435
- socket.destroy();
436
- sockets.clear();
437
- for (const client of wss.clients)
438
- client.terminate();
439
- wss.close((wssError) => {
440
- if (wssError) {
441
- reject(wssError);
442
- return;
443
- }
444
- server.close((serverError) => {
445
- if (serverError) {
446
- reject(serverError);
447
- return;
448
- }
449
- logger.debug(formatCompact({ op: 'http_host_closed' }));
450
- address = undefined;
451
- resolve();
452
- });
453
- });
454
- });
455
- })();
456
- return closeResult;
397
+ changeListeners.clear();
457
398
  },
458
399
  };
459
- return runtime;
400
+ }
401
+ /**
402
+ * Adds host default headers (handler headers win) and, when `release` is given, keeps the
403
+ * generation lease until the body is fully delivered or cancelled.
404
+ */
405
+ function withHeaders(response, defaults, release) {
406
+ const entries = defaults ? Object.entries(defaults) : [];
407
+ if (entries.length === 0 && !release)
408
+ return response;
409
+ const headers = new Headers(response.headers);
410
+ for (const [name, value] of entries)
411
+ if (!headers.has(name))
412
+ headers.set(name, value);
413
+ let body = response.body;
414
+ if (release) {
415
+ if (!body)
416
+ release();
417
+ else
418
+ body = releaseWhenDrained(body, release);
419
+ }
420
+ try {
421
+ return new Response(body, { status: response.status, statusText: response.statusText, headers });
422
+ }
423
+ catch {
424
+ // Response.error() and friends cannot be re-wrapped.
425
+ return new Response(null, { status: 500 });
426
+ }
427
+ }
428
+ function releaseWhenDrained(source, release) {
429
+ const reader = source.getReader();
430
+ let done = false;
431
+ const finish = () => {
432
+ if (done)
433
+ return;
434
+ done = true;
435
+ release();
436
+ };
437
+ return new ReadableStream({
438
+ async pull(controller) {
439
+ try {
440
+ const next = await reader.read();
441
+ if (next.done) {
442
+ finish();
443
+ controller.close();
444
+ }
445
+ else {
446
+ controller.enqueue(next.value);
447
+ }
448
+ }
449
+ catch (error) {
450
+ finish();
451
+ controller.error(error);
452
+ }
453
+ },
454
+ async cancel(reason) {
455
+ finish();
456
+ await reader.cancel(reason).catch(() => undefined);
457
+ },
458
+ }, { highWaterMark: 0 });
460
459
  }
461
460
  function requiresHttpAuth(pathname, apiBase, authExempt) {
462
461
  if (pathname === '/pub' || pathname.startsWith('/pub/'))
@@ -473,24 +472,15 @@ function corsMatch(origin, allowed) {
473
472
  || allowed.some((entry) => (entry === origin
474
473
  || (entry.endsWith('*') && origin.startsWith(entry.slice(0, -1)))));
475
474
  }
476
- function applyCors(response, origin, allowed) {
477
- response.setHeader('Access-Control-Allow-Origin', allowed.includes('*') ? '*' : origin);
478
- response.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
479
- response.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type, Accept');
480
- response.setHeader('Access-Control-Max-Age', '86400');
481
- }
482
- function writeJson(response, status, body) {
483
- const payload = JSON.stringify(body);
484
- response.writeHead(status, {
485
- 'content-type': 'application/json; charset=utf-8',
486
- 'content-length': Buffer.byteLength(payload),
487
- });
488
- response.end(payload);
489
- }
490
- function headerValue(value) {
491
- if (Array.isArray(value))
492
- return value[0];
493
- return value;
475
+ function corsHeaders(origin, allowed) {
476
+ return {
477
+ 'access-control-allow-origin': allowed.includes('*') ? '*' : origin,
478
+ 'access-control-allow-methods': 'GET, POST, PUT, DELETE, OPTIONS',
479
+ 'access-control-allow-headers': 'Authorization, Content-Type, Accept',
480
+ 'access-control-max-age': '86400',
481
+ // Browsers hide non-safelisted response headers unless exposed (503 instance-unavailable).
482
+ 'access-control-expose-headers': 'Retry-After',
483
+ };
494
484
  }
495
485
  function matchHttpRoute(routes, method, pathname) {
496
486
  let best;
@@ -517,7 +507,12 @@ function matchHttpRoute(routes, method, pathname) {
517
507
  }
518
508
  return best;
519
509
  }
520
- function normalizePath(pathname) {
510
+ function prefixRemainder(prefix, pathname) {
511
+ if (prefix === '/')
512
+ return pathname.slice(1);
513
+ return pathname.length > prefix.length ? pathname.slice(prefix.length + 1) : '';
514
+ }
515
+ export function normalizePath(pathname) {
521
516
  if (!pathname.startsWith('/'))
522
517
  return `/${pathname}`;
523
518
  // 线性裁剪尾部斜杠(等价于 /\/+$/u,但避免长串 `/` 无匹配时的
@@ -527,11 +522,3 @@ function normalizePath(pathname) {
527
522
  end -= 1;
528
523
  return pathname.slice(0, end) || '/';
529
524
  }
530
- function upgradePath(url) {
531
- try {
532
- return normalizePath(new URL(url ?? '/', 'http://localhost').pathname);
533
- }
534
- catch {
535
- return '/';
536
- }
537
- }