@zhin.js/host-http 1.1.16 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -21
- package/lib/access-control.d.ts +127 -0
- package/lib/access-control.js +148 -0
- package/lib/addresses.d.ts +9 -0
- package/lib/addresses.js +9 -0
- package/lib/bridge/bridged-host.d.ts +43 -0
- package/lib/bridge/bridged-host.js +497 -0
- package/lib/bridge/control-channel.d.ts +59 -0
- package/lib/bridge/control-channel.js +131 -0
- package/lib/bridge/flow.d.ts +58 -0
- package/lib/bridge/flow.js +188 -0
- package/lib/bridge/index.d.ts +6 -0
- package/lib/bridge/index.js +6 -0
- package/lib/bridge/instance-transport.d.ts +112 -0
- package/lib/bridge/instance-transport.js +532 -0
- package/lib/bridge/protocol.d.ts +242 -0
- package/lib/bridge/protocol.js +369 -0
- package/lib/console-events.d.ts +5 -3
- package/lib/console-events.js +53 -27
- package/lib/console-plugin-rpc.d.ts +5 -1
- package/lib/console-plugin-rpc.js +5 -40
- package/lib/console-rest-pages-helpers.d.ts +4 -5
- package/lib/console-rest-pages-helpers.js +62 -79
- package/lib/console-rest-pages.js +80 -65
- package/lib/console-rpc-extended/dispatcher.js +1 -6
- package/lib/console-rpc-handlers.d.ts +0 -2
- package/lib/console-rpc-handlers.js +2 -40
- package/lib/console-rpc.d.ts +6 -14
- package/lib/console-rpc.js +6 -6
- package/lib/host-installer.d.ts +6 -3
- package/lib/host-installer.js +10 -40
- package/lib/host-options.d.ts +47 -0
- package/lib/host-options.js +1 -0
- package/lib/http-host.d.ts +124 -48
- package/lib/http-host.js +319 -332
- package/lib/index.d.ts +10 -5
- package/lib/index.js +9 -5
- package/lib/json-body.d.ts +1 -2
- package/lib/json-body.js +1 -2
- package/lib/marketplace-registry.d.ts +17 -0
- package/lib/marketplace-registry.js +52 -0
- package/lib/node/index.d.ts +10 -0
- package/lib/node/index.js +10 -0
- package/lib/{listener-group.d.ts → node/listener-group.d.ts} +2 -3
- package/lib/{listener-group.js → node/listener-group.js} +6 -10
- package/lib/node/listener.d.ts +26 -0
- package/lib/node/listener.js +231 -0
- package/lib/node/node-http.d.ts +38 -0
- package/lib/node/node-http.js +272 -0
- package/lib/node/server.d.ts +13 -0
- package/lib/node/server.js +64 -0
- package/lib/protocol-auth.d.ts +24 -0
- package/lib/protocol-auth.js +48 -0
- package/lib/public-api.d.ts +1 -1
- package/lib/token-registry.d.ts +54 -36
- package/lib/token-registry.js +133 -126
- package/lib/trust-proxy.d.ts +22 -0
- package/lib/trust-proxy.js +168 -0
- package/package.json +14 -6
- package/src/access-control.ts +255 -0
- package/src/addresses.ts +16 -0
- package/src/bridge/bridged-host.ts +548 -0
- package/src/bridge/control-channel.ts +173 -0
- package/src/bridge/flow.ts +208 -0
- package/src/bridge/index.ts +31 -0
- package/src/bridge/instance-transport.ts +627 -0
- package/src/bridge/protocol.ts +574 -0
- package/src/console-events.ts +59 -28
- package/src/console-plugin-rpc.ts +5 -51
- package/src/console-rest-pages-helpers.ts +60 -82
- package/src/console-rest-pages.ts +87 -67
- package/src/console-rpc-extended/dispatcher.ts +1 -6
- package/src/console-rpc-handlers.ts +2 -31
- package/src/console-rpc.ts +10 -19
- package/src/host-installer.ts +12 -44
- package/src/host-options.ts +47 -0
- package/src/http-host.ts +467 -421
- package/src/index.ts +55 -13
- package/src/json-body.ts +7 -2
- package/src/marketplace-registry.ts +62 -0
- package/src/node/index.ts +21 -0
- package/src/{listener-group.ts → node/listener-group.ts} +13 -24
- package/src/node/listener.ts +265 -0
- package/src/node/node-http.ts +263 -0
- package/src/node/server.ts +82 -0
- package/src/protocol-auth.ts +52 -0
- package/src/public-api.ts +2 -0
- package/src/token-registry.ts +143 -146
- package/src/trust-proxy.ts +178 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CONSOLE_PERMISSION_LIST,
|
|
3
|
+
CONSOLE_WRITE_PERMISSIONS,
|
|
4
|
+
expandPluginPermissions,
|
|
5
|
+
grantsAllow,
|
|
6
|
+
isPluginConsoleRpcType,
|
|
7
|
+
resolveConsoleHttpPermission,
|
|
8
|
+
resolveConsoleRpcPermission,
|
|
9
|
+
resolveConsoleWsPermission,
|
|
10
|
+
type ConsoleGrant,
|
|
11
|
+
type ConsolePermission,
|
|
12
|
+
type ConsolePluginPermissionInfo,
|
|
13
|
+
type ConsolePluginPermissionLevelResolver,
|
|
14
|
+
type ConsoleRpcPermissionRule,
|
|
15
|
+
type ConsoleScopeTarget,
|
|
16
|
+
} from '@zhin.js/console-protocol';
|
|
17
|
+
import { hasForwardingHeaders } from './trust-proxy.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Console principals (ADR 0049 §1):
|
|
21
|
+
* - `bootstrap`: the `http.token` before the first owner exists (`open: false`), or a Host
|
|
22
|
+
* without any credential before setup (`open: true`, today's local behaviour).
|
|
23
|
+
* - `user`: a session from `/auth/login`; `api-token`: a user-issued long-lived token.
|
|
24
|
+
*/
|
|
25
|
+
export type ConsolePrincipal = ConsoleBootstrapPrincipal | ConsoleUserPrincipal;
|
|
26
|
+
|
|
27
|
+
export interface ConsoleBootstrapPrincipal {
|
|
28
|
+
readonly kind: 'bootstrap';
|
|
29
|
+
/** No credential configured at all: the Host is open (local) until setup. */
|
|
30
|
+
readonly open: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ConsoleUserPrincipal {
|
|
34
|
+
readonly kind: 'user' | 'api-token';
|
|
35
|
+
readonly userId: string;
|
|
36
|
+
readonly username: string;
|
|
37
|
+
readonly displayName: string;
|
|
38
|
+
/** Session id (token hash) or API token id; never the bearer itself. */
|
|
39
|
+
readonly credentialId: string;
|
|
40
|
+
readonly grants: readonly ConsoleGrant[];
|
|
41
|
+
/** Role names across all bindings (matched against Page `requiredRoles`). */
|
|
42
|
+
readonly roles: readonly string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ConsoleRequestMeta {
|
|
46
|
+
readonly ip?: string;
|
|
47
|
+
readonly userAgent?: string;
|
|
48
|
+
/**
|
|
49
|
+
* The request carried proxy forwarding headers (`X-Forwarded-For` / `Forwarded`). Such a
|
|
50
|
+
* request never counts as local: an open Host (no bootstrap token, no owner) refuses it.
|
|
51
|
+
*/
|
|
52
|
+
readonly forwarded?: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* An in-process caller with no network peer (tests, embedding), set explicitly by the caller.
|
|
55
|
+
* An open Host admits it like a loopback peer; a request without `ip` and without this marker
|
|
56
|
+
* is never local.
|
|
57
|
+
*/
|
|
58
|
+
readonly inProcess?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ConsoleAuditEntry {
|
|
62
|
+
readonly actor: string;
|
|
63
|
+
readonly action: string;
|
|
64
|
+
readonly target?: string;
|
|
65
|
+
readonly outcome: 'ok' | 'denied' | 'error';
|
|
66
|
+
/** Never values: RPC type, target, parameter key names. */
|
|
67
|
+
readonly detail?: Readonly<Record<string, unknown>>;
|
|
68
|
+
readonly ip?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Pluggable user/session backend supplied by the composition root (`basic/cli`), so this
|
|
73
|
+
* package never imports the database layer.
|
|
74
|
+
*/
|
|
75
|
+
export interface ConsoleAccessProvider {
|
|
76
|
+
/** True once an enabled user with a global `owner` binding exists. */
|
|
77
|
+
initialized(): boolean;
|
|
78
|
+
/** Resolves a session or API token. Unknown, expired or revoked → null. */
|
|
79
|
+
authenticate(token: string, meta: ConsoleRequestMeta): Promise<ConsoleUserPrincipal | null>;
|
|
80
|
+
audit?(entry: ConsoleAuditEntry): void;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function isUserPrincipal(principal: ConsolePrincipal | undefined): principal is ConsoleUserPrincipal {
|
|
84
|
+
return principal?.kind === 'user' || principal?.kind === 'api-token';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Whether the request carried an explicit credential: a user session / API token, or the
|
|
89
|
+
* still-valid bootstrap token (`{ kind: 'bootstrap', open: false }`). An open Host is not explicit.
|
|
90
|
+
*/
|
|
91
|
+
export function isExplicitConsolePrincipal(principal: ConsolePrincipal | undefined): boolean {
|
|
92
|
+
return isUserPrincipal(principal) || (principal?.kind === 'bootstrap' && !principal.open);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function auditActor(principal: ConsolePrincipal | undefined): string {
|
|
96
|
+
if (!principal) return 'anonymous';
|
|
97
|
+
if (principal.kind === 'bootstrap') return 'bootstrap';
|
|
98
|
+
return principal.kind === 'user' ? `user:${principal.userId}` : `api-token:${principal.credentialId}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Audit/session metadata of a request: peer address and a bounded user agent. */
|
|
102
|
+
export function requestMeta(
|
|
103
|
+
request: { readonly headers: Headers },
|
|
104
|
+
remoteAddress?: string,
|
|
105
|
+
options: { readonly inProcess?: boolean } = {},
|
|
106
|
+
): ConsoleRequestMeta {
|
|
107
|
+
const agent = request.headers.get('user-agent');
|
|
108
|
+
return Object.freeze({
|
|
109
|
+
...(remoteAddress ? { ip: remoteAddress } : {}),
|
|
110
|
+
...(options.inProcess === true && !remoteAddress ? { inProcess: true } : {}),
|
|
111
|
+
...(typeof agent === 'string' ? { userAgent: agent.slice(0, 256) } : {}),
|
|
112
|
+
...(hasForwardingHeaders(request.headers) ? { forwarded: true } : {}),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Whether the principal holds `permission` at `target` (bootstrap: only an open Host). Plugin
|
|
118
|
+
* permissions also match by level; `levelOf` supplies declared levels (default: inferred).
|
|
119
|
+
*/
|
|
120
|
+
export function principalAllows(
|
|
121
|
+
principal: ConsolePrincipal | undefined,
|
|
122
|
+
permission: string,
|
|
123
|
+
target?: ConsoleScopeTarget,
|
|
124
|
+
levelOf?: ConsolePluginPermissionLevelResolver,
|
|
125
|
+
): boolean {
|
|
126
|
+
if (!principal) return false;
|
|
127
|
+
if (principal.kind === 'bootstrap') return principal.open;
|
|
128
|
+
return grantsAllow(principal.grants, permission, target, levelOf);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Permissions held through global bindings, sorted (what `/auth/me` and page gating use).
|
|
133
|
+
* With a plugin permission catalog, `plugin.perms.<level>` expands to the catalog ids of that level.
|
|
134
|
+
*/
|
|
135
|
+
export function globalPermissions(
|
|
136
|
+
principal: ConsolePrincipal | undefined,
|
|
137
|
+
pluginPermissions: Iterable<Pick<ConsolePluginPermissionInfo, 'id' | 'level'>> = [],
|
|
138
|
+
): readonly string[] {
|
|
139
|
+
if (!principal) return Object.freeze([]);
|
|
140
|
+
if (principal.kind === 'bootstrap') return principal.open ? CONSOLE_PERMISSION_LIST : Object.freeze([]);
|
|
141
|
+
const held = new Set<string>();
|
|
142
|
+
for (const grant of principal.grants) {
|
|
143
|
+
if (grant.scope.kind === 'global') for (const permission of grant.permissions) held.add(permission);
|
|
144
|
+
}
|
|
145
|
+
return expandPluginPermissions([...held], pluginPermissions);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export type ConsoleRpcDecision =
|
|
149
|
+
| Readonly<{
|
|
150
|
+
allowed: true; rule: ConsoleRpcPermissionRule | null; permission: ConsolePermission | null; write: boolean;
|
|
151
|
+
/**
|
|
152
|
+
* Plugin Console RPC for a signed-in principal: admitted here only so the plugin RPC
|
|
153
|
+
* dispatcher can check the method's own (or page / fallback) permissions. The caller must
|
|
154
|
+
* deny when no plugin dispatcher answers.
|
|
155
|
+
*/
|
|
156
|
+
delegated?: true;
|
|
157
|
+
}>
|
|
158
|
+
| Readonly<{ allowed: false; code: 'setup_required' | 'unmapped' | 'forbidden'; error: string; permission: ConsolePermission | null }>;
|
|
159
|
+
|
|
160
|
+
/** RPC authorization for one principal and message (deny by default). */
|
|
161
|
+
export function authorizeConsoleRpc(principal: ConsolePrincipal, message: Readonly<Record<string, unknown>>): ConsoleRpcDecision {
|
|
162
|
+
const rule = resolveConsoleRpcPermission(message.type, message);
|
|
163
|
+
const permission = rule ? (rule.kind === 'exact' ? rule.permission : rule.read) : null;
|
|
164
|
+
if (principal.kind === 'bootstrap') {
|
|
165
|
+
if (principal.open) {
|
|
166
|
+
return Object.freeze({ allowed: true, rule, permission, write: rule ? isWriteRule(rule) : true });
|
|
167
|
+
}
|
|
168
|
+
return Object.freeze({ allowed: false, code: 'setup_required', error: '请先完成初始化:创建第一个所有者', permission });
|
|
169
|
+
}
|
|
170
|
+
if (!rule) {
|
|
171
|
+
return Object.freeze({ allowed: false, code: 'unmapped', error: `RPC "${String(message.type ?? '')}" 未登记权限`, permission: null });
|
|
172
|
+
}
|
|
173
|
+
if (isPluginConsoleRpcType(message.type)) {
|
|
174
|
+
return Object.freeze({ allowed: true, rule, permission, write: isWriteRule(rule), delegated: true });
|
|
175
|
+
}
|
|
176
|
+
if (!grantsAllow(principal.grants, permission!, rule.target)) {
|
|
177
|
+
return Object.freeze({ allowed: false, code: 'forbidden', error: `缺少权限 ${permission}`, permission });
|
|
178
|
+
}
|
|
179
|
+
return Object.freeze({ allowed: true, rule, permission, write: isWriteRule(rule) });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function isWriteRule(rule: ConsoleRpcPermissionRule): boolean {
|
|
183
|
+
return rule.kind === 'exact' ? CONSOLE_WRITE_PERMISSIONS.has(rule.permission) : false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* For plugin RPC: whether the principal holds the rule's write permission (`feature.configure`,
|
|
188
|
+
* or `endpoint.manage` for adapter account methods) at its target. The plugin RPC dispatcher
|
|
189
|
+
* authorizes each method itself; this is only the catalog fallback.
|
|
190
|
+
*/
|
|
191
|
+
export function pluginRpcWriteAllowed(principal: ConsolePrincipal, rule: ConsoleRpcPermissionRule | null): boolean {
|
|
192
|
+
if (principal.kind === 'bootstrap') return principal.open;
|
|
193
|
+
if (!rule) return false;
|
|
194
|
+
const permission = rule.kind === 'exact' ? rule.permission : rule.write;
|
|
195
|
+
return grantsAllow(principal.grants, permission, rule.target);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export type ConsoleHttpDecision =
|
|
199
|
+
| Readonly<{ allowed: true; permission: string | null }>
|
|
200
|
+
| Readonly<{ allowed: false; status: 401 | 403; code: 'unauthorized' | 'forbidden' | 'setup_required'; error: string; permission: string | null }>;
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* HTTP authorization of a route under the API base. `principal` is undefined when no valid
|
|
204
|
+
* credential was presented.
|
|
205
|
+
*/
|
|
206
|
+
export function authorizeConsoleHttp(
|
|
207
|
+
principal: ConsolePrincipal | undefined,
|
|
208
|
+
method: string,
|
|
209
|
+
relativePath: string,
|
|
210
|
+
): ConsoleHttpDecision {
|
|
211
|
+
const rule = resolveConsoleHttpPermission(method, relativePath);
|
|
212
|
+
const permission = rule?.permission ?? null;
|
|
213
|
+
if (rule?.permission === 'public') return Object.freeze({ allowed: true, permission });
|
|
214
|
+
if (!principal) {
|
|
215
|
+
return Object.freeze({ allowed: false, status: 401, code: 'unauthorized', error: 'Invalid or missing token', permission });
|
|
216
|
+
}
|
|
217
|
+
if (principal.kind === 'bootstrap') {
|
|
218
|
+
if (principal.open || rule?.permission === 'bootstrap') return Object.freeze({ allowed: true, permission });
|
|
219
|
+
return Object.freeze({ allowed: false, status: 403, code: 'setup_required', error: '初始化令牌只能用于创建第一个所有者', permission });
|
|
220
|
+
}
|
|
221
|
+
if (!rule) {
|
|
222
|
+
return Object.freeze({ allowed: false, status: 403, code: 'forbidden', error: 'Route is not registered with a Console permission', permission: null });
|
|
223
|
+
}
|
|
224
|
+
if (rule.permission === 'rpc' || rule.permission === 'authenticated' || rule.permission === 'bootstrap') {
|
|
225
|
+
return Object.freeze({ allowed: true, permission });
|
|
226
|
+
}
|
|
227
|
+
// Scoped permissions over plain HTTP have no knowable target: a global binding is required.
|
|
228
|
+
if (grantsAllow(principal.grants, rule.permission)) return Object.freeze({ allowed: true, permission });
|
|
229
|
+
return Object.freeze({ allowed: false, status: 403, code: 'forbidden', error: `缺少权限 ${rule.permission}`, permission });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** WebSocket upgrade authorization for non-bootstrap principals. */
|
|
233
|
+
export function authorizeConsoleWebSocket(principal: ConsolePrincipal, pathname: string): boolean {
|
|
234
|
+
// Only the Console's own sockets are host-authenticated; plugin sockets use `auth: 'protocol'`.
|
|
235
|
+
const permission = resolveConsoleWsPermission(pathname);
|
|
236
|
+
if (principal.kind === 'bootstrap') return principal.open;
|
|
237
|
+
return permission !== null && grantsAllow(principal.grants, permission);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Audit detail for an RPC: type, target and parameter key names only. */
|
|
241
|
+
export function rpcAuditDetail(message: Readonly<Record<string, unknown>>): Readonly<Record<string, unknown>> {
|
|
242
|
+
const data = message.data && typeof message.data === 'object' && !Array.isArray(message.data)
|
|
243
|
+
? Object.keys(message.data as Record<string, unknown>).sort()
|
|
244
|
+
: [];
|
|
245
|
+
const keys = Object.keys(message).filter(key => key !== 'type' && key !== 'requestId' && key !== 'data').sort();
|
|
246
|
+
return Object.freeze({ type: String(message.type ?? ''), keys: Object.freeze([...new Set([...keys, ...data])]) });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function rpcAuditTarget(rule: ConsoleRpcPermissionRule | null): string | undefined {
|
|
250
|
+
if (!rule) return undefined;
|
|
251
|
+
const target = rule.target;
|
|
252
|
+
if (target.kind === 'adapter') return `adapter:${target.adapter}`;
|
|
253
|
+
if (target.kind === 'endpoint') return `endpoint:${target.adapter}/${target.endpoint}`;
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
package/src/addresses.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { HttpHost } from './http-host.js';
|
|
2
|
+
import type { HttpHostAddress } from './host-options.js';
|
|
3
|
+
|
|
4
|
+
/** @internal Listener groups expose every bound address through this key. */
|
|
5
|
+
export const httpHostAddressProvider = Symbol('HttpHostAddressProvider');
|
|
6
|
+
|
|
7
|
+
export interface AddressAwareHttpHost {
|
|
8
|
+
readonly [httpHostAddressProvider]: () => readonly HttpHostAddress[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Returns every bound listener, ordered primary/local first. */
|
|
12
|
+
export function listHttpHostAddresses(host: HttpHost): readonly HttpHostAddress[] {
|
|
13
|
+
const provider = (host as Partial<AddressAwareHttpHost>)[httpHostAddressProvider];
|
|
14
|
+
if (typeof provider === 'function') return Object.freeze([...provider.call(host)]);
|
|
15
|
+
return Object.freeze(host.address ? [host.address] : []);
|
|
16
|
+
}
|