@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.
- 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
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 `packages/
|
|
8
|
-
and `
|
|
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
|
-
|
|
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
|
|
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
|
-
-
|
|
27
|
-
|
|
28
|
-
|
|
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 {
|
|
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('
|
|
38
|
-
|
|
39
|
-
|
|
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} #
|
|
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
|
|
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,
|
|
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[];
|
package/lib/addresses.js
ADDED
|
@@ -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;
|