@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.
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/README.md CHANGED
@@ -4,8 +4,23 @@ Minimal HTTP and WebSocket host for Plugin Runtime. Provides `httpHostToken` so
4
4
  Console can register path-scoped WebSocket / HTTP routes without importing Koa or the legacy
5
5
  `@zhin.js/host-router`.
6
6
 
7
- Root installs the host via `installResources` (see `basic/cli/src/plugin-runtime/http-host-installer.ts`
8
- and `console-host-installer.ts`).
7
+ Root installs the host via `installResources` (see `packages/host/http/src/host-installer.ts`
8
+ and `packages/im/zhin/src/instance/console/host.ts`).
9
+
10
+ The route contract is transport independent (ADR 0054 §2): HTTP routes take a Web standard
11
+ `Request` and return a `Response`; WebSocket routes receive a `WsConnection`. The main entry never
12
+ loads `node:http` or `ws`. The in-process Node listener (`createHttpHost`, `createHttpHostGroup`)
13
+ the Node ↔ Web conversion (`createNodeRequest`, `writeNodeResponse`, `wrapNodeWebSocket`) and the
14
+ bare listener both of them and the gateway run on (`createNodeHttpListener`) live in
15
+ `@zhin.js/host-http/node`.
16
+
17
+ `@zhin.js/host-http/bridge` carries the same contract across the gateway ↔ instance IPC channel
18
+ (ADR 0054 §3): `createBridgedHttpHost` is the instance's `HttpHost` (it announces a route table,
19
+ serves forwarded requests with chunked, credit-based streaming bodies and bridged WebSocket
20
+ frames, and never loads `node:http` / `ws`); `IpcInstanceTransport` implements the gateway-facing
21
+ `InstanceTransport` (`routes()`, `onRoutes()`, `fetch(request, { principal })`,
22
+ `openWebSocket(info, peer)`). The gateway authenticates; the bridged router runs in `forwarded`
23
+ authentication mode and still authorizes every `{apiBase}` route against the Console permission map.
9
24
 
10
25
  The composition root owns the process listener through `ProcessHttpHost`. Generation scopes receive
11
26
  only the `HttpHost` routing port: they may register HTTP/WS routes but cannot listen or close the
@@ -18,38 +33,62 @@ calls share one operation; calling `listen()` after close rejects.
18
33
 
19
34
  ## Capabilities (this slice)
20
35
 
21
- - Path-scoped WebSocket upgrades (`ws(path)`)
22
- - HTTP `route(method, path, handler, meta?)` (exact + `/*` prefix)
36
+ - Path-scoped WebSocket upgrades (`ws(path)`), delivered as `WsConnection`
37
+ (`url`, `headers`, `principal?`, `remoteAddress?`, `send`, `close`, `onMessage`, `onClose`,
38
+ `bufferedAmount`); the Node transport answers protocol pings and drops peers that miss a pong
39
+ - HTTP `route(method, path, handler, meta?)` (exact + `/*` prefix); `handler(request, context)`
40
+ returns a `Response`; `context` carries `url`, `params` (`'*'` = prefix remainder), the
41
+ authenticated `principal` and `remoteAddress`
42
+ - Streaming responses (SSE, large files) use `ReadableStream` bodies; `createResponseStream()`
43
+ gives a writer with backpressure (`write()` → false, `onDrain`) and disconnect (`signal`, `onClose`)
23
44
  - Built-in `GET /pub/health` and `GET /pub/openapi.json`
24
- - CLI-composed runtimes additionally expose `GET /pub/ready` and full-scope `GET /api/system/readiness`; see [production probes](../../../docs/operations/production.md).
45
+ - CLI-composed runtimes additionally expose `GET /pub/ready` and `GET /api/system/readiness` (requires `status.view`); see [production probes](../../../docs/operations/production.md).
25
46
  - CORS allowlist (always includes `https://console.zhin.dev`)
26
- - Optional Bearer auth for `/api/*` when `http.token` / `http.tokens` are set (ADR 0016 scopes)
27
- - WebSocket upgrade auth (Authorization or `?token=`); demo scope limited to `/sandbox`
28
- - `readJsonBody()` helper for JSON POST/PUT bodies (no Koa)
47
+ - Bearer auth for `/api/*` (ADR 0049): `http.token` is a bootstrap token that only reaches
48
+ `/auth/setup*` until the first owner exists; afterwards only Console user sessions and API tokens
49
+ (supplied by a pluggable `ConsoleAccessProvider` on `tokenRegistry`) are accepted, and every
50
+ route / RPC is checked against the permission map in `@zhin.js/console-protocol`
51
+ - WebSocket upgrade auth (Authorization or `?token=`), checked per path permission
52
+ - `readJsonBody()` / `readTextBody()` / `readBodyBytes()` (bounded, default 1 MiB, 413 after
53
+ draining) and `jsonResponse()` helpers (no Koa)
29
54
 
30
55
  ```ts
31
- import { httpHostToken, type HttpHost } from '@zhin.js/host-http';
56
+ import {
57
+ createResponseStream, httpHostToken, jsonResponse, readJsonBody, SSE_HEADERS,
58
+ verifyProtocolAccessToken,
59
+ } from '@zhin.js/host-http';
32
60
 
33
- const http = context.use(httpHostToken);
34
- const handle = http.ws('/sandbox');
35
- handle.onConnection(({ socket, authScope }) => { /* ... */ });
61
+ const http = context.resources.use(httpHostToken);
36
62
 
37
- http.route('GET', '/api/secure', (_req, res, _url, scope) => {
38
- res.writeHead(200, { 'content-type': 'application/json' });
39
- res.end(JSON.stringify({ scope }));
63
+ http.route('POST', '/api/echo', async (request, { principal }) => {
64
+ const body = await readJsonBody<{ text?: string }>(request);
65
+ return jsonResponse(200, { success: true, data: { text: body?.text, kind: principal?.kind } });
66
+ });
67
+
68
+ http.route('GET', '/api/ticks', () => {
69
+ const stream = createResponseStream();
70
+ const timer = setInterval(() => stream.write('data: tick\n\n'), 1000);
71
+ stream.onClose(() => clearInterval(timer)); // client disconnected
72
+ return new Response(stream.stream, { headers: SSE_HEADERS });
73
+ });
74
+
75
+ // A plugin socket authenticates itself (Console credentials are not accepted here).
76
+ http.ws('/my-protocol', { auth: 'protocol' }).onConnection((connection) => {
77
+ if (!verifyProtocolAccessToken(config.accessToken, connection)) return connection.close(4003, 'Unauthorized');
78
+ connection.onMessage((data) => connection.send(typeof data === 'string' ? `echo:${data}` : data));
40
79
  });
41
80
  ```
42
81
 
82
+ Webhook routes check their own credential with `verifyProtocolAccessToken(token, { headers:
83
+ request.headers, url: context.url, remoteAddress: context.remoteAddress })`.
84
+
43
85
  ## Config (`http` in Root YAML)
44
86
 
45
87
  ```yaml
46
88
  http:
47
89
  host: 127.0.0.1
48
90
  port: 8086
49
- token: ${HTTP_TOKEN} # full scope
50
- tokens:
51
- - token: ${DEMO_TOKEN}
52
- scope: demo
91
+ token: ${HTTP_TOKEN} # bootstrap: creates the first owner, rejected afterwards
53
92
  corsOrigins:
54
93
  - https://example.test
55
94
  base: /api # auth prefix
@@ -74,8 +113,8 @@ not provide an OS sandbox against a local process concurrently replacing directo
74
113
 
75
114
  ### Saved configuration explanations
76
115
 
77
- The full-scope `config:explain` Console RPC reuses the composition root's
116
+ The `config:explain` Console RPC (requires `ops.config.read`) reuses the composition root's
78
117
  `PluginManagementPort.explainConfiguration(instanceKey, expectedRevision)` authority.
79
- The route returns metadata only, rejects demo before reading configuration, and normalizes
118
+ The route returns metadata only, checks the caller's permission before reading configuration, and normalizes
80
119
  errors without forwarding validator details or causes. It does not apply/restart the Runtime,
81
120
  expand values for clients, execute plugin code, or perform platform/network actions.
@@ -0,0 +1,127 @@
1
+ import { type ConsoleGrant, type ConsolePermission, type ConsolePluginPermissionInfo, type ConsolePluginPermissionLevelResolver, type ConsoleRpcPermissionRule, type ConsoleScopeTarget } from '@zhin.js/console-protocol';
2
+ /**
3
+ * Console principals (ADR 0049 §1):
4
+ * - `bootstrap`: the `http.token` before the first owner exists (`open: false`), or a Host
5
+ * without any credential before setup (`open: true`, today's local behaviour).
6
+ * - `user`: a session from `/auth/login`; `api-token`: a user-issued long-lived token.
7
+ */
8
+ export type ConsolePrincipal = ConsoleBootstrapPrincipal | ConsoleUserPrincipal;
9
+ export interface ConsoleBootstrapPrincipal {
10
+ readonly kind: 'bootstrap';
11
+ /** No credential configured at all: the Host is open (local) until setup. */
12
+ readonly open: boolean;
13
+ }
14
+ export interface ConsoleUserPrincipal {
15
+ readonly kind: 'user' | 'api-token';
16
+ readonly userId: string;
17
+ readonly username: string;
18
+ readonly displayName: string;
19
+ /** Session id (token hash) or API token id; never the bearer itself. */
20
+ readonly credentialId: string;
21
+ readonly grants: readonly ConsoleGrant[];
22
+ /** Role names across all bindings (matched against Page `requiredRoles`). */
23
+ readonly roles: readonly string[];
24
+ }
25
+ export interface ConsoleRequestMeta {
26
+ readonly ip?: string;
27
+ readonly userAgent?: string;
28
+ /**
29
+ * The request carried proxy forwarding headers (`X-Forwarded-For` / `Forwarded`). Such a
30
+ * request never counts as local: an open Host (no bootstrap token, no owner) refuses it.
31
+ */
32
+ readonly forwarded?: boolean;
33
+ /**
34
+ * An in-process caller with no network peer (tests, embedding), set explicitly by the caller.
35
+ * An open Host admits it like a loopback peer; a request without `ip` and without this marker
36
+ * is never local.
37
+ */
38
+ readonly inProcess?: boolean;
39
+ }
40
+ export interface ConsoleAuditEntry {
41
+ readonly actor: string;
42
+ readonly action: string;
43
+ readonly target?: string;
44
+ readonly outcome: 'ok' | 'denied' | 'error';
45
+ /** Never values: RPC type, target, parameter key names. */
46
+ readonly detail?: Readonly<Record<string, unknown>>;
47
+ readonly ip?: string;
48
+ }
49
+ /**
50
+ * Pluggable user/session backend supplied by the composition root (`basic/cli`), so this
51
+ * package never imports the database layer.
52
+ */
53
+ export interface ConsoleAccessProvider {
54
+ /** True once an enabled user with a global `owner` binding exists. */
55
+ initialized(): boolean;
56
+ /** Resolves a session or API token. Unknown, expired or revoked → null. */
57
+ authenticate(token: string, meta: ConsoleRequestMeta): Promise<ConsoleUserPrincipal | null>;
58
+ audit?(entry: ConsoleAuditEntry): void;
59
+ }
60
+ export declare function isUserPrincipal(principal: ConsolePrincipal | undefined): principal is ConsoleUserPrincipal;
61
+ /**
62
+ * Whether the request carried an explicit credential: a user session / API token, or the
63
+ * still-valid bootstrap token (`{ kind: 'bootstrap', open: false }`). An open Host is not explicit.
64
+ */
65
+ export declare function isExplicitConsolePrincipal(principal: ConsolePrincipal | undefined): boolean;
66
+ export declare function auditActor(principal: ConsolePrincipal | undefined): string;
67
+ /** Audit/session metadata of a request: peer address and a bounded user agent. */
68
+ export declare function requestMeta(request: {
69
+ readonly headers: Headers;
70
+ }, remoteAddress?: string, options?: {
71
+ readonly inProcess?: boolean;
72
+ }): ConsoleRequestMeta;
73
+ /**
74
+ * Whether the principal holds `permission` at `target` (bootstrap: only an open Host). Plugin
75
+ * permissions also match by level; `levelOf` supplies declared levels (default: inferred).
76
+ */
77
+ export declare function principalAllows(principal: ConsolePrincipal | undefined, permission: string, target?: ConsoleScopeTarget, levelOf?: ConsolePluginPermissionLevelResolver): boolean;
78
+ /**
79
+ * Permissions held through global bindings, sorted (what `/auth/me` and page gating use).
80
+ * With a plugin permission catalog, `plugin.perms.<level>` expands to the catalog ids of that level.
81
+ */
82
+ export declare function globalPermissions(principal: ConsolePrincipal | undefined, pluginPermissions?: Iterable<Pick<ConsolePluginPermissionInfo, 'id' | 'level'>>): readonly string[];
83
+ export type ConsoleRpcDecision = Readonly<{
84
+ allowed: true;
85
+ rule: ConsoleRpcPermissionRule | null;
86
+ permission: ConsolePermission | null;
87
+ write: boolean;
88
+ /**
89
+ * Plugin Console RPC for a signed-in principal: admitted here only so the plugin RPC
90
+ * dispatcher can check the method's own (or page / fallback) permissions. The caller must
91
+ * deny when no plugin dispatcher answers.
92
+ */
93
+ delegated?: true;
94
+ }> | Readonly<{
95
+ allowed: false;
96
+ code: 'setup_required' | 'unmapped' | 'forbidden';
97
+ error: string;
98
+ permission: ConsolePermission | null;
99
+ }>;
100
+ /** RPC authorization for one principal and message (deny by default). */
101
+ export declare function authorizeConsoleRpc(principal: ConsolePrincipal, message: Readonly<Record<string, unknown>>): ConsoleRpcDecision;
102
+ /**
103
+ * For plugin RPC: whether the principal holds the rule's write permission (`feature.configure`,
104
+ * or `endpoint.manage` for adapter account methods) at its target. The plugin RPC dispatcher
105
+ * authorizes each method itself; this is only the catalog fallback.
106
+ */
107
+ export declare function pluginRpcWriteAllowed(principal: ConsolePrincipal, rule: ConsoleRpcPermissionRule | null): boolean;
108
+ export type ConsoleHttpDecision = Readonly<{
109
+ allowed: true;
110
+ permission: string | null;
111
+ }> | Readonly<{
112
+ allowed: false;
113
+ status: 401 | 403;
114
+ code: 'unauthorized' | 'forbidden' | 'setup_required';
115
+ error: string;
116
+ permission: string | null;
117
+ }>;
118
+ /**
119
+ * HTTP authorization of a route under the API base. `principal` is undefined when no valid
120
+ * credential was presented.
121
+ */
122
+ export declare function authorizeConsoleHttp(principal: ConsolePrincipal | undefined, method: string, relativePath: string): ConsoleHttpDecision;
123
+ /** WebSocket upgrade authorization for non-bootstrap principals. */
124
+ export declare function authorizeConsoleWebSocket(principal: ConsolePrincipal, pathname: string): boolean;
125
+ /** Audit detail for an RPC: type, target and parameter key names only. */
126
+ export declare function rpcAuditDetail(message: Readonly<Record<string, unknown>>): Readonly<Record<string, unknown>>;
127
+ export declare function rpcAuditTarget(rule: ConsoleRpcPermissionRule | null): string | undefined;
@@ -0,0 +1,148 @@
1
+ import { CONSOLE_PERMISSION_LIST, CONSOLE_WRITE_PERMISSIONS, expandPluginPermissions, grantsAllow, isPluginConsoleRpcType, resolveConsoleHttpPermission, resolveConsoleRpcPermission, resolveConsoleWsPermission, } from '@zhin.js/console-protocol';
2
+ import { hasForwardingHeaders } from './trust-proxy.js';
3
+ export function isUserPrincipal(principal) {
4
+ return principal?.kind === 'user' || principal?.kind === 'api-token';
5
+ }
6
+ /**
7
+ * Whether the request carried an explicit credential: a user session / API token, or the
8
+ * still-valid bootstrap token (`{ kind: 'bootstrap', open: false }`). An open Host is not explicit.
9
+ */
10
+ export function isExplicitConsolePrincipal(principal) {
11
+ return isUserPrincipal(principal) || (principal?.kind === 'bootstrap' && !principal.open);
12
+ }
13
+ export function auditActor(principal) {
14
+ if (!principal)
15
+ return 'anonymous';
16
+ if (principal.kind === 'bootstrap')
17
+ return 'bootstrap';
18
+ return principal.kind === 'user' ? `user:${principal.userId}` : `api-token:${principal.credentialId}`;
19
+ }
20
+ /** Audit/session metadata of a request: peer address and a bounded user agent. */
21
+ export function requestMeta(request, remoteAddress, options = {}) {
22
+ const agent = request.headers.get('user-agent');
23
+ return Object.freeze({
24
+ ...(remoteAddress ? { ip: remoteAddress } : {}),
25
+ ...(options.inProcess === true && !remoteAddress ? { inProcess: true } : {}),
26
+ ...(typeof agent === 'string' ? { userAgent: agent.slice(0, 256) } : {}),
27
+ ...(hasForwardingHeaders(request.headers) ? { forwarded: true } : {}),
28
+ });
29
+ }
30
+ /**
31
+ * Whether the principal holds `permission` at `target` (bootstrap: only an open Host). Plugin
32
+ * permissions also match by level; `levelOf` supplies declared levels (default: inferred).
33
+ */
34
+ export function principalAllows(principal, permission, target, levelOf) {
35
+ if (!principal)
36
+ return false;
37
+ if (principal.kind === 'bootstrap')
38
+ return principal.open;
39
+ return grantsAllow(principal.grants, permission, target, levelOf);
40
+ }
41
+ /**
42
+ * Permissions held through global bindings, sorted (what `/auth/me` and page gating use).
43
+ * With a plugin permission catalog, `plugin.perms.<level>` expands to the catalog ids of that level.
44
+ */
45
+ export function globalPermissions(principal, pluginPermissions = []) {
46
+ if (!principal)
47
+ return Object.freeze([]);
48
+ if (principal.kind === 'bootstrap')
49
+ return principal.open ? CONSOLE_PERMISSION_LIST : Object.freeze([]);
50
+ const held = new Set();
51
+ for (const grant of principal.grants) {
52
+ if (grant.scope.kind === 'global')
53
+ for (const permission of grant.permissions)
54
+ held.add(permission);
55
+ }
56
+ return expandPluginPermissions([...held], pluginPermissions);
57
+ }
58
+ /** RPC authorization for one principal and message (deny by default). */
59
+ export function authorizeConsoleRpc(principal, message) {
60
+ const rule = resolveConsoleRpcPermission(message.type, message);
61
+ const permission = rule ? (rule.kind === 'exact' ? rule.permission : rule.read) : null;
62
+ if (principal.kind === 'bootstrap') {
63
+ if (principal.open) {
64
+ return Object.freeze({ allowed: true, rule, permission, write: rule ? isWriteRule(rule) : true });
65
+ }
66
+ return Object.freeze({ allowed: false, code: 'setup_required', error: '请先完成初始化:创建第一个所有者', permission });
67
+ }
68
+ if (!rule) {
69
+ return Object.freeze({ allowed: false, code: 'unmapped', error: `RPC "${String(message.type ?? '')}" 未登记权限`, permission: null });
70
+ }
71
+ if (isPluginConsoleRpcType(message.type)) {
72
+ return Object.freeze({ allowed: true, rule, permission, write: isWriteRule(rule), delegated: true });
73
+ }
74
+ if (!grantsAllow(principal.grants, permission, rule.target)) {
75
+ return Object.freeze({ allowed: false, code: 'forbidden', error: `缺少权限 ${permission}`, permission });
76
+ }
77
+ return Object.freeze({ allowed: true, rule, permission, write: isWriteRule(rule) });
78
+ }
79
+ function isWriteRule(rule) {
80
+ return rule.kind === 'exact' ? CONSOLE_WRITE_PERMISSIONS.has(rule.permission) : false;
81
+ }
82
+ /**
83
+ * For plugin RPC: whether the principal holds the rule's write permission (`feature.configure`,
84
+ * or `endpoint.manage` for adapter account methods) at its target. The plugin RPC dispatcher
85
+ * authorizes each method itself; this is only the catalog fallback.
86
+ */
87
+ export function pluginRpcWriteAllowed(principal, rule) {
88
+ if (principal.kind === 'bootstrap')
89
+ return principal.open;
90
+ if (!rule)
91
+ return false;
92
+ const permission = rule.kind === 'exact' ? rule.permission : rule.write;
93
+ return grantsAllow(principal.grants, permission, rule.target);
94
+ }
95
+ /**
96
+ * HTTP authorization of a route under the API base. `principal` is undefined when no valid
97
+ * credential was presented.
98
+ */
99
+ export function authorizeConsoleHttp(principal, method, relativePath) {
100
+ const rule = resolveConsoleHttpPermission(method, relativePath);
101
+ const permission = rule?.permission ?? null;
102
+ if (rule?.permission === 'public')
103
+ return Object.freeze({ allowed: true, permission });
104
+ if (!principal) {
105
+ return Object.freeze({ allowed: false, status: 401, code: 'unauthorized', error: 'Invalid or missing token', permission });
106
+ }
107
+ if (principal.kind === 'bootstrap') {
108
+ if (principal.open || rule?.permission === 'bootstrap')
109
+ return Object.freeze({ allowed: true, permission });
110
+ return Object.freeze({ allowed: false, status: 403, code: 'setup_required', error: '初始化令牌只能用于创建第一个所有者', permission });
111
+ }
112
+ if (!rule) {
113
+ return Object.freeze({ allowed: false, status: 403, code: 'forbidden', error: 'Route is not registered with a Console permission', permission: null });
114
+ }
115
+ if (rule.permission === 'rpc' || rule.permission === 'authenticated' || rule.permission === 'bootstrap') {
116
+ return Object.freeze({ allowed: true, permission });
117
+ }
118
+ // Scoped permissions over plain HTTP have no knowable target: a global binding is required.
119
+ if (grantsAllow(principal.grants, rule.permission))
120
+ return Object.freeze({ allowed: true, permission });
121
+ return Object.freeze({ allowed: false, status: 403, code: 'forbidden', error: `缺少权限 ${rule.permission}`, permission });
122
+ }
123
+ /** WebSocket upgrade authorization for non-bootstrap principals. */
124
+ export function authorizeConsoleWebSocket(principal, pathname) {
125
+ // Only the Console's own sockets are host-authenticated; plugin sockets use `auth: 'protocol'`.
126
+ const permission = resolveConsoleWsPermission(pathname);
127
+ if (principal.kind === 'bootstrap')
128
+ return principal.open;
129
+ return permission !== null && grantsAllow(principal.grants, permission);
130
+ }
131
+ /** Audit detail for an RPC: type, target and parameter key names only. */
132
+ export function rpcAuditDetail(message) {
133
+ const data = message.data && typeof message.data === 'object' && !Array.isArray(message.data)
134
+ ? Object.keys(message.data).sort()
135
+ : [];
136
+ const keys = Object.keys(message).filter(key => key !== 'type' && key !== 'requestId' && key !== 'data').sort();
137
+ return Object.freeze({ type: String(message.type ?? ''), keys: Object.freeze([...new Set([...keys, ...data])]) });
138
+ }
139
+ export function rpcAuditTarget(rule) {
140
+ if (!rule)
141
+ return undefined;
142
+ const target = rule.target;
143
+ if (target.kind === 'adapter')
144
+ return `adapter:${target.adapter}`;
145
+ if (target.kind === 'endpoint')
146
+ return `endpoint:${target.adapter}/${target.endpoint}`;
147
+ return undefined;
148
+ }
@@ -0,0 +1,9 @@
1
+ import type { HttpHost } from './http-host.js';
2
+ import type { HttpHostAddress } from './host-options.js';
3
+ /** @internal Listener groups expose every bound address through this key. */
4
+ export declare const httpHostAddressProvider: unique symbol;
5
+ export interface AddressAwareHttpHost {
6
+ readonly [httpHostAddressProvider]: () => readonly HttpHostAddress[];
7
+ }
8
+ /** Returns every bound listener, ordered primary/local first. */
9
+ export declare function listHttpHostAddresses(host: HttpHost): readonly HttpHostAddress[];
@@ -0,0 +1,9 @@
1
+ /** @internal Listener groups expose every bound address through this key. */
2
+ export const httpHostAddressProvider = Symbol('HttpHostAddressProvider');
3
+ /** Returns every bound listener, ordered primary/local first. */
4
+ export function listHttpHostAddresses(host) {
5
+ const provider = host[httpHostAddressProvider];
6
+ if (typeof provider === 'function')
7
+ return Object.freeze([...provider.call(host)]);
8
+ return Object.freeze(host.address ? [host.address] : []);
9
+ }
@@ -0,0 +1,43 @@
1
+ import { type GenerationAdmissionBindable } from '@zhin.js/plugin-runtime';
2
+ import { type HttpHost, type HttpRouterOptions } from '../http-host.js';
3
+ import type { HttpHostAddress } from '../host-options.js';
4
+ import { type AddressAwareHttpHost } from '../addresses.js';
5
+ import { type BridgeLimits, type BridgeRouteTable, type GatewayToInstanceMessage, type InstanceToGatewayMessage } from './protocol.js';
6
+ /** Close code / reason used when the gateway revoked the socket's principal. */
7
+ export declare const PRINCIPAL_REVOKED_CLOSE_CODE = 4401;
8
+ /** What the bridged Host needs from the control channel (see `openInstanceControlChannel`). */
9
+ export interface BridgeChannel {
10
+ send(message: InstanceToGatewayMessage): void;
11
+ onMessage(listener: (message: GatewayToInstanceMessage) => void): () => void;
12
+ onDisconnect(listener: () => void): () => void;
13
+ /** Keeps the process alive while the Host listens (see `InstanceControlChannel.hold`). */
14
+ hold?(): () => void;
15
+ }
16
+ export interface BridgedHttpHostOptions extends Omit<HttpRouterOptions, 'address' | 'authentication' | 'secure' | 'token' | 'tokenRegistry'> {
17
+ readonly channel: BridgeChannel;
18
+ readonly limits?: Partial<BridgeLimits>;
19
+ }
20
+ /** @public Instance HTTP Host carried by the gateway bridge. */
21
+ export interface BridgedHttpHost extends HttpHost, GenerationAdmissionBindable<HttpHost>, AddressAwareHttpHost {
22
+ /**
23
+ * Opens the bridge: announces the route table and starts serving forwarded requests.
24
+ * Resolves with the gateway's primary address once the gateway announced it (else undefined).
25
+ */
26
+ listen(): Promise<HttpHostAddress | undefined>;
27
+ /** Ends every exchange and socket, withdraws the route table and drops all routes. */
28
+ close(): Promise<void>;
29
+ /** Route table as the gateway sees it. */
30
+ routeTable(): BridgeRouteTable;
31
+ /** Announces the table again, tagged with `generation` (call on generation commit). */
32
+ publishRoutes(generation?: number): void;
33
+ /** Open HTTP exchanges plus WebSocket connections. */
34
+ readonly activeStreams: number;
35
+ /**
36
+ * Called when the gateway revoked a principal (after its exchanges and sockets were closed),
37
+ * for state the bridge does not see (e.g. sessions keyed by user). Returns an unsubscriber.
38
+ */
39
+ onPrincipalRevoked(listener: (principalId: string) => void): () => void;
40
+ }
41
+ export declare function createBridgedHttpHost(options: BridgedHttpHostOptions): BridgedHttpHost;
42
+ /** Close codes a peer may put on the wire; reserved ones (1005, 1006, 1015) become 1000. */
43
+ export declare function sendableCloseCode(code: number): number;